Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions ui/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
55 changes: 55 additions & 0 deletions ui/utils/quick-settings.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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';
Expand Down Expand Up @@ -70,6 +85,18 @@ export class QuickSettings {
<span class="qs-switch"></span>
</label>
</div>
<div class="qs-section">
<div class="qs-section-title">API Access</div>
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
<span>Bearer token (set only if the server enforces RUVIEW_API_TOKEN)</span>
<input type="password" id="qs-api-token" class="qs-text-input" placeholder="Paste token..." autocomplete="off" style="width: 100%; box-sizing: border-box;">
<div style="display: flex; gap: 8px;">
<button class="qs-btn" id="qs-api-token-save">Save & Apply</button>
<button class="qs-btn-danger" id="qs-api-token-clear">Clear</button>
</div>
<span id="qs-api-token-status" style="font-size: 0.85em; opacity: 0.75;"></span>
</div>
</div>
<div class="qs-section">
<div class="qs-section-title">Data</div>
<div class="qs-row">
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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() {
Expand Down
33 changes: 32 additions & 1 deletion v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 { "<html/>" }))
}

Expand Down Expand Up @@ -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"));
Expand Down