diff --git a/ui/style.css b/ui/style.css index 6c19ba6d4e..55c1905e1e 100644 --- a/ui/style.css +++ b/ui/style.css @@ -4088,6 +4088,20 @@ a:focus-visible, color: #fff; } +.qs-text-input { + padding: var(--space-6) var(--space-8); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: var(--color-secondary); + color: var(--color-text); + font-size: var(--font-size-sm); +} + +.qs-text-input:focus { + outline: 2px solid var(--color-primary); + outline-offset: 1px; +} + /* --- Screenshot Flash --- */ .screenshot-flash { diff --git a/ui/utils/quick-settings.js b/ui/utils/quick-settings.js index 358592b824..5112ee9a4f 100644 --- a/ui/utils/quick-settings.js +++ b/ui/utils/quick-settings.js @@ -1,6 +1,10 @@ // Quick Settings Panel - Centralized configuration for all UI features // Accessible via gear icon in header +import { apiService } from '../services/api.service.js'; + +const API_TOKEN_STORAGE_KEY = 'ruview-api-token'; + export class QuickSettings { constructor(app) { this.app = app; @@ -10,10 +14,21 @@ export class QuickSettings { } init() { + this.applyStoredApiToken(); this.createButton(); this.createPanel(); } + // Apply a previously-saved bearer token to apiService as early as + // possible, before any tab's REST calls fire. The server only ever + // checks the `Authorization: Bearer` header (see bearer_auth.rs) — this + // intentionally never puts the token in a URL query string. + applyStoredApiToken() { + let token = null; + try { token = localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ } + if (token) apiService.setAuthToken(token); + } + createButton() { this.button = document.createElement('button'); this.button.className = 'settings-gear'; @@ -70,6 +85,18 @@ export class QuickSettings { +
+
API Access
+
+ Bearer token (set only if the server enforces RUVIEW_API_TOKEN) + +
+ + +
+ +
+
Data
@@ -112,6 +139,30 @@ export class QuickSettings { } }); + this.panel.querySelector('#qs-api-token-save').addEventListener('click', () => { + const input = this.panel.querySelector('#qs-api-token'); + const status = this.panel.querySelector('#qs-api-token-status'); + const token = input.value.trim(); + if (!token) { + status.textContent = 'Enter a token first, or use Clear to remove one.'; + return; + } + try { localStorage.setItem(API_TOKEN_STORAGE_KEY, token); } catch { /* noop */ } + apiService.setAuthToken(token); + status.textContent = 'Token saved and applied. Reloading...'; + setTimeout(() => window.location.reload(), 600); + }); + + this.panel.querySelector('#qs-api-token-clear').addEventListener('click', () => { + const input = this.panel.querySelector('#qs-api-token'); + const status = this.panel.querySelector('#qs-api-token-status'); + try { localStorage.removeItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ } + apiService.setAuthToken(null); + input.value = ''; + status.textContent = 'Token cleared. Reloading...'; + setTimeout(() => window.location.reload(), 600); + }); + this.panel.querySelector('#qs-clear-data').addEventListener('click', () => { try { localStorage.clear(); @@ -154,6 +205,10 @@ export class QuickSettings { if (this.getSetting('compact')) { document.body.classList.add('compact-mode'); } + const status = this.panel.querySelector('#qs-api-token-status'); + let hasToken = false; + try { hasToken = !!localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ } + if (status) status.textContent = hasToken ? 'A token is currently set.' : 'No token set (auth is off or unnecessary).'; } prefersReducedMotion() { diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index d929e857d2..011343ace4 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -34,6 +34,15 @@ pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN"; /// Path prefix the middleware protects when auth is enabled. pub const PROTECTED_PREFIX: &str = "/api/v1/"; +/// `/api/v1/stream/pose` is a WebSocket upgrade endpoint reachable from +/// browser code. Unlike a plain fetch(), the browser `WebSocket` constructor +/// cannot attach an `Authorization` header to the handshake request, so this +/// path can never carry a bearer token from a stock browser client — the +/// same reasoning that already exempts `/ws/sensing` (see module docs). +/// Exempted here rather than moved out of `/api/v1/*` to avoid an API +/// surface change for existing clients. +const EXEMPT_PATHS: &[&str] = &["/api/v1/stream/pose"]; + /// Cheap, cloneable handle to the configured token (or `None`). #[derive(Debug, Clone, Default)] pub struct AuthState { @@ -93,7 +102,8 @@ pub async fn require_bearer( let Some(expected) = auth.token.clone() else { return next.run(request).await; }; - if !request.uri().path().starts_with(PROTECTED_PREFIX) { + let path = request.uri().path(); + if !path.starts_with(PROTECTED_PREFIX) || EXEMPT_PATHS.contains(&path) { return next.run(request).await; } let supplied = request @@ -141,6 +151,7 @@ mod tests { .route("/health", get(|| async { "ok" })) .route("/api/v1/info", get(|| async { "ok" })) .route("/api/v1/sensitive", axum::routing::post(|| async { "ok" })) + .route("/api/v1/stream/pose", get(|| async { "ok" })) .route("/ui/index.html", get(|| async { "" })) } @@ -361,6 +372,26 @@ mod tests { ); } + /// `/api/v1/stream/pose` is a WebSocket upgrade the browser `WebSocket` + /// constructor drives directly — it cannot attach an `Authorization` + /// header, so this path must stay reachable even with auth ON (mirrors + /// the existing `/ws/sensing` exemption, just inside the `/api/v1/*` + /// prefix this time). + #[tokio::test] + async fn enabled_exempts_pose_stream_websocket() { + let r = wrap(AuthState::from_token("s3cr3t")); + assert_eq!( + status(r.clone(), "GET", "/api/v1/stream/pose", None).await, + StatusCode::OK, + "pose stream WS must stay reachable without a bearer token" + ); + // The exemption is narrow: it must not leak to other /api/v1/* paths. + assert_eq!( + status(r, "GET", "/api/v1/info", None).await, + StatusCode::UNAUTHORIZED + ); + } + #[test] fn ct_eq_basics() { assert!(ct_eq(b"abc", b"abc"));