Files
doctate/server/src/routes/magic.rs
T
Brummel 17aa5d7200 Implement audio range requests
This commit enables serving audio files via HTTP Range requests. This is
crucial for allowing HTML5 audio players to seek to specific positions
within an audio file without re-downloading the entire file.

The changes include:
- Modifying `handle_audio` in `server/src/routes/web.rs` to parse
  `Range` headers.
- Implementing `serve_range` to handle partial content responses.
- Adding a `parse_range` helper function.
- Updating tests to verify range request functionality.
- Adding `ACCEPT_RANGES: bytes` header to indicate support for range
  requests.
- Storing recording duration in a sidecar file for faster UI rendering.
- Enhancing the HTML template to support a custom audio player with
  seeking.
2026-04-19 22:26:04 +02:00

203 lines
6.6 KiB
Rust

//! Magic-link routes — turn an API-key authenticated desktop call into a
//! browser session without an interactive password login.
//!
//! Flow:
//! 1. Desktop client → `POST /api/auth/magic-link` (X-API-Key) with the
//! desired `return_to` path. Server stores a one-time token (TTL 60s)
//! and returns it.
//! 2. Desktop client opens the system browser at
//! `{server_url}/web/magic?token=…`.
//! 3. The browser hits `GET /web/magic`. The server consumes the token
//! (one-time-use), installs a regular `WebSession`, sets the session
//! cookie, and 303-redirects to `return_to`.
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::extract::{Query, State};
use axum::http::header;
use axum::response::{IntoResponse, Redirect, Response};
use axum::{Json, http::StatusCode};
use axum_extra::extract::cookie::CookieJar;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::AuthenticatedUser;
use crate::config::Config;
use crate::error::AppError;
use crate::magic_link::{MagicLinkStore, PendingMagicLink};
use crate::web_session::{SessionStore, WebSession, build_session_cookie, generate_token};
/// Magic-link tokens only need to survive click → browser-launch → first
/// request. 60s gives slow systems headroom without lengthening the attack
/// window unnecessarily.
const MAGIC_LINK_TTL_SECS: u64 = 60;
/// Default landing page when the desktop client does not specify one.
const DEFAULT_RETURN_TO: &str = "/web/cases";
#[derive(Deserialize, Default)]
pub struct MagicLinkRequest {
#[serde(default)]
pub return_to: Option<String>,
}
#[derive(Serialize)]
pub struct MagicLinkResponse {
pub token: String,
}
/// Issue a one-time magic-link token. Authenticated by `X-API-Key` —
/// re-uses the existing `AuthenticatedUser` extractor so policy stays
/// in one place.
pub async fn handle_create(
user: AuthenticatedUser,
State(store): State<MagicLinkStore>,
body: Option<Json<MagicLinkRequest>>,
) -> Result<Json<MagicLinkResponse>, AppError> {
let req = body.map(|Json(r)| r).unwrap_or_default();
let return_to = req
.return_to
.unwrap_or_else(|| DEFAULT_RETURN_TO.to_owned());
// Open-redirect guard: only accept paths under our own /web/ tree.
// Reject schemes, hosts, protocol-relative URLs, and traversal.
if !is_safe_return_to(&return_to) {
warn!(slug = %user.slug, return_to = %return_to, "magic-link rejected: unsafe return_to");
return Err(AppError::BadRequest(
"return_to must be a path under /web/".into(),
));
}
let token = generate_token();
let pending = PendingMagicLink {
slug: user.slug.clone(),
role: user.role.clone(),
return_to,
expires_at: Instant::now() + Duration::from_secs(MAGIC_LINK_TTL_SECS),
};
{
let mut w = store.write().await;
// Best-effort eviction so the map cannot grow unbounded under load.
w.retain(|_, v| v.expires_at > Instant::now());
w.insert(token.clone(), pending);
}
info!(slug = %user.slug, "magic-link issued");
Ok(Json(MagicLinkResponse { token }))
}
#[derive(Deserialize)]
pub struct ConsumeQuery {
pub token: String,
}
/// Consume a magic-link token: install a session cookie and redirect to
/// the previously-stored `return_to`. One-time use — the token is removed
/// from the store on first lookup, success or expiry alike.
pub async fn handle_consume(
State(config): State<Arc<Config>>,
State(magic_store): State<MagicLinkStore>,
State(session_store): State<SessionStore>,
jar: CookieJar,
Query(q): Query<ConsumeQuery>,
) -> Response {
let pending = {
let mut w = magic_store.write().await;
w.remove(&q.token)
};
let Some(pending) = pending else {
warn!("magic-link consume failed: unknown or already-used token");
return redirect_to_login();
};
if pending.expires_at <= Instant::now() {
warn!(slug = %pending.slug, "magic-link consume failed: expired");
return redirect_to_login();
}
let session_token = generate_token();
let ttl = Duration::from_secs(u64::from(config.session_timeout_hours) * 3600);
{
let mut w = session_store.write().await;
w.insert(
session_token.clone(),
WebSession {
slug: pending.slug.clone(),
role: pending.role.clone(),
expires_at: Instant::now() + ttl,
},
);
}
info!(slug = %pending.slug, "magic-link consumed → session installed");
let cookie = build_session_cookie(session_token, ttl, config.cookie_secure);
let jar = jar.add(cookie);
// 303 See Other forces the browser to follow with GET. Combined with
// `Referrer-Policy: no-referrer`, the token does not leak to any
// resource the redirect target may load.
let mut response = (jar, Redirect::to(&pending.return_to)).into_response();
response.headers_mut().insert(
header::REFERRER_POLICY,
"no-referrer".parse().expect("static header value"),
);
response
}
fn redirect_to_login() -> Response {
Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/web/login")
.header(header::REFERRER_POLICY, "no-referrer")
.body(axum::body::Body::empty())
.expect("static redirect response")
}
/// Allow only same-origin paths under `/web/`. Rejects schemes, host
/// authorities, protocol-relative URLs (`//evil.com/...`), and the
/// trivially malformed empty string.
fn is_safe_return_to(path: &str) -> bool {
if path.is_empty() {
return false;
}
if path.starts_with("//") {
return false; // protocol-relative — would jump host
}
if !path.starts_with("/web/") {
return false;
}
// Defensive: a `\` anywhere can confuse some legacy parsers into
// treating the rest as an authority. Reject outright.
if path.contains('\\') {
return false;
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_return_to_accepts_web_paths() {
assert!(is_safe_return_to("/web/cases"));
assert!(is_safe_return_to(
"/web/cases/123e4567-e89b-12d3-a456-426614174000"
));
}
#[test]
fn safe_return_to_rejects_open_redirects() {
assert!(!is_safe_return_to(""));
assert!(!is_safe_return_to("/"));
assert!(!is_safe_return_to("//evil.com/path"));
assert!(!is_safe_return_to("https://evil.com/"));
assert!(!is_safe_return_to("/api/upload"));
assert!(!is_safe_return_to("/web\\evil"));
}
}