diff --git a/CHANGELOG.md b/CHANGELOG.md index 9541a7994..267396385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file. - **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing. ### Fixed +- **The streaming overlay (`/overlay`) showed nothing in OBS when login was enabled (#2613, reporter @MartinNYHC)** — With authentication on, the overlay page worked when opened in a browser where you were already signed in, but stayed blank in OBS. The reporter suspected their Cloudflare/remote setup; it was unrelated. **Root cause.** The `/overlay/{id}` *route* renders without a login, but every piece of data it draws is auth-gated — printer status and name (`PRINTERS_READ`), one setting (`SETTINGS_READ`), and the camera stream (a camera-stream token). In your own browser those ride the JWT from local storage and the app-wide stream-token sync; OBS is a fresh browser with **no session**, so the status calls 401'd and the overlay never populated (the same would happen in any private/incognito window — remote access was never the cause). Unlike the Cam Wall (`/camwall?token=…`), the overlay had no token mode, and a long-lived token couldn't help because the JWT-gated status/settings endpoints reject it. **Fix.** The overlay is now a self-contained kiosk surface. A new **Streaming Overlay** long-lived-token scope is offered under Settings → API Keys (with a ready-made `/overlay/{id}?token=…` URL copied once on creation); the overlay page reads `?token=` from the URL and, in that mode, authenticates its status and camera calls with the token instead of a JWT (and skips the WebSocket, falling back to its existing 2 s poll). A new token-authenticated `GET /printers/{id}/overlay-status` returns exactly the fields the overlay draws — name, camera rotation, live print state, and the one setting — and nothing else. The scope is deliberately **separate from `camwall`**: the overlay names the file on screen, which the Cam Wall is trusted never to expose, so folding it in would have silently widened every existing wall token. The logged-in path (opening the overlay while signed in) is unchanged. Docs updated to explain the token and stop claiming the overlay needs no authentication. Covered by backend tests (scope boundaries in both directions — an overlay token can't reach the Cam Wall feed and a camwall/camera-stream token can't reach the overlay feed — plus the payload shape, disconnected-printer shape, and revoked/absent/garbage-token rejection) and frontend tests (kiosk mode reads the token feed and carries the token to the camera, never touches the JWT-only status endpoint or a socket; the mint UI offers the scope and hands over the assembled OBS URL). - **Reassigning a queue item while it was dispatching split it across two printers (#2615, reporter @Jostxxl)** — Editing a queue item's printer while its FTP upload was already in flight left the queue row pointing at one printer while the archive, expected-print registration, and the physical `project_file` command had gone to another. On a farm this made the reassigned-to printer look broken (marked `printing` but never sent the job), left the row permanently inconsistent, and could trigger a duplicate dispatch after a restart. **Root cause.** A queue row stays `status='pending'` for the entire (multi-minute) FTP upload — status only flips to `printing` at the very end. The edit route only blocked non-`pending` rows, so a `PATCH` during the upload window was accepted; the in-flight dispatch kept using the printer it had snapshotted at the start, while the DB row's `printer_id` changed underneath it. The existing #1853 CAS guards *cancellation* mid-dispatch, not *reassignment*. **Fix.** A `dispatching_at` claim is stamped atomically on the row (`WHERE status='pending' AND dispatching_at IS NULL`) the moment the scheduler begins dispatching, before any slow I/O, and cleared when dispatch ends. While it's held, both edit routes reject changes — the single-item `PATCH` returns **409** (re-checked immediately before the write to close the read-modify-write gap), and bulk edits skip the row — and the scheduler's selection query won't re-pick it. Startup reconciliation clears any claim orphaned by a crash mid-dispatch (no dispatch coroutine survives a restart, so every claim present at boot is stale), so a stale token can never wedge an item out of the queue. The row stays `pending` throughout, so no status-consumer, UI, completion, or reconciliation path had to change. To move a dispatching item, cancel it first (the coordinated escape) and re-queue. New column `print_queue.dispatching_at` (nullable timestamp, dialect-safe DDL — SQLite `DATETIME` / Postgres `TIMESTAMP`). Covered by scheduler tests (claim is exclusive, fails on non-pending rows, releases on every exit, skips an already-claimed row, startup clears stale claims) and API tests (reassign returns 409 with `printer_id` unchanged, bulk skips the claimed row, an unclaimed pending row still edits normally). - **A single plate printed from a multi-plate 3MF recorded the whole file's filament in statistics (#2614, reporter @Jostxxl)** — Dispatching one selected plate of a sliced multi-plate 3MF through the queue could log the **entire file's** filament against that one plate. The reporter's `heart 3.gcode.3mf` has 22 plates totalling ~12.0 kg; every completed plate recorded `12006.49 g`, so 13 runs inflated lifetime/user/project/filament stats by ~156 kg from one file. **Root cause.** The per-run value written to `PrintLogEntry.filament_used_grams` prefers the AMS-tracked spool delta, but when the tracker measured nothing (no inventory assignment on the printer) a *completed* run fell back to `PrintArchive.filament_used_grams` — which is deliberately the **sum over every plate** of the source 3MF (correct for the archive card and project rollup, #1593). The archive's `plate_id` (persisted by #2603) was never consulted on this path, so the whole-file total was copied verbatim; `cost` had the same defect, falling back to the whole-file `archive.cost`. **Forward fix.** When the archive carries a `plate_id` and its 3MF is on disk, the completed-run fallback now uses that plate's own slicer estimate (`extract_plate_metadata_from_3mf`, the same plate-scoped parse the inventory tracker uses), and scales cost by the plate's share of the whole. The tracker-measured path is unchanged (measured spool deltas still win) and single-plate archives are unaffected (plate value equals the whole-file value). **Backfill.** A startup migration repairs rows already written: for completed print-log entries whose stored grams **exactly equal** the linked archive's whole-file value (the mis-copy signature) and whose archive has a `plate_id` and an on-disk 3MF, it recomputes the plate-scoped grams + cost. The exact-match guard means tracker-measured rows (a rounded spool-delta sum) and partial-progress rows (scaled to progress) are never touched; it's idempotent (a corrected row no longer matches) and data-only, identical on SQLite and Postgres. Logs how many rows and how many grams of over-count it removed. Covered by unit tests for the forward helper (plate scoping, cost scaling, fallbacks when there's no plate_id / no file / unreadable estimate) and the backfill (mis-copy repaired, tracker/partial rows untouched, missing-3MF skipped, single-plate not relabelled, idempotent). - **Progress notification ran off the left edge of the screen in the installed iPhone PWA (#2612)** — On an iPhone 13 Pro with Bambuddy added to the Home Screen, the print-dispatch progress toast was clipped off the left side of the display — text like "prints", "plate_6", and "MB (21.0%)" bled past the edge. **Root cause.** The toast viewport is anchored `right-20` (80 px from the right, to clear the bug-report bubble) and the dispatch toast has a fixed `w-[420px]`. On a phone that's 390 CSS px wide, 420 + 80 overflows the left edge by ~110 px — the toast simply didn't fit. **Fix.** Every toast now carries a viewport-relative `max-width` (`calc(100vw - 6rem - safe-area insets)`) so it can never exceed the screen; on desktop the 420 px still wins. The viewport's position is also now safe-area-aware (`env(safe-area-inset-*)` on bottom/right) so an installed PWA clears the home indicator and a landscape notch, and the per-job filename row gets `min-w-0`/`shrink-0` so long names truncate instead of pushing the toast wide at the narrower phone width. Frontend-only; no backend, schema, or i18n change. Covered by a test pinning the viewport-relative width cap. diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 6ecc89226..9e1ada51b 100644 --- a/backend/app/api/routes/printers.py +++ b/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, diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 5d1e243dd..2e37d722c 100644 --- a/backend/app/core/auth.py +++ b/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, diff --git a/backend/app/main.py b/backend/app/main.py index cc3aa247f..f4eb5cb25 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6622,6 +6622,13 @@ PUBLIC_API_PATTERNS = [ # Camera (streams loaded via 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. diff --git a/backend/app/services/long_lived_tokens.py b/backend/app/services/long_lived_tokens.py index 7ef1b67a2..6e406c4a6 100644 --- a/backend/app/services/long_lived_tokens.py +++ b/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"}) +# 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 token has to -# be able to pull the video its own tiles are showing. -STREAM_SCOPES: tuple[str, ...] = ("camera_stream", "camwall") +# 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 diff --git a/backend/tests/integration/test_overlay_status_api.py b/backend/tests/integration/test_overlay_status_api.py new file mode 100644 index 000000000..06e469977 --- /dev/null +++ b/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 diff --git a/backend/tests/unit/services/test_long_lived_tokens.py b/backend/tests/unit/services/test_long_lived_tokens.py index 77c18f593..f2d47e78db 100644 --- a/backend/tests/unit/services/test_long_lived_tokens.py +++ b/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, diff --git a/frontend/src/__tests__/pages/CameraTokensPage.test.tsx b/frontend/src/__tests__/pages/CameraTokensPage.test.tsx index 2e2d3afd1..47714b5cd 100644 --- a/frontend/src/__tests__/pages/CameraTokensPage.test.tsx +++ b/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(); + + 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([])), diff --git a/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx b/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx index 2cc27e959..639fa5a53 100644 --- a/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx +++ b/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 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(); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index b0abbcce2..9189eae37 100644 --- a/frontend/src/api/client.ts +++ b/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( 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( + 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) => diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 82734eefd..3ee0b02c4 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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.', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 6ba20b25e..65d4270f4 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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.', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 40d1a0ff8..7696e6f29 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/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.', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index bf39bac60..bba906621 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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.', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 1131db3fc..560b53165 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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.', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index af35cd42c..dae5f65a8 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/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: 'このトークンが表示されるのは今回限りです。このダイアログを閉じると二度と表示できません。', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 654a08748..6da6aef78 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/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: '복사', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 822b70c2e..fa1be5f6a 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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.', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 7adbc1794..4c3df4aac 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/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.', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index f8c1b64e5..307c31667 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/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: '这是此令牌唯一一次可见。关闭此对话框后您将无法再次查看。', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 9f62de9fb..1c51660db 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/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: '這是此權杖唯一一次可見。關閉此對話框後您將無法再次查看。', diff --git a/frontend/src/pages/CameraTokensPage.tsx b/frontend/src/pages/CameraTokensPage.tsx index 6234c4f75..8ebf84382 100644 --- a/frontend/src/pages/CameraTokensPage.tsx +++ b/frontend/src/pages/CameraTokensPage.tsx @@ -101,6 +101,7 @@ function CreateTokenForm({ onCreated }: CreateTokenFormProps) { > +

{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) { )} + {overlayUrl && ( +

+

+ {t('cameraTokens.created.overlayUrlTitle', 'Overlay URL for OBS')} +

+

+ {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.', + )} +

+
+ + {overlayUrl} + + +
+
+ )}