feat(#1108): long-lived camera-stream tokens + fix(#1089) audit-pass tweaks

#1108 — Long-lived camera-stream tokens for HA / Frigate / kiosks. Camera-only
  V1, hard 365-day cap (no infinite tokens), pbkdf2 hashed at rest, plaintext
  shown to user exactly once on creation. New "Camera API Tokens" panel under
  Settings → API Keys with self-service create/revoke, styled confirm modal,
  admin "All users" view for leak triage. Auth path: /camera/stream tries the
  existing 60-min ephemeral table first, falls through to the long-lived path.
  Indexed lookup_prefix keeps verify O(1) per token.

  Permission audit: gated the existing API-keys-CRUD + Webhook docs + API
  Browser content behind api_keys:read so non-admins with camera:view land on
  the API Keys tab and see only the Camera Tokens panel they actually have
  permission to use. Grid layout collapses to single column for non-admins.

  Tests: 29 new backend (15 service + 14 integration covering create/list/
  revoke ownership rules, the auth fall-through, scope enforcement, prefix
  collisions) + 6 new frontend tests for the section UI including the new
  modal flow. All 77 backend tests + 21 frontend camera tests pass. Ruff
  clean (lint + format).

  Docs: README updated with fan-out + long-lived-token bullets. Wiki gets a
  new "Long-Lived Camera Tokens" section under features/camera.md (HA YAML
  example, security model, permission requirements, revoke flow). Website
  features.html gets the bullet under Camera Streaming.

  Also includes #1089 follow-up tweaks already merged in this branch:
  _stream_start_times.setdefault for accurate stream_uptime, subscribe()
  RuntimeError retry to close the grace-vs-subscribe race, atomic
  unsubscribe count via the iter_subscriber on_unsubscribe callback.
This commit is contained in:
maziggy
2026-04-25 10:44:37 +02:00
parent 1e3ad697f2
commit fcda728af4
26 changed files with 2660 additions and 431 deletions
+30
View File
@@ -112,6 +112,21 @@ async function request<T>(
return await response.json();
}
// Long-lived camera-stream tokens (#1108). The `token` field is populated
// only on the create response — listing endpoints set it to null because
// the plaintext value is shown to the user exactly once.
export interface LongLivedCameraToken {
id: number;
user_id: number;
name: string;
scope: 'camera_stream';
lookup_prefix: string;
created_at: string;
expires_at: string;
last_used_at: string | null;
token: string | null;
}
// Printer types
export interface Printer {
id: number;
@@ -4275,6 +4290,21 @@ export const api = {
// Camera
getCameraStreamToken: () =>
request<{ token: string }>('/printers/camera/stream-token', { method: 'POST' }),
// Long-lived camera-stream tokens (#1108)
createLongLivedCameraToken: (payload: { name: string; expires_in_days: number }) =>
request<LongLivedCameraToken>('/auth/tokens', {
method: 'POST',
body: JSON.stringify({ ...payload, scope: 'camera_stream' }),
}),
listMyLongLivedCameraTokens: () =>
request<LongLivedCameraToken[]>('/auth/tokens'),
listAllLongLivedCameraTokens: () =>
request<LongLivedCameraToken[]>('/auth/tokens/all'),
listLongLivedCameraTokensForUser: (userId: number) =>
request<LongLivedCameraToken[]>(`/auth/tokens?user_id=${userId}`),
revokeLongLivedCameraToken: (tokenId: number) =>
request<void>(`/auth/tokens/${tokenId}`, { method: 'DELETE' }),
getCameraStreamUrl: (printerId: number, fps = 10) =>
withStreamToken(`${API_BASE}/printers/${printerId}/camera/stream?fps=${fps}`),
getCameraSnapshotUrl: (printerId: number) =>