Przeglądaj źródła

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.
maziggy 1 miesiąc temu
rodzic
commit
258db95483

Plik diff jest za duży
+ 0 - 0
CHANGELOG.md


+ 65 - 0
backend/app/api/routes/printers.py

@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core import database
 from backend.app.core.auth import (
     RequireCameraStreamTokenIfAuthEnabled,
+    RequireOverlayTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     is_auth_enabled,
 )
@@ -821,6 +822,70 @@ async def get_printer_status(
     )
 
 
+@router.get("/{printer_id}/overlay-status")
+async def get_overlay_status(
+    printer_id: int,
+    _: None = RequireOverlayTokenIfAuthEnabled,
+    db: AsyncSession = Depends(get_db),
+) -> dict:
+    """Everything the streaming overlay (#2613) draws for one printer.
+
+    A token-authenticated sibling of ``get_printer_status`` for embeds with no
+    login session — OBS loads ``/overlay/{id}?token=...`` and this feeds it.
+    Deliberately flat and minimal (name, camera rotation, live print state, and
+    the one setting the overlay reads) rather than the full ``PrinterStatus``:
+    a token holder gets exactly the fields the overlay renders, nothing more.
+
+    Unlike the Cam Wall feed this *includes the print filename* — the overlay
+    names the part on screen — which is why it sits behind its own ``overlay``
+    scope rather than ``camwall``.
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    time_format = await get_setting(db, "time_format") or "system"
+    state = printer_manager.get_status(printer_id)
+
+    if not state:
+        # Never connected this run — mirror get_printer_status()'s disconnected
+        # shape so the overlay renders its offline state rather than erroring.
+        return {
+            "id": printer_id,
+            "name": printer.name,
+            "camera_rotation": printer.camera_rotation or 0,
+            "connected": False,
+            "state": None,
+            "current_print": None,
+            "gcode_file": None,
+            "progress": None,
+            "remaining_time": None,
+            "layer_num": None,
+            "total_layers": None,
+            "stg_cur_name": None,
+            "time_format": time_format,
+        }
+
+    return {
+        "id": printer_id,
+        "name": printer.name,
+        "camera_rotation": printer.camera_rotation or 0,
+        "connected": state.connected,
+        "state": state.state,
+        "current_print": state.current_print,
+        "gcode_file": state.gcode_file,
+        "progress": state.progress,
+        "remaining_time": state.remaining_time,
+        "layer_num": state.layer_num,
+        "total_layers": state.total_layers,
+        "stg_cur_name": get_derived_status_name(state, printer.model),
+        "time_format": time_format,
+    }
+
+
 @router.get("/{printer_id}/current-print-user")
 async def get_current_print_user(
     printer_id: int,

+ 42 - 0
backend/app/core/auth.py

@@ -725,6 +725,23 @@ async def verify_camwall_token(token: str) -> bool:
         return record is not None
 
 
+async def verify_overlay_token(token: str) -> bool:
+    """Verify a streaming-overlay token (#2613). Reusable — does not consume it.
+
+    Like :func:`verify_camwall_token`, only the matching long-lived scope passes:
+    the overlay status feed names the file being printed, so it must not be
+    reachable by a ``camwall`` token (which is trusted to hide the part name) or
+    a bare ``camera_stream`` token (handed out for video alone). The 60-minute
+    ephemeral token belongs to a logged-in browser, which reaches the same data
+    through the ordinary printers API and has no need of this endpoint.
+    """
+    async with async_session() as db:
+        from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
+
+        record = await verify_long_lived(db, token, scope="overlay")
+        return record is not None
+
+
 def verify_password(plain_password: str, hashed_password: str) -> bool:
     """Verify a password against a hash.
 
@@ -1774,6 +1791,31 @@ def require_camwall_token_if_auth_enabled():
 RequireCamWallTokenIfAuthEnabled = Depends(require_camwall_token_if_auth_enabled())
 
 
+def require_overlay_token_if_auth_enabled():
+    """Dependency that validates a streaming-overlay token query param when auth
+    is enabled.
+
+    Used by the read-only overlay status feed (#2613), which OBS (or any
+    embed with no login session) loads with the token in the URL because it
+    has no JWT to carry.
+    """
+
+    async def checker(token: str | None = None) -> None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return  # Auth disabled, allow access
+        if not token or not await verify_overlay_token(token):
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Valid overlay token required. Create one under Settings > API Keys with the 'Streaming Overlay' scope.",
+            )
+
+    return checker
+
+
+RequireOverlayTokenIfAuthEnabled = Depends(require_overlay_token_if_auth_enabled())
+
+
 def require_ownership_permission(
     all_permission: str | Permission,
     own_permission: str | Permission,

+ 7 - 0
backend/app/main.py

@@ -6622,6 +6622,13 @@ PUBLIC_API_PATTERNS = [
     # Camera (streams loaded via <img> tag)
     "/camera/stream",  # /printers/{id}/camera/stream
     "/camera/snapshot",  # /printers/{id}/camera/snapshot
+    # Streaming-overlay status feed (#2613): OBS loads /overlay/{id} with no login
+    # and this backs it, authenticated by an ``overlay``-scoped token in the query
+    # string (same reasoning as the camera streams above — no header to carry a
+    # JWT). "Public" only means the middleware steps aside; the route still runs
+    # RequireOverlayTokenIfAuthEnabled, which rejects an absent, expired, revoked,
+    # or wrong-scoped token — a camwall or camera_stream token does NOT open it.
+    "/overlay-status",  # /printers/{id}/overlay-status
     # Slicer token-authenticated downloads — protocol handlers (bambustudioopen://,
     # orcaslicer://) cannot send auth headers. These endpoints validate a short-lived
     # download token in the URL path instead.

+ 11 - 5
backend/app/services/long_lived_tokens.py

@@ -45,11 +45,17 @@ MAX_TOKEN_LIFETIME_DAYS = 365
 #                   Cam Wall draws: printer names and print state (#2531).
 #                   Strictly wider than camera_stream, so it gets its own scope
 #                   rather than quietly extending tokens already handed out.
-ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream", "camwall"})
-
-# Scopes the camera stream / snapshot endpoints honour. A Cam Wall token has to
-# be able to pull the video its own tiles are showing.
-STREAM_SCOPES: tuple[str, ...] = ("camera_stream", "camwall")
+#   overlay       — the streaming overlay (#2613): the camera stream plus the
+#                   single-printer status the /overlay page draws, which unlike
+#                   the Cam Wall *includes the print filename*. A distinct grant
+#                   precisely because it reveals the part name a camwall token
+#                   is trusted never to expose, so folding it into camwall would
+#                   silently widen every wall token already handed out.
+ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream", "camwall", "overlay"})
+
+# Scopes the camera stream / snapshot endpoints honour. A Cam Wall or overlay
+# token has to be able to pull the video its own view is showing.
+STREAM_SCOPES: tuple[str, ...] = ("camera_stream", "camwall", "overlay")
 
 # Don't write to last_used_at more than once per minute per token. MJPEG
 # streams call verify() at most once per fetch (the browser holds the

+ 216 - 0
backend/tests/integration/test_overlay_status_api.py

@@ -0,0 +1,216 @@
+"""Integration tests for the token-authenticated streaming-overlay feed (#2613).
+
+Like the Cam Wall feed, the overlay endpoint exists as its own scope-gated
+route because a kiosk/OBS URL is not a secret. But it is deliberately *wider*
+than the Cam Wall: it names the file being printed (the overlay draws the part
+on screen). So the tests that matter are the scope boundaries — an overlay
+token must not reach the Cam Wall feed and vice versa, a camwall token must not
+reach the overlay feed (that would leak the filename it is trusted to hide) —
+plus the positive path and the disconnected-printer shape.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _setup_admin(async_client: AsyncClient, *, suffix: str) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": f"overlayadmin{suffix}",
+            "admin_password": "AdminPass1!",
+        },
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": f"overlayadmin{suffix}", "password": "AdminPass1!"},
+    )
+    return login.json()["access_token"]
+
+
+async def _mint(async_client: AsyncClient, jwt: str, *, scope: str, name: str = "obs") -> str:
+    response = await async_client.post(
+        "/api/v1/auth/tokens",
+        headers={"Authorization": f"Bearer {jwt}"},
+        json={"name": name, "expires_in_days": 30, "scope": scope},
+    )
+    assert response.status_code == 201, response.text
+    assert response.json()["scope"] == scope
+    return response.json()["token"]
+
+
+@pytest.fixture
+async def printer_row(db_session):
+    """Insert the printer straight into the DB.
+
+    POST /printers probes the real device before it will store a row, and there
+    is no printer on the other end of a test run.
+    """
+    from backend.app.models.printer import Printer
+
+    printer = Printer(
+        name="Stream P1S",
+        ip_address="192.168.1.88",
+        access_code="12345678",
+        serial_number="01P00A000000002",
+        model="P1S",
+    )
+    db_session.add(printer)
+    await db_session.commit()
+    return printer
+
+
+class TestOverlayFeedAuth:
+    async def test_no_token_is_rejected(self, async_client: AsyncClient, printer_row):
+        await _setup_admin(async_client, suffix="_notoken")
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status")
+        assert response.status_code == 401
+
+    async def test_garbage_token_is_rejected(self, async_client: AsyncClient, printer_row):
+        await _setup_admin(async_client, suffix="_garbage")
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token=bblt_aaaaaaaa_nope")
+        assert response.status_code == 401
+
+    async def test_camera_stream_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row):
+        """A ``camera_stream`` token was handed out for video alone — it must not
+        acquire the live print status (and filename) just because a new feature
+        shipped.
+        """
+        jwt = await _setup_admin(async_client, suffix="_streamscope")
+        stream_token = await _mint(async_client, jwt, scope="camera_stream")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={stream_token}")
+        assert response.status_code == 401
+
+    async def test_camwall_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row):
+        """The crux of a *separate* scope from camwall.
+
+        A Cam Wall token is trusted precisely because it can never name the part
+        being printed. The overlay feed does name it, so a camwall token must be
+        rejected here — otherwise every wall token silently gains filename
+        visibility.
+        """
+        jwt = await _setup_admin(async_client, suffix="_camwallscope")
+        camwall_token = await _mint(async_client, jwt, scope="camwall")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={camwall_token}")
+        assert response.status_code == 401
+
+    async def test_overlay_token_reaches_the_feed(self, async_client: AsyncClient, printer_row):
+        jwt = await _setup_admin(async_client, suffix="_rightscope")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
+        assert response.status_code == 200, response.text
+        assert response.json()["name"] == "Stream P1S"
+
+    async def test_revoked_overlay_token_is_rejected(self, async_client: AsyncClient, printer_row):
+        jwt = await _setup_admin(async_client, suffix="_revoked")
+        created = await async_client.post(
+            "/api/v1/auth/tokens",
+            headers={"Authorization": f"Bearer {jwt}"},
+            json={"name": "obs", "expires_in_days": 30, "scope": "overlay"},
+        )
+        overlay_token = created.json()["token"]
+        await async_client.delete(
+            f"/api/v1/auth/tokens/{created.json()['id']}",
+            headers={"Authorization": f"Bearer {jwt}"},
+        )
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
+        assert response.status_code == 401
+
+
+class TestOverlayFeedPayload:
+    async def test_payload_shape_includes_filename_fields(self, async_client: AsyncClient, printer_row):
+        """Unlike the Cam Wall, the overlay *does* carry the filename fields —
+        that is what distinguishes the scope. Assert the exact key set so the
+        payload can't silently grow to leak more than the overlay draws.
+        """
+        jwt = await _setup_admin(async_client, suffix="_payload")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
+        assert response.status_code == 200
+        entry = response.json()
+
+        # Never the secrets — the URL is on a public stream.
+        for leaked in ("serial_number", "ip_address", "access_code"):
+            assert leaked not in entry, f"{leaked} must not be served to an overlay token"
+
+        assert set(entry) == {
+            "id",
+            "name",
+            "camera_rotation",
+            "connected",
+            "state",
+            "current_print",
+            "gcode_file",
+            "progress",
+            "remaining_time",
+            "layer_num",
+            "total_layers",
+            "stg_cur_name",
+            "time_format",
+        }
+
+    async def test_disconnected_printer_reports_connected_false(self, async_client: AsyncClient, printer_row):
+        """No MQTT client runs in tests, so the printer has no state — the
+        overlay must render its offline state rather than erroring.
+        """
+        jwt = await _setup_admin(async_client, suffix="_offline")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
+        entry = response.json()
+        assert entry["connected"] is False
+        assert entry["state"] is None
+        assert entry["current_print"] is None
+
+    async def test_unknown_printer_is_404_not_401(self, async_client: AsyncClient):
+        """A valid token for a printer id that doesn't exist is a 404 — the token
+        passed the gate, the resource simply isn't there.
+        """
+        jwt = await _setup_admin(async_client, suffix="_404")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        response = await async_client.get(f"/api/v1/printers/99999/overlay-status?token={overlay_token}")
+        assert response.status_code == 404
+
+
+class TestOverlayTokenReachesTheVideo:
+    """The overlay draws the camera feed, so the same token has to satisfy the
+    camera-stream gate.
+    """
+
+    async def test_overlay_token_passes_the_camera_stream_gate(self, async_client: AsyncClient):
+        from backend.app.core.auth import verify_camera_stream_token
+
+        jwt = await _setup_admin(async_client, suffix="_video")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        assert await verify_camera_stream_token(overlay_token) is True
+
+    async def test_overlay_gate_rejects_camera_stream_and_camwall(self, async_client: AsyncClient):
+        from backend.app.core.auth import verify_overlay_token
+
+        jwt = await _setup_admin(async_client, suffix="_gate")
+        stream_token = await _mint(async_client, jwt, scope="camera_stream")
+        camwall_token = await _mint(async_client, jwt, scope="camwall", name="wall")
+
+        assert await verify_overlay_token(stream_token) is False
+        assert await verify_overlay_token(camwall_token) is False
+
+    async def test_camwall_gate_rejects_an_overlay_token(self, async_client: AsyncClient):
+        """Symmetric guard: the new scope must not widen the Cam Wall either."""
+        from backend.app.core.auth import verify_camwall_token
+
+        jwt = await _setup_admin(async_client, suffix="_gate_camwall")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        assert await verify_camwall_token(overlay_token) is False

+ 3 - 2
backend/tests/unit/services/test_long_lived_tokens.py

@@ -107,13 +107,14 @@ async def test_create_rejects_expiry_above_policy_cap(db_session, alice: User):
 
 
 async def test_create_rejects_unsupported_scope(db_session, alice: User):
-    """The scope set is closed: ``camera_stream`` (#1108) and ``camwall`` (#2531).
+    """The scope set is closed: ``camera_stream`` (#1108), ``camwall`` (#2531),
+    and ``overlay`` (#2613).
 
     Pinned deliberately. Adding a scope should be a decision someone makes on
     purpose — a new value here means a new class of thing a URL-borne token can
     reach, so it should not be possible to add one without this line failing.
     """
-    assert {"camera_stream", "camwall"} == set(ALLOWED_SCOPES)
+    assert {"camera_stream", "camwall", "overlay"} == set(ALLOWED_SCOPES)
     with pytest.raises(ValueError, match="unsupported scope"):
         await create_token(
             db_session,

+ 33 - 0
frontend/src/__tests__/pages/CameraTokensPage.test.tsx

@@ -120,6 +120,39 @@ describe('CameraTokensPage', () => {
     ).not.toBeInTheDocument();
   });
 
+  it('offers the overlay scope and shows a ready-made OBS overlay URL (#2613)', async () => {
+    server.use(
+      http.get('*/api/v1/auth/tokens', () => HttpResponse.json([])),
+      http.post('*/api/v1/auth/tokens', async ({ request }) => {
+        const body = await request.json();
+        expect(body).toMatchObject({ name: 'OBS', scope: 'overlay' });
+        return HttpResponse.json(
+          token({
+            id: 43,
+            name: 'OBS',
+            scope: 'overlay',
+            token: 'bblt_abcd1234_secretsecretsecretsecretsecret',
+          }),
+          { status: 201 },
+        );
+      }),
+    );
+
+    const user = userEvent.setup();
+    render(<CameraTokensPage />);
+
+    await screen.findByText(/no tokens yet/i);
+    await user.type(screen.getByLabelText(/token name/i), 'OBS');
+    await user.selectOptions(screen.getByLabelText(/scope/i), 'overlay');
+    await user.click(screen.getByRole('button', { name: /^create$/i }));
+
+    // The created modal hands over the assembled OBS overlay URL carrying the
+    // token, not just the raw token.
+    expect(await screen.findByText(/overlay url for obs/i)).toBeInTheDocument();
+    const url = screen.getByText(/\/overlay\/1\?token=/);
+    expect(url).toHaveTextContent('token=bblt_abcd1234_secretsecretsecretsecretsecret');
+  });
+
   it('clamps the days input to the 365-day policy cap', async () => {
     server.use(
       http.get('*/api/v1/auth/tokens', () => HttpResponse.json([])),

+ 62 - 0
frontend/src/__tests__/pages/StreamOverlayPage.test.tsx

@@ -368,4 +368,66 @@ describe('StreamOverlayPage', () => {
       });
     });
   });
+
+  describe('kiosk token mode (#2613)', () => {
+    const mockOverlayPrinting = {
+      id: 1,
+      name: 'X1 Carbon',
+      camera_rotation: 0,
+      connected: true,
+      state: 'RUNNING',
+      current_print: 'KioskBenchy.gcode.3mf',
+      gcode_file: 'plate_1.gcode',
+      progress: 67,
+      remaining_time: 40,
+      layer_num: 10,
+      total_layers: 20,
+      stg_cur_name: null,
+      time_format: 'system',
+    };
+
+    it('reads the token-authed overlay-status feed and carries the token to the camera', async () => {
+      let overlayHit = false;
+      server.use(
+        http.get('/api/v1/printers/:id/overlay-status', () => {
+          overlayHit = true;
+          return HttpResponse.json(mockOverlayPrinting);
+        })
+      );
+
+      renderOverlayPage(1, '?token=obs-tok');
+
+      await waitFor(() => {
+        expect(screen.getByText('KioskBenchy')).toBeInTheDocument();
+      });
+      expect(overlayHit).toBe(true);
+      expect(screen.getByText('67%')).toBeInTheDocument();
+
+      // The camera <img> must carry the same kiosk token — a fresh OBS browser
+      // has no session to mint a camera stream token from.
+      const img = screen.getByAltText('Camera stream') as HTMLImageElement;
+      expect(img.src).toContain('token=obs-tok');
+    });
+
+    it('does not touch the JWT-only status endpoint or a WebSocket in kiosk mode', async () => {
+      let statusHit = false;
+      server.use(
+        http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json(mockOverlayPrinting)),
+        http.get('/api/v1/printers/:id/status', () => {
+          statusHit = true;
+          return HttpResponse.json(mockStatusIdle);
+        })
+      );
+
+      renderOverlayPage(1, '?token=obs-tok');
+
+      await waitFor(() => {
+        expect(screen.getByText('KioskBenchy')).toBeInTheDocument();
+      });
+      // The logged-in status query is disabled when a token is present, so an
+      // unauthenticated OBS browser never fires a doomed 401 (or opens a socket).
+      expect(statusHit).toBe(false);
+      expect(WebSocket).not.toHaveBeenCalled();
+    });
+  });
 });

+ 30 - 1
frontend/src/api/client.ts

@@ -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) =>

+ 6 - 0
frontend/src/i18n/locales/de.ts

@@ -6678,6 +6678,7 @@ export default {
     scope: {
       camera_stream: 'Kamera-Stream',
       camwall: 'Kamera-Wand',
+      overlay: 'Streaming-Overlay',
     },
     title: 'Kamera-API-Tokens',
     navTitle: 'Kamera-API-Tokens',
@@ -6696,6 +6697,8 @@ export default {
         'Ein Kamera-Stream-Token kann ausschließlich Kamera-Streams und Schnappschüsse abrufen. Geeignet für Home Assistant, Frigate oder alles, was eine einzelne Kamera einbettet.',
       hintCamWall:
         'Ein Kamera-Wand-Token öffnet /camwall auf einem Bildschirm ohne Anmeldung. Es sieht Name und Status jedes Druckers sowie deren Kamera-Streams. Dateinamen, Adressen und Zugangscodes sieht es nicht.',
+      hintOverlay:
+        'Ein Streaming-Overlay-Token öffnet /overlay/{printerId} auf einem Bildschirm ohne Anmeldung – für OBS oder jeden Livestream. Es sieht den Kamera-Stream eines Druckers sowie dessen Live-Druckstatus, einschließlich des auf dem Bildschirm angezeigten Dateinamens. Adressen und Zugangscodes sieht es nicht.',
       title: 'Neues Token erstellen',
       nameLabel: 'Token-Name',
       namePlaceholder: 'z. B. Home Assistant',
@@ -6708,6 +6711,9 @@ export default {
       camWallUrlTitle: 'Kamera-Wand-Adresse für diesen Bildschirm',
       camWallUrlHint:
         'Diese Adresse auf dem Bildschirm öffnen. Wer die Adresse lesen kann, kann die Kamera-Wand sehen — behandeln Sie sie wie einen Schlüssel. Widerrufen Sie das Token, um den Bildschirm abzuschalten.',
+      overlayUrlTitle: 'Overlay-Adresse für OBS',
+      overlayUrlHint:
+        'Fügen Sie dies in OBS als Browser-Quelle hinzu. Ändern Sie die Zahl in /overlay/1 auf die Nummer Ihres Druckers (aus dessen Adresse auf der Seite „Drucker“). Wer die Adresse lesen kann, kann den Stream sehen – behandeln Sie sie wie einen Schlüssel und widerrufen Sie das Token, um sie abzuschalten.',
       title: 'Token erstellt – jetzt kopieren',
       warning:
         'Dies ist das einzige Mal, dass dieser Token sichtbar ist. Nach dem Schließen dieses Dialogs können Sie ihn nie wieder anzeigen.',

+ 6 - 0
frontend/src/i18n/locales/en.ts

@@ -6722,6 +6722,7 @@ export default {
     scope: {
       camera_stream: 'Camera stream',
       camwall: 'Cam Wall',
+      overlay: 'Streaming Overlay',
     },
     title: 'Camera API Tokens',
     navTitle: 'Camera API tokens',
@@ -6740,6 +6741,8 @@ export default {
         'A camera-stream token can only fetch camera streams and snapshots. Use it for Home Assistant, Frigate, or anything embedding a single camera.',
       hintCamWall:
         "A Cam Wall token opens /camwall on a screen with no login. It can see every printer's name and state, and their camera streams. It cannot see filenames, addresses or access codes.",
+      hintOverlay:
+        "A Streaming Overlay token opens /overlay/{printerId} on a screen with no login — for OBS or any live stream. It can see one printer's camera stream plus its live print status, including the filename shown on screen. It cannot see addresses or access codes.",
       title: 'Create new token',
       nameLabel: 'Token name',
       namePlaceholder: 'e.g. Home Assistant',
@@ -6752,6 +6755,9 @@ export default {
       camWallUrlTitle: 'Cam Wall URL for this display',
       camWallUrlHint:
         'Open this on the screen. Anyone who can read the URL can watch the wall, so treat it like a key — revoke the token to cut the display off.',
+      overlayUrlTitle: 'Overlay URL for OBS',
+      overlayUrlHint:
+        "Add this as a Browser Source in OBS. Change the /overlay/1 number to your printer's number (from its URL on the Printers page). Anyone who can read the URL can watch the stream, so treat it like a key — revoke the token to cut it off.",
       title: 'Token created — copy it now',
       warning:
         'This is the only time this token will be visible. After you close this dialog you can never view it again.',

+ 6 - 0
frontend/src/i18n/locales/es.ts

@@ -6687,6 +6687,7 @@ export default {
     scope: {
       camera_stream: 'Transmisión de cámara',
       camwall: 'Muro de cámaras',
+      overlay: 'Superposición de streaming',
     },
     title: 'Tokens de API de la cámara',
     navTitle: 'Tokens de API de la cámara',
@@ -6705,6 +6706,8 @@ export default {
         'Un token de transmisión de cámara solo puede obtener transmisiones e instantáneas. Úsalo para Home Assistant, Frigate o cualquier cosa que incruste una sola cámara.',
       hintCamWall:
         'Un token de muro de cámaras abre /camwall en una pantalla sin iniciar sesión. Puede ver el nombre y el estado de cada impresora, y sus transmisiones de cámara. No puede ver nombres de archivo, direcciones ni códigos de acceso.',
+      hintOverlay:
+        'Un token de superposición de streaming abre /overlay/{printerId} en una pantalla sin iniciar sesión, para OBS o cualquier transmisión en vivo. Puede ver la transmisión de la cámara de una impresora y su estado de impresión en vivo, incluido el nombre de archivo que aparece en pantalla. No puede ver direcciones ni códigos de acceso.',
       title: 'Crear nuevo token',
       nameLabel: 'Nombre del token',
       namePlaceholder: 'p. ej. Home Assistant',
@@ -6717,6 +6720,9 @@ export default {
       camWallUrlTitle: 'Dirección del muro de cámaras para esta pantalla',
       camWallUrlHint:
         'Abre esta dirección en la pantalla. Cualquiera que pueda leerla puede ver el muro, así que trátala como una llave: revoca el token para dejar la pantalla sin acceso.',
+      overlayUrlTitle: 'Dirección de superposición para OBS',
+      overlayUrlHint:
+        'Agrega esto como Fuente de navegador en OBS. Cambia el número de /overlay/1 por el número de tu impresora (de su dirección en la página Impresoras). Cualquiera que pueda leer la dirección puede ver la transmisión, así que trátala como una llave: revoca el token para cortar el acceso.',
       title: 'Token creado — cópielo ahora',
       warning:
         'Esta es la única vez que este token estará visible. Después de cerrar este diálogo no podrá volver a verlo nunca.',

+ 6 - 0
frontend/src/i18n/locales/fr.ts

@@ -6666,6 +6666,7 @@ export default {
     scope: {
       camera_stream: 'Flux de caméra',
       camwall: 'Mur de caméras',
+      overlay: 'Incrustation de streaming',
     },
     title: 'Jetons API caméra',
     navTitle: 'Jetons API caméra',
@@ -6684,6 +6685,8 @@ export default {
         'Un jeton de flux de caméra ne peut récupérer que des flux et des instantanés. À utiliser pour Home Assistant, Frigate ou tout ce qui intègre une seule caméra.',
       hintCamWall:
         "Un jeton Mur de caméras ouvre /camwall sur un écran sans connexion. Il voit le nom et l'état de chaque imprimante, ainsi que leurs flux de caméra. Il ne voit ni les noms de fichiers, ni les adresses, ni les codes d'accès.",
+      hintOverlay:
+        "Un jeton Incrustation de streaming ouvre /overlay/{printerId} sur un écran sans connexion — pour OBS ou tout autre flux en direct. Il voit le flux de caméra d'une imprimante ainsi que son état d'impression en direct, y compris le nom de fichier affiché à l'écran. Il ne voit ni les adresses ni les codes d'accès.",
       title: 'Créer un nouveau jeton',
       nameLabel: 'Nom du jeton',
       namePlaceholder: 'par ex. Home Assistant',
@@ -6696,6 +6699,9 @@ export default {
       camWallUrlTitle: 'Adresse du mur de caméras pour cet écran',
       camWallUrlHint:
         "Ouvrez cette adresse sur l'écran. Quiconque peut lire l'adresse peut regarder le mur : traitez-la comme une clé. Révoquez le jeton pour couper l'écran.",
+      overlayUrlTitle: "Adresse d'incrustation pour OBS",
+      overlayUrlHint:
+        "Ajoutez ceci comme Source navigateur dans OBS. Remplacez le numéro dans /overlay/1 par le numéro de votre imprimante (indiqué dans son adresse sur la page Imprimantes). Quiconque peut lire l'adresse peut regarder le flux : traitez-la comme une clé et révoquez le jeton pour couper l'accès.",
       title: 'Jeton créé – copiez-le maintenant',
       warning:
         'C\'est la seule fois où ce jeton sera visible. Après la fermeture de ce dialogue, vous ne pourrez plus jamais le voir.',

+ 6 - 0
frontend/src/i18n/locales/it.ts

@@ -6665,6 +6665,7 @@ export default {
     scope: {
       camera_stream: 'Flusso della telecamera',
       camwall: 'Muro telecamere',
+      overlay: 'Overlay di streaming',
     },
     title: 'Token API telecamera',
     navTitle: 'Token API telecamera',
@@ -6683,6 +6684,8 @@ export default {
         'Un token del flusso della telecamera può recuperare soltanto flussi e istantanee. Usalo per Home Assistant, Frigate o qualsiasi cosa incorpori una singola telecamera.',
       hintCamWall:
         'Un token Muro telecamere apre /camwall su uno schermo senza login. Vede nome e stato di ogni stampante e i relativi flussi della telecamera. Non vede nomi di file, indirizzi o codici di accesso.',
+      hintOverlay:
+        'Un token Overlay di streaming apre /overlay/{printerId} su uno schermo senza login, per OBS o qualsiasi diretta streaming. Vede il flusso della telecamera di una stampante e il suo stato di stampa in tempo reale, incluso il nome del file mostrato sullo schermo. Non vede indirizzi o codici di accesso.',
       title: 'Crea nuovo token',
       nameLabel: 'Nome token',
       namePlaceholder: 'es. Home Assistant',
@@ -6695,6 +6698,9 @@ export default {
       camWallUrlTitle: 'Indirizzo del muro telecamere per questo schermo',
       camWallUrlHint:
         'Apri questo indirizzo sullo schermo. Chiunque possa leggerlo può guardare il muro, quindi trattalo come una chiave: revoca il token per escludere lo schermo.',
+      overlayUrlTitle: 'Indirizzo overlay per OBS',
+      overlayUrlHint:
+        "Aggiungi questo come Sorgente browser in OBS. Cambia il numero in /overlay/1 con il numero della tua stampante (dall'indirizzo nella pagina Stampanti). Chiunque possa leggere l'indirizzo può guardare lo streaming, quindi trattalo come una chiave: revoca il token per interrompere l'accesso.",
       title: 'Token creato – copialo ora',
       warning:
         'Questa è l\'unica volta in cui questo token sarà visibile. Dopo la chiusura di questa finestra non potrai più visualizzarlo.',

+ 6 - 0
frontend/src/i18n/locales/ja.ts

@@ -6677,6 +6677,7 @@ export default {
     scope: {
       camera_stream: 'カメラストリーム',
       camwall: 'カメラウォール',
+      overlay: '配信オーバーレイ',
     },
     title: 'カメラAPIトークン',
     navTitle: 'カメラAPIトークン',
@@ -6695,6 +6696,8 @@ export default {
         'カメラストリームトークンで取得できるのは、カメラの映像とスナップショットだけです。Home Assistant や Frigate など、単一のカメラを埋め込む用途に使用してください。',
       hintCamWall:
         'カメラウォールトークンは、ログインなしの画面で /camwall を開きます。各プリンターの名前と状態、そしてカメラ映像を見ることができます。ファイル名、アドレス、アクセスコードは見えません。',
+      hintOverlay:
+        '配信オーバーレイトークンは、ログインなしの画面で /overlay/{printerId} を開きます — OBS やライブ配信向けです。1台のプリンターのカメラ映像に加え、画面に表示されるファイル名を含むライブの印刷状況を見ることができます。アドレスやアクセスコードは見えません。',
       title: '新しいトークンを作成',
       nameLabel: 'トークン名',
       namePlaceholder: '例:Home Assistant',
@@ -6707,6 +6710,9 @@ export default {
       camWallUrlTitle: 'この画面用のカメラウォール URL',
       camWallUrlHint:
         'この URL を画面で開いてください。URL を読める人は誰でもウォールを見られるため、鍵と同じように扱ってください。トークンを取り消すと、その画面は遮断されます。',
+      overlayUrlTitle: 'OBS 用のオーバーレイ URL',
+      overlayUrlHint:
+        'これを OBS の「ブラウザ」ソース(Browser Source)として追加してください。/overlay/1 の番号を、お使いのプリンターの番号(プリンターページの URL に表示)に変更します。URL を読める人は誰でも配信を見られるため、鍵と同じように扱い、遮断するにはトークンを取り消してください。',
       title: 'トークンを作成しました – 今すぐコピー',
       warning:
         'このトークンが表示されるのは今回限りです。このダイアログを閉じると二度と表示できません。',

+ 6 - 0
frontend/src/i18n/locales/ko.ts

@@ -6147,6 +6147,7 @@ export default {
     scope: {
       camera_stream: '카메라 스트림',
       camwall: '카메라 월',
+      overlay: '스트리밍 오버레이',
     },
     title: '카메라 API 토큰',
     navTitle: '카메라 API 토큰',
@@ -6165,6 +6166,8 @@ export default {
         '카메라 스트림 토큰은 카메라 스트림과 스냅숏만 가져올 수 있습니다. Home Assistant, Frigate 등 카메라 하나를 삽입하는 용도로 사용하세요.',
       hintCamWall:
         '카메라 월 토큰은 로그인 없이 화면에서 /camwall을 엽니다. 모든 프린터의 이름과 상태, 카메라 스트림을 볼 수 있습니다. 파일 이름, 주소, 액세스 코드는 볼 수 없습니다.',
+      hintOverlay:
+        '스트리밍 오버레이 토큰은 로그인 없이 화면에서 /overlay/{printerId}을 엽니다 — OBS나 모든 라이브 방송용입니다. 프린터 한 대의 카메라 스트림과 화면에 표시되는 파일 이름을 포함한 실시간 인쇄 상태를 볼 수 있습니다. 주소나 액세스 코드는 볼 수 없습니다.',
       title: '새 토큰 만들기',
       nameLabel: '토큰 이름',
       namePlaceholder: '예: Home Assistant',
@@ -6176,6 +6179,9 @@ export default {
       camWallUrlTitle: '이 화면용 카메라 월 주소',
       camWallUrlHint:
         '이 주소를 화면에서 여세요. 주소를 읽을 수 있는 사람은 누구나 월을 볼 수 있으므로 열쇠처럼 다루세요. 토큰을 취소하면 해당 화면의 접근이 차단됩니다.',
+      overlayUrlTitle: 'OBS용 오버레이 주소',
+      overlayUrlHint:
+        'OBS에서 이것을 브라우저 소스로 추가하세요. /overlay/1의 숫자를 프린터의 번호(프린터 페이지의 주소에 표시됨)로 변경하세요. 주소를 읽을 수 있는 사람은 누구나 스트림을 볼 수 있으므로 열쇠처럼 다루세요 — 접근을 차단하려면 토큰을 취소하세요.',
       title: '토큰 생성됨 — 지금 복사하세요',
       warning: '이 토큰은 이 번만 볼 수 있습니다. 이 대화상자를 닫으면 다시는 볼 수 없습니다.',
       copy: '복사',

+ 6 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -6665,6 +6665,7 @@ export default {
     scope: {
       camera_stream: 'Transmissão da câmera',
       camwall: 'Mural de câmeras',
+      overlay: 'Sobreposição de streaming',
     },
     title: 'Tokens da API de câmera',
     navTitle: 'Tokens da API de câmera',
@@ -6683,6 +6684,8 @@ export default {
         'Um token de transmissão da câmera só consegue buscar transmissões e instantâneos. Use-o no Home Assistant, no Frigate ou em qualquer coisa que incorpore uma única câmera.',
       hintCamWall:
         'Um token do mural de câmeras abre /camwall em uma tela sem login. Ele vê o nome e o estado de cada impressora e as transmissões das câmeras. Não vê nomes de arquivo, endereços nem códigos de acesso.',
+      hintOverlay:
+        'Um token de sobreposição de streaming abre /overlay/{printerId} em uma tela sem login — para o OBS ou qualquer transmissão ao vivo. Ele vê a transmissão da câmera de uma impressora e seu status de impressão ao vivo, incluindo o nome de arquivo mostrado na tela. Não vê endereços nem códigos de acesso.',
       title: 'Criar novo token',
       nameLabel: 'Nome do token',
       namePlaceholder: 'ex. Home Assistant',
@@ -6695,6 +6698,9 @@ export default {
       camWallUrlTitle: 'Endereço do mural de câmeras para esta tela',
       camWallUrlHint:
         'Abra este endereço na tela. Qualquer pessoa que consiga lê-lo pode assistir ao painel, então trate-o como uma chave: revogue o token para cortar o acesso da tela.',
+      overlayUrlTitle: 'Endereço de sobreposição para OBS',
+      overlayUrlHint:
+        'Adicione isto como Fonte de navegador no OBS. Altere o número em /overlay/1 para o número da sua impressora (do endereço dela na página Impressoras). Qualquer pessoa que consiga ler o endereço pode assistir à transmissão, então trate-o como uma chave: revogue o token para cortar o acesso.',
       title: 'Token criado – copie agora',
       warning:
         'Esta é a única vez que este token será visível. Após fechar este diálogo, você nunca poderá vê-lo novamente.',

+ 6 - 0
frontend/src/i18n/locales/tr.ts

@@ -6618,6 +6618,7 @@ export default {
     scope: {
       camera_stream: 'Kamera akışı',
       camwall: 'Kamera duvarı',
+      overlay: 'Yayın bindirmesi',
     },
     title: 'Kamera API Belirteçleri',
     navTitle: 'Kamera API belirteçleri',
@@ -6636,6 +6637,8 @@ export default {
         'Kamera akışı belirteci yalnızca kamera akışlarını ve anlık görüntüleri alabilir. Home Assistant, Frigate veya tek bir kamerayı gömen her şey için kullanın.',
       hintCamWall:
         'Kamera duvarı belirteci, oturum açmadan bir ekranda /camwall adresini açar. Her yazıcının adını ve durumunu, ayrıca kamera akışlarını görebilir. Dosya adlarını, adresleri veya erişim kodlarını göremez.',
+      hintOverlay:
+        'Yayın bindirmesi belirteci, oturum açmadan bir ekranda /overlay/{printerId} adresini açar — OBS veya herhangi bir canlı yayın için. Bir yazıcının kamera akışını ve ekranda gösterilen dosya adı dahil canlı yazdırma durumunu görebilir. Adresleri veya erişim kodlarını göremez.',
       title: 'Yeni belirteç oluştur',
       nameLabel: 'Belirteç adı',
       namePlaceholder: 'örn. Home Assistant',
@@ -6648,6 +6651,9 @@ export default {
       camWallUrlTitle: 'Bu ekran için kamera duvarı adresi',
       camWallUrlHint:
         'Bu adresi ekranda açın. Adresi okuyabilen herkes duvarı izleyebilir, bu yüzden onu bir anahtar gibi görün; ekranın erişimini kesmek için belirteci iptal edin.',
+      overlayUrlTitle: 'OBS için bindirme adresi',
+      overlayUrlHint:
+        'Bunu OBS\'ye Tarayıcı Kaynağı olarak ekleyin. /overlay/1 içindeki sayıyı yazıcınızın numarasıyla değiştirin (Yazıcılar sayfasındaki adresinden). Adresi okuyabilen herkes yayını izleyebilir, bu yüzden onu bir anahtar gibi görün — erişimi kesmek için belirteci iptal edin.',
       title: 'Belirteç oluşturuldu — şimdi kopyalayın',
       warning:
         'Bu, bu belirtecin görünür olacağı tek seferdir. Bu iletişim kutusunu kapattıktan sonra onu bir daha asla görüntüleyemezsiniz.',

+ 6 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -6664,6 +6664,7 @@ export default {
     scope: {
       camera_stream: '摄像头视频流',
       camwall: '摄像头墙',
+      overlay: '直播叠加层',
     },
     title: '摄像头 API 令牌',
     navTitle: '摄像头 API 令牌',
@@ -6682,6 +6683,8 @@ export default {
         '摄像头视频流令牌只能获取摄像头视频流和快照。适用于 Home Assistant、Frigate 或任何嵌入单个摄像头的场景。',
       hintCamWall:
         '摄像头墙令牌可在无需登录的屏幕上打开 /camwall,能看到每台打印机的名称和状态以及摄像头视频流,但看不到文件名、地址或访问码。',
+      hintOverlay:
+        '直播叠加层令牌可在无需登录的屏幕上打开 /overlay/{printerId}——供 OBS 或任何直播使用。它能看到一台打印机的摄像头视频流以及实时打印状态,包括屏幕上显示的文件名,但看不到地址或访问码。',
       title: '创建新令牌',
       nameLabel: '令牌名称',
       namePlaceholder: '例如 Home Assistant',
@@ -6694,6 +6697,9 @@ export default {
       camWallUrlTitle: '此屏幕的摄像头墙网址',
       camWallUrlHint:
         '在屏幕上打开此网址。任何能看到该网址的人都能观看摄像头墙,请像对待钥匙一样对待它——撤销令牌即可切断该屏幕的访问。',
+      overlayUrlTitle: '用于 OBS 的叠加层网址',
+      overlayUrlHint:
+        '在 OBS 中将其添加为“浏览器”源(Browser Source)。将 /overlay/1 中的数字改为您打印机的编号(可在“打印机”页面的网址中查看)。任何能看到该网址的人都能观看直播,请像对待钥匙一样对待它——撤销令牌即可切断访问。',
       title: '令牌已创建 — 立即复制',
       warning:
         '这是此令牌唯一一次可见。关闭此对话框后您将无法再次查看。',

+ 6 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -6664,6 +6664,7 @@ export default {
     scope: {
       camera_stream: '攝影機串流',
       camwall: '攝影機牆',
+      overlay: '直播疊加層',
     },
     title: '攝影機 API 權杖',
     navTitle: '攝影機 API 權杖',
@@ -6682,6 +6683,8 @@ export default {
         '攝影機串流權杖只能取得攝影機串流與快照。適用於 Home Assistant、Frigate 或任何嵌入單一攝影機的情境。',
       hintCamWall:
         '攝影機牆權杖可在無須登入的螢幕上開啟 /camwall,能看到每台印表機的名稱與狀態以及攝影機串流,但看不到檔案名稱、位址或存取碼。',
+      hintOverlay:
+        '直播疊加層權杖可在無須登入的螢幕上開啟 /overlay/{printerId}——供 OBS 或任何直播使用。它能看到一台印表機的攝影機串流以及即時列印狀態,包括螢幕上顯示的檔案名稱,但看不到位址或存取碼。',
       title: '建立新權杖',
       nameLabel: '權杖名稱',
       namePlaceholder: '例如 Home Assistant',
@@ -6694,6 +6697,9 @@ export default {
       camWallUrlTitle: '此螢幕的攝影機牆網址',
       camWallUrlHint:
         '在螢幕上開啟此網址。任何能看到該網址的人都能觀看攝影機牆,請像對待鑰匙一樣對待它——撤銷權杖即可切斷該螢幕的存取。',
+      overlayUrlTitle: '用於 OBS 的疊加層網址',
+      overlayUrlHint:
+        '在 OBS 中將其新增為「瀏覽器」來源(Browser Source)。將 /overlay/1 中的數字改為您印表機的編號(可在「印表機」頁面的網址中查看)。任何能看到該網址的人都能觀看直播,請像對待鑰匙一樣對待它——撤銷權杖即可切斷存取。',
       title: '權杖已建立 — 立即複製',
       warning:
         '這是此權杖唯一一次可見。關閉此對話框後您將無法再次查看。',

+ 44 - 4
frontend/src/pages/CameraTokensPage.tsx

@@ -101,6 +101,7 @@ function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
         >
           <option value="camera_stream">{t('cameraTokens.scope.camera_stream', 'Camera stream')}</option>
           <option value="camwall">{t('cameraTokens.scope.camwall', 'Cam Wall')}</option>
+          <option value="overlay">{t('cameraTokens.scope.overlay', 'Streaming Overlay')}</option>
         </select>
         <input
           type="number"
@@ -133,10 +134,15 @@ function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
               'cameraTokens.create.hintCamWall',
               'A Cam Wall token opens /camwall on a screen with no login — it can see every printer\'s name and state, and their camera streams. It cannot see filenames, addresses or access codes.',
             )
-          : t(
-              'cameraTokens.create.hintCameraStream',
-              'A camera-stream token can only fetch camera streams and snapshots. Use it for Home Assistant, Frigate, or anything embedding a single camera.',
-            )}
+          : scope === 'overlay'
+            ? t(
+                'cameraTokens.create.hintOverlay',
+                'A Streaming Overlay token opens /overlay/{printerId} on a screen with no login — for OBS or any live stream. It can see one printer\'s camera stream plus its live print status, including the filename shown on screen. It cannot see addresses or access codes.',
+              )
+            : t(
+                'cameraTokens.create.hintCameraStream',
+                'A camera-stream token can only fetch camera streams and snapshots. Use it for Home Assistant, Frigate, or anything embedding a single camera.',
+              )}
       </p>
       <p className="text-xs text-bambu-gray mt-1">
         {t(
@@ -217,6 +223,14 @@ function JustCreatedModal({ token, onClose }: JustCreatedModalProps) {
       ? `${window.location.origin}/camwall?token=${encodeURIComponent(plaintext)}`
       : null;
 
+  // For an overlay token, likewise the artefact is the URL. It targets one
+  // printer, so we template printer 1 and tell the user to swap in the number
+  // from the printer's URL on the main page (#2613).
+  const overlayUrl =
+    token.scope === 'overlay' && plaintext
+      ? `${window.location.origin}/overlay/1?token=${encodeURIComponent(plaintext)}`
+      : null;
+
   const copyText = async (value: string) => {
     if (!value) return;
     try {
@@ -300,6 +314,32 @@ function JustCreatedModal({ token, onClose }: JustCreatedModalProps) {
             </div>
           </div>
         )}
+        {overlayUrl && (
+          <div className="mb-4">
+            <p className="text-sm font-medium text-white mb-1">
+              {t('cameraTokens.created.overlayUrlTitle', 'Overlay URL for OBS')}
+            </p>
+            <p className="text-xs text-bambu-gray mb-2">
+              {t(
+                'cameraTokens.created.overlayUrlHint',
+                'Add this as a Browser Source in OBS. Change the /overlay/1 number to your printer\'s number (from its URL on the Printers page). Anyone who can read the URL can watch the stream, so treat it like a key — revoke the token to cut it off.',
+              )}
+            </p>
+            <div className="flex items-center gap-2">
+              <code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
+                {overlayUrl}
+              </code>
+              <button
+                type="button"
+                onClick={() => copyText(overlayUrl)}
+                className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
+              >
+                <Copy className="w-4 h-4" />
+                {t('cameraTokens.created.copy', 'Copy')}
+              </button>
+            </div>
+          </div>
+        )}
         <div className="flex justify-end">
           <button
             type="button"

+ 51 - 17
frontend/src/pages/StreamOverlayPage.tsx

@@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { Layers, Clock, Timer, Printer } from 'lucide-react';
 import { api, ApiError, withStreamToken } from '../api/client';
-import type { PrinterStatus } from '../api/client';
 import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
 
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
@@ -57,7 +56,9 @@ function parseConfig(params: URLSearchParams): OverlayConfig {
   };
 }
 
-function getStatusText(status: PrinterStatus, t: TFunction): string {
+// Accepts the minimal shape shared by PrinterStatus (logged-in path) and the
+// token-authed OverlayStatus (kiosk path) — both carry state + stg_cur_name.
+function getStatusText(status: { state: string | null; stg_cur_name?: string | null }, t: TFunction): string {
   if (status.stg_cur_name) return status.stg_cur_name;
 
   switch (status.state) {
@@ -117,32 +118,59 @@ export function StreamOverlayPage() {
   const config = useMemo(() => parseConfig(searchParams), [searchParams]);
   const sizes = getSizeClasses(config.size);
 
-  // Fetch printer info
-  const { data: printer } = useQuery({
+  // Kiosk mode (#2613): OBS and other embeds have no login session, so they
+  // pass an `overlay`-scoped token in the URL. When present, every data call
+  // (status + camera stream) is authenticated by that token instead of a JWT.
+  const token = searchParams.get('token');
+  const kiosk = token != null && token !== '';
+
+  // Kiosk path: one token-authenticated call for name + live status + the one
+  // setting the overlay reads. No JWT, so this is the only feed available.
+  const { data: overlay } = useQuery({
+    queryKey: ['overlayStatus', id, token],
+    queryFn: () => api.getOverlayStatus(id, token ?? undefined),
+    enabled: id > 0 && kiosk,
+    refetchInterval: 2000,
+  });
+
+  // Logged-in path: the ordinary JWT-authenticated queries, unchanged. Disabled
+  // in kiosk mode so an unauthenticated OBS browser never fires a doomed 401.
+  const { data: printerData } = useQuery({
     queryKey: ['printer', id],
     queryFn: () => api.getPrinter(id),
-    enabled: id > 0,
+    enabled: id > 0 && !kiosk,
   });
 
-  // Fetch printer status with polling
-  const { data: status } = useQuery({
+  const { data: statusData } = useQuery({
     queryKey: ['printerStatus', id],
     queryFn: () => api.getPrinterStatus(id),
-    enabled: id > 0,
+    enabled: id > 0 && !kiosk,
     refetchInterval: 2000,
   });
 
-  // Fetch settings info
   const { data: settings } = useQuery({
     queryKey: ['settings'],
     queryFn: api.getSettings,
+    enabled: !kiosk,
   });
 
-  const timeFormat: TimeFormat = settings?.time_format || 'system';
+  // Normalize the two sources into the shape the render below reads. Memoized
+  // because the title effect depends on `printer` — a fresh object literal each
+  // render would re-run it (and reset document.title) on every poll tick.
+  const printer = useMemo(
+    () =>
+      kiosk
+        ? overlay && { name: overlay.name, camera_rotation: overlay.camera_rotation }
+        : printerData,
+    [kiosk, overlay, printerData],
+  );
+  const status = kiosk ? overlay : statusData;
+  const timeFormat: TimeFormat = (kiosk ? overlay?.time_format : settings?.time_format) || 'system';
 
-  // WebSocket for real-time updates
+  // WebSocket for real-time updates (JWT-authenticated; skipped in kiosk mode,
+  // where the token can't mint a ws-token — the 2s poll above is the feed).
   useEffect(() => {
-    if (!id) return;
+    if (!id || kiosk) return;
 
     let ws: WebSocket | null = null;
     let cancelled = false;
@@ -153,10 +181,10 @@ export function StreamOverlayPage() {
     // Bearer tokens, not cookies, for JWT auth). Auth-disabled deployments
     // succeed even without a token.
     (async () => {
-      let token: string | undefined;
+      let wsToken: string | undefined;
       try {
         const resp = await api.getWebSocketToken();
-        token = resp.token;
+        wsToken = resp.token;
       } catch (err) {
         // A 401 (JWT expired) / 403 (no WEBSOCKET_CONNECT permission) is an
         // auth decision — a tokenless socket would just be closed 4401, so
@@ -171,7 +199,7 @@ export function StreamOverlayPage() {
       if (cancelled) return;
 
       const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
-      const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
+      const tokenParam = wsToken ? `?token=${encodeURIComponent(wsToken)}` : '';
       const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
       ws = new WebSocket(wsUrl);
 
@@ -195,7 +223,7 @@ export function StreamOverlayPage() {
       cancelled = true;
       if (ws) ws.close();
     };
-  }, [id, queryClient]);
+  }, [id, kiosk, queryClient]);
 
   // Update document title
   useEffect(() => {
@@ -230,7 +258,13 @@ export function StreamOverlayPage() {
 
   const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
   const progress = status.progress || 0;
-  const streamUrl = withStreamToken(`/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`);
+  // Append the kiosk token directly rather than leaning on withStreamToken's
+  // module cache — the cache is populated by an effect and would miss the first
+  // render (a 401 flash before the retry). The logged-in path keeps the cache.
+  const camPath = `/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`;
+  const streamUrl = kiosk && token
+    ? `${camPath}&token=${encodeURIComponent(token)}`
+    : withStreamToken(camPath);
 
   return (
     <div className="min-h-screen bg-black relative overflow-hidden">

Plik diff jest za duży
+ 0 - 0
static/assets/index-BLUpUiDA.js


Plik diff jest za duży
+ 1 - 0
static/assets/index-CKAbipPc.css


Plik diff jest za duży
+ 0 - 1
static/assets/index-CZwzTgpo.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CREN25a-.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CZwzTgpo.css">
+    <script type="module" crossorigin src="/assets/index-BLUpUiDA.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CKAbipPc.css">
   </head>
   <body>
     <div id="root"></div>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików