refactor: drop /web/ URL prefix from browser routes

The /web/ prefix predated the /api/ split; today it just clutters every URL
without disambiguating anything. All 16 browser routes move to the apex
(/cases, /login, /magic, /events, /audio/...). The 6 /api/* routes are
unchanged. A new /->>/cases redirect closes the apex 404.

The open-redirect guard in magic.rs and case_actions.rs flips from a
positive whitelist (starts_with("/web/")) to a deny-list: same-origin path,
not protocol-relative, not under /api/, no \. The /api/ exclusion is now
load-bearing and covered by tests.

Pre-production: no transition redirects.
This commit is contained in:
2026-05-04 18:36:10 +02:00
parent 3d67cbc1c8
commit 2c6062a53e
43 changed files with 313 additions and 266 deletions
+1 -1
View File
@@ -340,7 +340,7 @@ impl DoctateApp {
let server_url = cfg.server_url.clone();
let api_key = cfg.api_key.clone();
let http = self.http_client.clone();
let return_to = format!("/web/cases/{case_id}");
let return_to = format!("/cases/{case_id}");
let fallback_url = join_url(&server_url, &return_to);
// Fire-and-forget on the runtime. Blocking the egui UI thread on
+7 -7
View File
@@ -35,7 +35,7 @@ pub enum MagicLinkError {
/// Request a magic-link token and build the full URL to open. The
/// `return_to` path is what the browser lands on **after** the token
/// is consumed; must start with `/web/` (server enforces this too).
/// is consumed; must start with `/` (server enforces this too).
pub async fn build_magic_url(
http: &reqwest::Client,
server_url: &str,
@@ -56,7 +56,7 @@ pub async fn build_magic_url(
let parsed: CreateResponse = resp.json().await?;
Ok(join_url(
server_url,
&format!("/web/magic?token={}", parsed.token),
&format!("/magic?token={}", parsed.token),
))
}
@@ -80,10 +80,10 @@ mod tests {
.await;
let http = reqwest::Client::new();
let url = build_magic_url(&http, &server.uri(), "test-key", "/web/cases/abc")
let url = build_magic_url(&http, &server.uri(), "test-key", "/cases/abc")
.await
.expect("magic url");
assert_eq!(url, format!("{}/web/magic?token={token}", server.uri()));
assert_eq!(url, format!("{}/magic?token={token}", server.uri()));
}
#[tokio::test]
@@ -96,7 +96,7 @@ mod tests {
.await;
let http = reqwest::Client::new();
let err = build_magic_url(&http, &server.uri(), "wrong-key", "/web/cases/abc")
let err = build_magic_url(&http, &server.uri(), "wrong-key", "/cases/abc")
.await
.expect_err("should error");
assert!(matches!(err, MagicLinkError::Status(s) if s.as_u16() == 401));
@@ -113,9 +113,9 @@ mod tests {
let http = reqwest::Client::new();
let url_with_slash = format!("{}/", server.uri());
let url = build_magic_url(&http, &url_with_slash, "k", "/web/cases/abc")
let url = build_magic_url(&http, &url_with_slash, "k", "/cases/abc")
.await
.expect("magic url");
assert!(!url.contains("//web/"), "double slash leaked: {url}");
assert!(!url.contains("//magic"), "double slash leaked: {url}");
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
//! Shared vocabulary for the admin bulk-action endpoint (`POST
//! /web/cases/bulk`).
//! /cases/bulk`).
//!
//! The wire shape is `application/x-www-form-urlencoded` with an
//! `action=<token>` field and one or more `case_id=<uuid>` repetitions.
+1 -1
View File
@@ -83,7 +83,7 @@ show_case() {
fi
echo
echo "Browse: $SERVER_URL/web/cases"
echo "Browse: $SERVER_URL/cases"
}
# Record + upload for the given case, then display the case state.
+2 -2
View File
@@ -1,6 +1,6 @@
//! Opportunistic auto-trigger for LLM analysis.
//!
//! Called by the `/web/cases` and `/web/cases/{id}` handlers on every
//! Called by the `/cases` and `/cases/{id}` handlers on every
//! request. The SSE-driven `location.reload()` in the UI turns every
//! relevant state change (upload, transcript, document) into a fresh
//! handler invocation, so no background timer is needed.
@@ -154,7 +154,7 @@ pub async fn try_enqueue(case_dir: &Path, tx: &AnalyzeSender, events_tx: &EventS
/// Iterate over every non-deleted UUID-named case directory under
/// `user_root` and run `try_enqueue` on each. Safe to call on every
/// `/web/cases` render — skips are cheap filesystem stats.
/// `/cases` render — skips are cheap filesystem stats.
pub async fn try_enqueue_all_for_user(
user_root: &Path,
tx: &AnalyzeSender,
+5 -5
View File
@@ -70,7 +70,7 @@ where
}
/// Extracted from the session cookie on web UI requests.
/// On missing/expired session, returns `AppError::Redirect("/web/login")`.
/// On missing/expired session, returns `AppError::Redirect("/login")`.
pub struct AuthenticatedWebUser {
pub slug: String,
pub role: String,
@@ -112,7 +112,7 @@ where
let token = jar
.get(SESSION_COOKIE)
.map(|c| c.value().to_owned())
.ok_or_else(|| AppError::Redirect("/web/login".into()))?;
.ok_or_else(|| AppError::Redirect("/login".into()))?;
// Read lock first to check validity. If expired, upgrade to write and remove.
let expired = {
@@ -121,7 +121,7 @@ where
Some(s) => s.expires_at <= Instant::now(),
None => {
warn!(token_prefix = %token_prefix(&token), "session lookup failed (unknown or already expired)");
return Err(AppError::Redirect("/web/login".into()));
return Err(AppError::Redirect("/login".into()));
}
}
};
@@ -132,13 +132,13 @@ where
if let Some(s) = removed {
warn!(slug = %s.slug, "session expired");
}
return Err(AppError::Redirect("/web/login".into()));
return Err(AppError::Redirect("/login".into()));
}
let r = store.read().await;
let session = r
.get(&token)
.ok_or_else(|| AppError::Redirect("/web/login".into()))?;
.ok_or_else(|| AppError::Redirect("/login".into()))?;
let data_dir = config.data_path.join(&session.slug);
// Look up the TOML user record for the policy fields the session
+8 -8
View File
@@ -1,4 +1,4 @@
//! CSRF-token validation for state-changing POSTs under `/web/`.
//! CSRF-token validation for state-changing browser POSTs.
//!
//! The `WebSession` carries a `csrf_token` minted once at login or
//! magic-link consume. Browser forms include a hidden `csrf_token`
@@ -32,12 +32,12 @@ pub trait HasCsrfToken {
}
/// Wrapping extractor — a drop-in replacement for `Form<T>` on
/// state-changing `/web/` POST handlers. Validates the form body's
/// state-changing browser POST handlers. Validates the form body's
/// `csrf_token` against the session's csrf_token via a constant-time
/// comparison.
///
/// Rejection mapping:
/// - Missing or expired session → 302 redirect to `/web/login` (so a
/// - Missing or expired session → 302 redirect to `/login` (so a
/// second-tab logout shows "bitte neu anmelden", not "csrf failed").
/// - Form body malformed → 400 `BadRequest`.
/// - Token present but mismatched → 403 `Forbidden`.
@@ -60,15 +60,15 @@ where
let cookie_token = CookieJar::from_headers(req.headers())
.get(SESSION_COOKIE)
.map(|c| c.value().to_owned())
.ok_or_else(|| AppError::Redirect("/web/login".into()))?;
.ok_or_else(|| AppError::Redirect("/login".into()))?;
let expected = {
let r = store.read().await;
let session = r
.get(&cookie_token)
.ok_or_else(|| AppError::Redirect("/web/login".into()))?;
.ok_or_else(|| AppError::Redirect("/login".into()))?;
if session.expires_at <= Instant::now() {
return Err(AppError::Redirect("/web/login".into()));
return Err(AppError::Redirect("/login".into()));
}
session.csrf_token.clone()
};
@@ -104,8 +104,8 @@ where
///
/// `return_to` is optional and only consulted by handlers that route
/// the user back to the source page (analyze, reset). Other handlers
/// ignore it. Validation (must start with `/web/`) lives at the call
/// site.
/// ignore it. Validation (same-origin, not under `/api/`) lives at the
/// call site.
#[derive(serde::Deserialize)]
pub struct CsrfOnlyForm {
#[serde(default)]
+1 -1
View File
@@ -1,6 +1,6 @@
//! Broadcast bus for UI live-update events.
//!
//! The web UI subscribes via the `/web/events` SSE route (see
//! The web UI subscribes via the `/events` SSE route (see
//! `routes::events`). Background workers and request handlers call
//! [`emit`] whenever they mutate case data on disk; subscribed browsers
//! then schedule a debounced reload. The filesystem remains the source
+2 -2
View File
@@ -207,8 +207,8 @@ pub fn create_router(config: Arc<Config>) -> Router {
/// Same as [`create_router`], but also returns a handle to the newly
/// created session store so tests can peek at `WebSession::csrf_token`
/// values without rendering and parsing HTML. The store handle is
/// shared with the router (`Arc`), so any session created via `/web/login`
/// or `/web/magic` becomes observable through it.
/// shared with the router (`Arc`), so any session created via `/login`
/// or `/magic` becomes observable through it.
pub fn create_router_and_session_store(config: Arc<Config>) -> (Router, SessionStore) {
create_router_and_session_store_with_settings(config, Arc::new(Settings::default()))
}
+1 -1
View File
@@ -2,7 +2,7 @@
//! browser session without an interactive password login.
//!
//! Lifecycle: the desktop client calls `POST /api/auth/magic-link` (API-key
//! authenticated), gets a token, opens the browser at `/web/magic?token=…`.
//! authenticated), gets a token, opens the browser at `/magic?token=…`.
//! The web handler removes the token (one-time-use) and installs a regular
//! `WebSession`. TTL is intentionally short — the token only needs to
//! survive the click → browser-launch → first-request round trip.
+1 -1
View File
@@ -131,7 +131,7 @@ async fn main() {
});
// Live-update bus: background workers and handlers publish case
// events here; the SSE route at `/web/events` fans them out to
// events here; the SSE route at `/events` fans them out to
// subscribed browsers.
let events_tx = events::channel();
+2 -2
View File
@@ -39,7 +39,7 @@ impl HasCsrfToken for BulkForm {
}
}
/// POST /web/cases/bulk — apply `action` (analyze | close | reset) to
/// POST /cases/bulk — apply `action` (analyze | close | reset) to
/// every selected case. Errors per case are logged but do not abort the
/// batch: a single bad/missing case must not block the rest.
pub async fn handle_bulk_action(
@@ -85,7 +85,7 @@ pub async fn handle_bulk_action(
}
}
Ok(Redirect::to("/web/cases"))
Ok(Redirect::to("/cases"))
}
async fn bulk_analyze(
+91 -47
View File
@@ -33,7 +33,7 @@ use crate::paths::{
use crate::routes::user_web::locate_case_or_404;
use crate::routes::web::validate_filename;
/// Form body for `POST /web/cases/{id}/analyze`. The `backend` value is set
/// Form body for `POST /cases/{id}/analyze`. The `backend` value is set
/// by the clicked submit button (`<button name="backend" value="…">`); an
/// empty value falls back to the default backend.
#[derive(Deserialize)]
@@ -52,7 +52,7 @@ impl HasCsrfToken for AnalyzeForm {
}
}
/// POST /web/cases/{case_id}/analyze
/// POST /cases/{case_id}/analyze
///
/// Trigger an LLM analysis for the case. If no document exists yet, writes
/// `analysis_input_v1.json`; otherwise writes `analysis_input_v{N+1}.json`
@@ -149,30 +149,32 @@ pub async fn handle_analyze_case(
Ok(Redirect::to(&validated_return_path(
form.return_to,
format!("/web/cases/{case_id}"),
format!("/cases/{case_id}"),
)))
}
/// Validate a `return_to` value submitted via a hidden form field.
///
/// Must start with `/web/` to be accepted; anything else (absolute
/// URLs, schema-relative `//evil.com/...`, paths outside the web area)
/// Accepts only same-origin browser paths (single leading slash, not
/// protocol-relative, not under `/api/`, no `\`). Anything else
/// (absolute URLs, `//evil.com/...`, JSON endpoints, traversal hacks)
/// is rejected and the supplied fallback is used. This keeps the
/// resulting redirect strictly same-origin and inside the user-facing
/// area, so a tampered hidden field cannot cause an open redirect.
fn validated_return_path(supplied: Option<String>, fallback: String) -> String {
supplied
.filter(|s| s.starts_with("/web/"))
.filter(|s| is_safe_browser_path(s))
.unwrap_or(fallback)
}
/// Resolve the post-action redirect target from the `Referer` header.
///
/// Extracts only the path component and requires it to start with `/web/`.
/// Schema and host are dropped entirely, so the resulting redirect is always
/// Extracts only the path component and applies the same same-origin
/// browser-path predicate as `validated_return_path`. Schema and host
/// are dropped entirely, so the resulting redirect is always
/// same-origin — a hostile `Referer` cannot cause an open redirect.
fn resolve_return_path(headers: &HeaderMap) -> String {
const FALLBACK: &str = "/web/cases";
const FALLBACK: &str = "/cases";
let Some(referer) = headers
.get(axum::http::header::REFERER)
.and_then(|v| v.to_str().ok())
@@ -188,22 +190,47 @@ fn resolve_return_path(headers: &HeaderMap) -> String {
},
None => referer,
};
if path_and_query.starts_with("/web/") {
// The predicate looks at the path part only; a `?query` tail is fine
// because none of the deny-rules trigger on `?`.
if is_safe_browser_path(path_and_query) {
path_and_query.to_string()
} else {
FALLBACK.into()
}
}
/// Like `resolve_return_path`, but collapses a `/web/cases/{uuid}` detail
/// path to `/web/cases` while preserving the query string. Used by
/// Same-origin browser-path predicate. Mirrors `magic::is_safe_return_to`
/// in spirit but is duplicated locally — the two call sites operate on
/// different input shapes and are independent enough that a shared
/// helper would be premature abstraction.
fn is_safe_browser_path(path: &str) -> bool {
if path.is_empty() || path == "/" {
return false;
}
if !path.starts_with('/') {
return false;
}
if path.starts_with("//") {
return false;
}
if path.starts_with("/api/") {
return false;
}
if path.contains('\\') {
return false;
}
true
}
/// Like `resolve_return_path`, but collapses a `/cases/{uuid}` detail
/// path to `/cases` while preserving the query string. Used by
/// handlers that remove the case from the detail view (close, purge) —
/// a redirect back to the now-404 detail page would be a dead end, but
/// losing `?show_closed=1` would eject the user from the show-closed
/// view they were in.
fn resolve_list_return_path(headers: &HeaderMap) -> String {
let raw = resolve_return_path(headers);
let Some(tail) = raw.strip_prefix("/web/cases/") else {
let Some(tail) = raw.strip_prefix("/cases/") else {
return raw;
};
let (case_part, query) = match tail.split_once('?') {
@@ -214,8 +241,8 @@ fn resolve_list_return_path(headers: &HeaderMap) -> String {
return raw;
}
match query {
Some(q) => format!("/web/cases?{q}"),
None => "/web/cases".into(),
Some(q) => format!("/cases?{q}"),
None => "/cases".into(),
}
}
@@ -407,7 +434,7 @@ pub(crate) async fn reset_case_artefacts(
Ok(())
}
/// POST /web/cases/{case_id}/close
/// POST /cases/{case_id}/close
///
/// Close the case: writes a `.closed` JSON marker into the case directory.
/// The case immediately disappears from listings and detail views (404).
@@ -451,7 +478,7 @@ pub async fn handle_close_case(
Ok(Redirect::to(&resolve_list_return_path(&headers)))
}
/// POST /web/cases/{case_id}/reopen
/// POST /cases/{case_id}/reopen
///
/// Reopen a closed case: removes the `.closed` marker. The case is
/// immediately back in the default listing. A reopened case is
@@ -504,7 +531,7 @@ pub async fn handle_reopen_case(
Ok(Redirect::to(&return_path))
}
/// POST /web/cases/{case_id}/reset
/// POST /cases/{case_id}/reset
///
/// Admin-only. Wipes all derived artefacts (transcripts, oneliner,
/// analysis input, document) and un-fails audio (`.m4a.failed` → `.m4a`)
@@ -539,7 +566,7 @@ pub async fn handle_reset_case(
);
Ok(Redirect::to(&validated_return_path(
form.return_to,
format!("/web/cases/{case_id}"),
format!("/cases/{case_id}"),
)))
}
@@ -559,7 +586,7 @@ impl HasCsrfToken for PurgeClosedForm {
}
}
/// POST /web/cases/purge-closed
/// POST /cases/purge-closed
///
/// Hard-delete every case under the user's data dir that carries a
/// `.closed` marker. Requires form field `confirm=yes` as a server-side
@@ -593,7 +620,7 @@ pub async fn handle_purge_closed(
Ok(r) => r,
Err(e) => {
warn!(slug = %user.slug, error = %e, "purge-closed: read_dir failed");
return Ok(Redirect::to("/web/cases"));
return Ok(Redirect::to("/cases"));
}
};
while let Ok(Some(entry)) = entries.next_entry().await {
@@ -626,7 +653,7 @@ pub async fn handle_purge_closed(
ok += 1;
}
info!(slug = %user.slug, ok, skipped, "purge-closed completed");
Ok(Redirect::to("/web/cases"))
Ok(Redirect::to("/cases"))
}
#[derive(Deserialize)]
@@ -642,7 +669,7 @@ impl HasCsrfToken for DeleteRecordingForm {
}
}
/// POST /web/cases/{case_id}/recordings/delete
/// POST /cases/{case_id}/recordings/delete
///
/// Hard-delete a single recording plus its transcript / duration sidecars
/// and invalidate derived artefacts (`oneliner.json`, `document.md`,
@@ -706,7 +733,7 @@ pub async fn handle_delete_recording(
CaseEventKind::RecordingDeleted,
);
Ok(Redirect::to(&format!("/web/cases/{case_id}/recordings")))
Ok(Redirect::to(&format!("/cases/{case_id}/recordings")))
}
async fn remove_if_exists(path: &Path) -> Result<(), AppError> {
@@ -733,52 +760,70 @@ mod tests {
#[test]
fn no_referer_falls_back_to_case_list() {
assert_eq!(resolve_return_path(&HeaderMap::new()), "/web/cases");
assert_eq!(resolve_return_path(&HeaderMap::new()), "/cases");
}
#[test]
fn absolute_referer_returns_path_only() {
let h = headers_with_referer("https://doctate.internal/web/cases/ABCD");
assert_eq!(resolve_return_path(&h), "/web/cases/ABCD");
let h = headers_with_referer("https://doctate.internal/cases/ABCD");
assert_eq!(resolve_return_path(&h), "/cases/ABCD");
}
#[test]
fn query_string_is_preserved() {
let h = headers_with_referer("https://doctate.internal/web/cases/ABCD?x=1");
assert_eq!(resolve_return_path(&h), "/web/cases/ABCD?x=1");
let h = headers_with_referer("https://doctate.internal/cases/ABCD?x=1");
assert_eq!(resolve_return_path(&h), "/cases/ABCD?x=1");
}
#[test]
fn hostile_host_is_stripped() {
// Open-redirect protection: the host component is discarded; only the
// path is reused. A crafted referer cannot redirect off-site.
let h = headers_with_referer("https://evil.example/web/cases/ABCD");
assert_eq!(resolve_return_path(&h), "/web/cases/ABCD");
let h = headers_with_referer("https://evil.example/cases/ABCD");
assert_eq!(resolve_return_path(&h), "/cases/ABCD");
}
#[test]
fn non_web_path_falls_back() {
fn arbitrary_browser_path_is_accepted() {
// The predicate is a deny-list: any same-origin path that is not
// under `/api/` and not protocol-relative passes through. A future
// route under `/admin/...` would not need a code change here.
let h = headers_with_referer("https://doctate.internal/admin/secret");
assert_eq!(resolve_return_path(&h), "/web/cases");
assert_eq!(resolve_return_path(&h), "/admin/secret");
}
#[test]
fn api_referer_falls_back() {
// A Referer that points at a JSON endpoint must not become the
// redirect target — would land the user on a JSON response.
let h = headers_with_referer("https://doctate.internal/api/upload");
assert_eq!(resolve_return_path(&h), "/cases");
}
#[test]
fn protocol_relative_referer_falls_back() {
// `//evil.example/...` is protocol-relative — would jump host.
let h = headers_with_referer("//evil.example/cases/ABCD");
assert_eq!(resolve_return_path(&h), "/cases");
}
#[test]
fn relative_referer_passes_through() {
let h = headers_with_referer("/web/cases/XYZ");
assert_eq!(resolve_return_path(&h), "/web/cases/XYZ");
let h = headers_with_referer("/cases/XYZ");
assert_eq!(resolve_return_path(&h), "/cases/XYZ");
}
#[test]
fn bare_host_without_path_falls_back() {
let h = headers_with_referer("https://doctate.internal");
assert_eq!(resolve_return_path(&h), "/web/cases");
assert_eq!(resolve_return_path(&h), "/cases");
}
#[test]
fn non_ascii_header_falls_back() {
let mut h = HeaderMap::new();
h.insert(REFERER, HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap());
assert_eq!(resolve_return_path(&h), "/web/cases");
assert_eq!(resolve_return_path(&h), "/cases");
}
// `resolve_list_return_path` is `resolve_return_path` plus a case-
@@ -787,27 +832,26 @@ mod tests {
#[test]
fn list_return_path_preserves_list_referer() {
let h = headers_with_referer("/web/cases?show_closed=1");
assert_eq!(resolve_list_return_path(&h), "/web/cases?show_closed=1");
let h = headers_with_referer("/cases?show_closed=1");
assert_eq!(resolve_list_return_path(&h), "/cases?show_closed=1");
}
#[test]
fn list_return_path_collapses_detail_with_query() {
let h =
headers_with_referer("/web/cases/11111111-1111-1111-1111-111111111111?show_closed=1");
assert_eq!(resolve_list_return_path(&h), "/web/cases?show_closed=1");
let h = headers_with_referer("/cases/11111111-1111-1111-1111-111111111111?show_closed=1");
assert_eq!(resolve_list_return_path(&h), "/cases?show_closed=1");
}
#[test]
fn list_return_path_collapses_bare_detail() {
let h = headers_with_referer("/web/cases/11111111-1111-1111-1111-111111111111");
assert_eq!(resolve_list_return_path(&h), "/web/cases");
let h = headers_with_referer("/cases/11111111-1111-1111-1111-111111111111");
assert_eq!(resolve_list_return_path(&h), "/cases");
}
#[test]
fn list_return_path_leaves_non_uuid_segment_alone() {
// Non-UUID `/web/cases/<x>` is not a detail page — don't touch it.
let h = headers_with_referer("/web/cases/undo-delete");
assert_eq!(resolve_list_return_path(&h), "/web/cases/undo-delete");
// Non-UUID `/cases/<x>` is not a detail page — don't touch it.
let h = headers_with_referer("/cases/undo-delete");
assert_eq!(resolve_list_return_path(&h), "/cases/undo-delete");
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
use crate::auth::AuthenticatedWebUser;
use crate::events::EventSender;
/// `GET /web/events` — `text/event-stream`, one connection per tab.
/// `GET /events` — `text/event-stream`, one connection per tab.
pub async fn handle_events(
user: AuthenticatedWebUser,
State(events_tx): State<EventSender>,
+2 -2
View File
@@ -90,7 +90,7 @@ pub async fn handle_login_submit(
let cookie = build_session_cookie(token, ttl, config.cookie_secure);
let jar = jar.add(cookie);
Ok((jar, Redirect::to("/web/cases")).into_response())
Ok((jar, Redirect::to("/cases")).into_response())
}
pub async fn handle_logout(
@@ -113,7 +113,7 @@ pub async fn handle_logout(
build_session_cookie(String::new(), Duration::from_secs(0), config.cookie_secure);
cookie.make_removal();
let jar = jar.add(cookie);
Ok((jar, Redirect::to("/web/login")).into_response())
Ok((jar, Redirect::to("/login")).into_response())
}
fn render_login(error: Option<&'static str>) -> Result<Html<String>, AppError> {
+25 -17
View File
@@ -6,8 +6,8 @@
//! 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
//! `{server_url}/magic?token=…`.
//! 3. The browser hits `GET /magic`. The server consumes the token
//! (one-time-use), installs a regular `WebSession`, sets the session
//! cookie, and 303-redirects to `return_to`.
@@ -34,7 +34,7 @@ use crate::web_session::{SessionStore, WebSession, build_session_cookie, generat
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";
const DEFAULT_RETURN_TO: &str = "/cases";
#[derive(Deserialize, Default)]
pub struct MagicLinkRequest {
@@ -60,12 +60,13 @@ pub async fn handle_create(
.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.
// Open-redirect guard: only accept same-origin browser paths.
// Reject schemes, hosts, protocol-relative URLs, traversal, and
// anything under `/api/` (would land the user on a JSON endpoint).
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(),
"return_to must be a same-origin browser path".into(),
));
}
@@ -162,24 +163,28 @@ pub async fn handle_consume(
fn redirect_to_login() -> Response {
Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/web/login")
.header(header::LOCATION, "/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.
/// Allow only same-origin browser paths. Rejects schemes, host
/// authorities, protocol-relative URLs (`//evil.com/...`), the bare
/// root and empty string, anything under `/api/` (JSON endpoints
/// must never be a magic-link target), and `\` traversal hacks.
fn is_safe_return_to(path: &str) -> bool {
if path.is_empty() {
if path.is_empty() || path == "/" {
return false;
}
if !path.starts_with('/') {
return false; // schemes, hosts, relative paths
}
if path.starts_with("//") {
return false; // protocol-relative — would jump host
}
if !path.starts_with("/web/") {
return false;
if path.starts_with("/api/") {
return false; // keep magic-links inside the browser-facing area
}
// Defensive: a `\` anywhere can confuse some legacy parsers into
// treating the rest as an authority. Reject outright.
@@ -194,11 +199,12 @@ mod tests {
use super::*;
#[test]
fn safe_return_to_accepts_web_paths() {
assert!(is_safe_return_to("/web/cases"));
fn safe_return_to_accepts_browser_paths() {
assert!(is_safe_return_to("/cases"));
assert!(is_safe_return_to(
"/web/cases/123e4567-e89b-12d3-a456-426614174000"
"/cases/123e4567-e89b-12d3-a456-426614174000"
));
assert!(is_safe_return_to("/login"));
}
#[test]
@@ -208,6 +214,8 @@ mod tests {
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"));
assert!(!is_safe_return_to("/api/cases/abc/oneliner"));
assert!(!is_safe_return_to("/cases\\evil"));
assert!(!is_safe_return_to("relative/path"));
}
}
+18 -19
View File
@@ -12,12 +12,14 @@ pub(crate) mod user_web;
pub(crate) mod web;
use axum::Router;
use axum::response::Redirect;
use axum::routing::{get, post, put};
use crate::AppState;
pub fn api_router() -> Router<AppState> {
Router::new()
.route("/", get(|| async { Redirect::permanent("/cases") }))
.route("/api/health", get(health::handle_health))
.route("/api/debug/whoami", get(debug::handle_whoami))
.route("/api/upload", post(upload::handle_upload))
@@ -27,50 +29,47 @@ pub fn api_router() -> Router<AppState> {
put(oneliner_override::handle_put_oneliner),
)
.route("/api/auth/magic-link", post(magic::handle_create))
.route("/web/magic", get(magic::handle_consume))
.route("/magic", get(magic::handle_consume))
.route(
"/web/login",
"/login",
get(login::handle_login_page).post(login::handle_login_submit),
)
.route("/web/logout", post(login::handle_logout))
.route("/web/cases", get(user_web::handle_my_cases))
.route("/web/cases/{case_id}", get(user_web::handle_case_page))
.route("/logout", post(login::handle_logout))
.route("/cases", get(user_web::handle_my_cases))
.route("/cases/{case_id}", get(user_web::handle_case_page))
.route(
"/web/cases/{case_id}/recordings",
"/cases/{case_id}/recordings",
get(user_web::handle_case_recordings),
)
.route(
"/web/cases/{case_id}/recordings/delete",
"/cases/{case_id}/recordings/delete",
post(case_actions::handle_delete_recording),
)
.route(
"/web/cases/{case_id}/analyze",
"/cases/{case_id}/analyze",
post(case_actions::handle_analyze_case),
)
.route(
"/web/cases/{case_id}/close",
"/cases/{case_id}/close",
post(case_actions::handle_close_case),
)
.route(
"/web/cases/{case_id}/reopen",
"/cases/{case_id}/reopen",
post(case_actions::handle_reopen_case),
)
.route(
"/web/cases/{case_id}/reset",
"/cases/{case_id}/reset",
post(case_actions::handle_reset_case),
)
.route(
"/web/cases/{case_id}/oneliner",
"/cases/{case_id}/oneliner",
post(oneliner_override::handle_web_put_oneliner),
)
.route(
"/web/cases/purge-closed",
"/cases/purge-closed",
post(case_actions::handle_purge_closed),
)
.route("/web/cases/bulk", post(bulk::handle_bulk_action))
.route(
"/web/audio/{user}/{case_id}/{filename}",
get(web::handle_audio),
)
.route("/web/events", get(events::handle_events))
.route("/cases/bulk", post(bulk::handle_bulk_action))
.route("/audio/{user}/{case_id}/{filename}", get(web::handle_audio))
.route("/events", get(events::handle_events))
}
+2 -2
View File
@@ -1,7 +1,7 @@
//! Doctor's manual oneliner override — two entry points sharing one core.
//!
//! - `PUT /api/cases/{case_id}/oneliner` (X-API-Key) for native clients.
//! - `POST /web/cases/{case_id}/oneliner` (session + CSRF) for the
//! - `POST /cases/{case_id}/oneliner` (session + CSRF) for the
//! browser-based web UI.
//!
//! Both routes funnel into [`apply_manual_override`], which validates
@@ -139,7 +139,7 @@ impl HasCsrfToken for OnelinerWebForm {
}
}
/// `POST /web/cases/{case_id}/oneliner` — session + CSRF authenticated.
/// `POST /cases/{case_id}/oneliner` — session + CSRF authenticated.
///
/// Mirrors [`handle_put_oneliner`] but uses the web auth stack so the
/// browser never has to expose an X-API-Key. Closed cases are rejected
+4 -4
View File
@@ -295,7 +295,7 @@ struct MyCasesTemplate {
csrf_token: String,
}
/// Query params for GET /web/cases and /web/cases/{case_id}.
/// Query params for GET /cases and /cases/{case_id}.
#[derive(Debug, Default, Deserialize)]
pub struct CaseListQuery {
/// `?show_closed=1` expands the list to include closed cases and
@@ -582,7 +582,7 @@ pub async fn handle_my_cases(
let retention = user_record.map(|u| u.retention.clone()).unwrap_or_default();
let preview_lines = user_record.map(|u| u.preview_lines).unwrap_or(2);
// Lazy retention sweep: at most one disk scan per /web/cases visit,
// Lazy retention sweep: at most one disk scan per /cases visit,
// reusing the user's retention policy from users.toml. Runs before
// the listing scan so any auto-close/auto-purge actions are
// reflected in the same response.
@@ -629,7 +629,7 @@ pub async fn handle_my_cases(
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}
/// GET /web/cases/{case_id}
/// GET /cases/{case_id}
///
/// Canonical case page. Renders the document inline if present; otherwise
/// shows status (analyzing / placeholder for empty / recordings-summary).
@@ -771,7 +771,7 @@ pub async fn handle_case_page(
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}
/// GET /web/cases/{case_id}/recordings
/// GET /cases/{case_id}/recordings
///
/// Read-only sub-page showing all `.m4a` + transcript pairs for the case.
/// Useful for power-users/admins; not part of the day-to-day workflow.
+2 -2
View File
@@ -13,8 +13,8 @@ pub struct WebSession {
pub expires_at: Instant,
/// CSRF token — bound to the session for its full lifetime. Minted
/// alongside the session cookie via [`generate_token`] and validated
/// by the `CsrfForm` extractor on every state-changing POST under
/// `/web/`. Not rotated per request (would break multi-tab use).
/// by the `CsrfForm` extractor on every state-changing browser POST.
/// Not rotated per request (would break multi-tab use).
pub csrf_token: String,
}
+20 -20
View File
@@ -330,13 +330,13 @@
</head>
<body>
<header>
<div><a class="back" href="/web/cases">&larr; Fälle</a></div>
<div><a class="back" href="/cases">&larr; Fälle</a></div>
<div class="header-right">
{% if is_admin %}<label class="admin-toggle"
><input type="checkbox" id="admin-view-toggle" /> Admin
view</label
>{% endif %}
<form method="post" action="/web/logout">
<form method="post" action="/logout">
{% call csrf::field(csrf_token) %}
<button type="submit">Logout</button>
</form>
@@ -362,7 +362,7 @@
<form
class="delete-form"
method="post"
action="/web/cases/{{ case_id }}/reopen"
action="/cases/{{ case_id }}/reopen"
>
{% call csrf::field(csrf_token) %}
<button
@@ -393,7 +393,7 @@
<form
class="delete-form"
method="post"
action="/web/cases/{{ case_id }}/close"
action="/cases/{{ case_id }}/close"
>
{% call csrf::field(csrf_token) %}
<button
@@ -435,15 +435,15 @@
<div class="actions">
{% if can_analyze && !has_document %}
<form method="post" action="/web/cases/{{ case_id }}/analyze">
<form method="post" action="/cases/{{ case_id }}/analyze">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="return_to" value="/web/cases/{{ case_id }}">
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
<button type="submit">Analysieren</button>
</form>
{% if is_admin %}
<form class="admin-only" method="post" action="/web/cases/{{ case_id }}/analyze">
<form class="admin-only" method="post" action="/cases/{{ case_id }}/analyze">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="return_to" value="/web/cases/{{ case_id }}">
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
{% for b in backends %}
<button type="submit" name="backend" value="{{ b.id }}"
title="Analysieren mit {{ b.label }}">{{ b.label }}</button>
@@ -456,10 +456,10 @@
<form
class="admin-only"
method="post"
action="/web/cases/{{ case_id }}/reset"
action="/cases/{{ case_id }}/reset"
>
{% call csrf::field(csrf_token) %}
<input type="hidden" name="return_to" value="/web/cases/{{ case_id }}">
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
<button type="submit">Reset</button>
</form>
<button
@@ -478,9 +478,9 @@
<div id="doc-content" class="doc-content">
{% if can_analyze %}
<div class="doc-actions-top">
<form method="post" action="/web/cases/{{ case_id }}/analyze">
<form method="post" action="/cases/{{ case_id }}/analyze">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="return_to" value="/web/cases/{{ case_id }}">
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
<button
type="submit"
class="copy-btn"
@@ -506,9 +506,9 @@
</button>
</form>
{% if is_admin %}
<form class="admin-only" method="post" action="/web/cases/{{ case_id }}/analyze">
<form class="admin-only" method="post" action="/cases/{{ case_id }}/analyze">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="return_to" value="/web/cases/{{ case_id }}">
<input type="hidden" name="return_to" value="/cases/{{ case_id }}">
{% for b in backends %}
<button
type="submit"
@@ -625,13 +625,13 @@
</details>
<form
method="post"
action="/web/cases/{{ case_id }}/analyze"
action="/cases/{{ case_id }}/analyze"
>
{% call csrf::field(csrf_token) %}
<input
type="hidden"
name="return_to"
value="/web/cases/{{ case_id }}"
value="/cases/{{ case_id }}"
/>
<button type="submit">Erneut versuchen</button>
</form>
@@ -639,13 +639,13 @@
<form
class="admin-only"
method="post"
action="/web/cases/{{ case_id }}/analyze"
action="/cases/{{ case_id }}/analyze"
>
{% call csrf::field(csrf_token) %}
<input
type="hidden"
name="return_to"
value="/web/cases/{{ case_id }}"
value="/cases/{{ case_id }}"
/>
{% for b in backends %}
<button type="submit" name="backend" value="{{ b.id }}">
@@ -669,7 +669,7 @@
<div>
<a
class="recordings-link"
href="/web/cases/{{ case_id }}/recordings"
href="/cases/{{ case_id }}/recordings"
>&rarr; Aufnahmen anzeigen ({{ recordings_count }})</a
>
</div>
@@ -730,7 +730,7 @@
clearTimeout(timer);
timer = setTimeout(() => location.reload(), 300);
};
const es = new EventSource("/web/events");
const es = new EventSource("/events");
// Release the socket-pool slot on navigation. Without this, Chrome
// keeps the SSE connection "draining" after unload; six rapid nav
// cycles exhaust the 6-per-origin pool and stall further requests.
+7 -7
View File
@@ -123,17 +123,17 @@ try {
<body>
<header>
<div class="breadcrumb">
<a href="/web/cases">&larr; Fälle</a>
<a href="/cases">&larr; Fälle</a>
{% match oneliner %}
{% when Some with (t) %}
<a href="/web/cases/{{ case_id }}">&larr; {{ t }}</a>
<a href="/cases/{{ case_id }}">&larr; {{ t }}</a>
{% when None %}
<a href="/web/cases/{{ case_id }}">&larr; Fall</a>
<a href="/cases/{{ case_id }}">&larr; Fall</a>
{% endmatch %}
</div>
<div class="header-right">
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
<form method="post" action="/web/logout">{% call csrf::field(csrf_token) %}<button type="submit">Logout</button></form>
<form method="post" action="/logout">{% call csrf::field(csrf_token) %}<button type="submit">Logout</button></form>
</div>
</header>
<h1>Aufnahmen</h1>
@@ -143,12 +143,12 @@ try {
<div class="recording{% if rec.failed %} failed-row{% endif %}">
<div class="recording-head">
<time class="rec-time" datetime="{{ rec.recorded_at_iso }}">{{ rec.recorded_at_iso }}</time>
<div class="player" data-src="/web/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}"{% match rec.duration_seconds %}{% when Some with (d) %} data-duration="{{ d }}"{% when None %}{% endmatch %}{% match rec.gain_db %}{% when Some with (g) %} data-gain-db="{{ g }}"{% when None %}{% endmatch %}>
<div class="player" data-src="/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}"{% match rec.duration_seconds %}{% when Some with (d) %} data-duration="{{ d }}"{% when None %}{% endmatch %}{% match rec.gain_db %}{% when Some with (g) %} data-gain-db="{{ g }}"{% when None %}{% endmatch %}>
<button type="button" class="play" aria-label="Abspielen"></button>
<input type="range" class="seek" min="0" max="1000" value="0" step="1" aria-label="Position">
<span class="time">0:00 / 0:00</span>
</div>
<form method="post" action="/web/cases/{{ case_id }}/recordings/delete" class="rec-delete-form" data-ts="{{ rec.recorded_at_iso }}">
<form method="post" action="/cases/{{ case_id }}/recordings/delete" class="rec-delete-form" data-ts="{{ rec.recorded_at_iso }}">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="filename" value="{{ rec.filename }}">
<button type="submit" class="delete-btn" title="Aufnahme entfernen" aria-label="Aufnahme entfernen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg></button>
@@ -406,7 +406,7 @@ try {
else location.reload();
}, 300);
};
const es = new EventSource('/web/events');
const es = new EventSource('/events');
// Release the socket-pool slot on navigation. Without this, Chrome
// keeps the SSE connection "draining" after unload; six rapid nav
// cycles exhaust the 6-per-origin pool and stall further requests.
+1 -1
View File
@@ -20,7 +20,7 @@ button { cursor: pointer; margin-top: 0.4em; }
<div class="error">{{ msg }}</div>
{% when None %}
{% endmatch %}
<form method="post" action="/web/login">
<form method="post" action="/login">
<label>Benutzer<input type="text" name="slug" autofocus required></label>
<label>Passwort<input type="password" name="password" required></label>
<button type="submit">Anmelden</button>
+13 -13
View File
@@ -88,22 +88,22 @@ try {
<h1>{{ display_name }}</h1>
<div class="header-right">
{% if is_admin %}<label class="admin-toggle"><input type="checkbox" id="admin-view-toggle"> Admin view</label>{% endif %}
<form method="post" action="/web/logout">{% call csrf::field(csrf_token) %}<button type="submit">Logout</button></form>
<form method="post" action="/logout">{% call csrf::field(csrf_token) %}<button type="submit">Logout</button></form>
</div>
</header>
{% if show_closed %}
<a class="closed-toggle" href="/web/cases">← Nur offene Fälle anzeigen</a>
<a class="closed-toggle" href="/cases">← Nur offene Fälle anzeigen</a>
{% else %}
<a class="closed-toggle" href="/web/cases?show_closed=1">Geschlossene Fälle einblenden</a>
<a class="closed-toggle" href="/cases?show_closed=1">Geschlossene Fälle einblenden</a>
{% endif %}
{% if total == 0 %}
<section><p class="empty">{% if show_closed %}Keine offenen oder geschlossenen Fälle.{% else %}Keine Fälle.{% endif %}</p></section>
{% else %}
<form method="post" action="/web/cases/bulk">
<form method="post" action="/cases/bulk">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="return_to" value="/web/cases{% if show_closed %}?show_closed=1{% endif %}">
<input type="hidden" name="return_to" value="/cases{% if show_closed %}?show_closed=1{% endif %}">
{% for g in groups %}
<section>
@@ -114,19 +114,19 @@ try {
{% if is_admin %}<div class="check admin-only"><input type="checkbox" name="case_id" value="{{ case.case_id }}"{% if case.is_closed %} disabled{% endif %}></div>{% endif %}
<div class="body">
<div class="line1">
<a class="case-link" href="/web/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}{% if case.oneliner_is_manual %} <span class="manual-marker" title="manuell bearbeitet" aria-label="manuell bearbeitet">👤</span>{% endif %}{% if case.has_failed_recording %} <span class="status-badge fehler">Fehler</span>{% else if case.has_document %} <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %} <span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</a>
<a class="recordings-link" href="/web/cases/{{ case.case_id }}/recordings{% if case.is_closed %}?show_closed=1{% endif %}">{{ case.recordings_count }} Aufnahmen</a>
<a class="case-link" href="/cases/{{ case.case_id }}{% if case.is_closed %}?show_closed=1{% endif %}"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span> — {% call ol::render(case.oneliner) %}{% if case.oneliner_is_manual %} <span class="manual-marker" title="manuell bearbeitet" aria-label="manuell bearbeitet">👤</span>{% endif %}{% if case.has_failed_recording %} <span class="status-badge fehler">Fehler</span>{% else if case.has_document %} <span class="status-badge done">ausgewertet</span>{% else if !case.is_closed %} <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}{% if case.is_closed %} <span class="closed-badge">geschlossen{% match case.days_until_purge %}{% when Some with (d) %} — wird in {{ d }} Tagen entfernt{% when None %}{% endmatch %}</span>{% endif %}</a>
<a class="recordings-link" href="/cases/{{ case.case_id }}/recordings{% if case.is_closed %}?show_closed=1{% endif %}">{{ case.recordings_count }} Aufnahmen</a>
</div>
{% match case.analysis_html %}{% when Some with (html) %}<div class="analysis" data-expand><span class="arrow"></span><div class="analysis-content">{{ html|safe }}</div></div>{% when None %}{% endmatch %}
{% if is_admin %}<div class="uuid admin-only">{{ case.case_id }}</div>{% endif %}
</div>
<div class="actions">
{% if is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze" class="admin-only"{% if !case.can_analyze || case.is_closed %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
<button type="submit" formaction="/web/cases/{{ case.case_id }}/reset" class="admin-only"{% if case.is_closed %} disabled{% endif %}>Reset</button>{% endif %}
{% if is_admin %}<button type="submit" formaction="/cases/{{ case.case_id }}/analyze" class="admin-only"{% if !case.can_analyze || case.is_closed %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
<button type="submit" formaction="/cases/{{ case.case_id }}/reset" class="admin-only"{% if case.is_closed %} disabled{% endif %}>Reset</button>{% endif %}
{% if case.is_closed %}
<button type="submit" formaction="/web/cases/{{ case.case_id }}/reopen" class="reopen-btn" title="Fall wiedereröffnen" aria-label="Fall wiedereröffnen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg></button>
<button type="submit" formaction="/cases/{{ case.case_id }}/reopen" class="reopen-btn" title="Fall wiedereröffnen" aria-label="Fall wiedereröffnen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg></button>
{% else %}
<button type="submit" formaction="/web/cases/{{ case.case_id }}/close" class="delete-btn" title="Fall schließen" aria-label="Fall schließen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" x2="10" y1="11" y2="17"></line><line x1="14" x2="14" y1="11" y2="17"></line></svg></button>
<button type="submit" formaction="/cases/{{ case.case_id }}/close" class="delete-btn" title="Fall schließen" aria-label="Fall schließen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" x2="10" y1="11" y2="17"></line><line x1="14" x2="14" y1="11" y2="17"></line></svg></button>
{% endif %}
</div>
</li>
@@ -143,7 +143,7 @@ try {
</div>{% endif %}
</form>
{% if is_admin && show_closed && any_closed %}
<form class="bulk-bar purge admin-only" method="post" action="/web/cases/purge-closed" onsubmit="return confirm('Alle geschlossenen Fälle UNWIDERRUFLICH löschen? Diese Aktion kann nicht rückgängig gemacht werden.')">
<form class="bulk-bar purge admin-only" method="post" action="/cases/purge-closed" onsubmit="return confirm('Alle geschlossenen Fälle UNWIDERRUFLICH löschen? Diese Aktion kann nicht rückgängig gemacht werden.')">
{% call csrf::field(csrf_token) %}
<input type="hidden" name="confirm" value="yes">
<span>Geschlossene Fälle:</span>
@@ -222,7 +222,7 @@ try {
clearTimeout(timer);
timer = setTimeout(() => location.reload(), 300);
};
const es = new EventSource('/web/events');
const es = new EventSource('/events');
// Release the socket-pool slot on navigation. Without this, Chrome
// keeps the SSE connection "draining" after unload; six rapid nav
// cycles exhaust the 6-per-origin pool and stall further requests.
+2 -2
View File
@@ -1,5 +1,5 @@
// Inline tap-to-edit for .oneliner-edit blocks. Click swaps the inner
// .oneliner-text for an <input>; Enter POSTs to /web/cases/{id}/oneliner;
// .oneliner-text for an <input>; Enter POSTs to /cases/{id}/oneliner;
// Escape (or blur) cancels. The SSE-driven reload picks up the persisted
// state, so no DOM patching is needed on success.
(() => {
@@ -37,7 +37,7 @@
});
try {
const r = await fetch(
`/web/cases/${host.dataset.caseId}/oneliner`,
`/cases/${host.dataset.caseId}/oneliner`,
{ method: 'POST', body, credentials: 'same-origin' }
);
if (!r.ok) {
+11 -11
View File
@@ -222,7 +222,7 @@ async fn analyze_case_happy_path_writes_input_and_redirects() {
.unwrap()
.to_str()
.unwrap(),
format!("/web/cases/{case_id}")
format!("/cases/{case_id}")
);
let input_path = case_dir.join(ANALYSIS_INPUT_FILE);
@@ -713,7 +713,7 @@ async fn close_writes_marker_and_hides_case() {
.unwrap()
.to_str()
.unwrap(),
"/web/cases"
"/cases"
);
let marker = case_dir.join(CLOSE_MARKER);
assert!(marker.exists(), ".closed marker missing");
@@ -833,12 +833,12 @@ async fn show_closed_query_includes_closed_cases() {
.await;
assert!(
!body.contains(case_id),
"default /web/cases must hide closed cases"
"default /cases must hide closed cases"
);
// With show_closed=1 it must appear, with the closed badge.
let body = common::body_string(
app.oneshot(get_with_cookie("/web/cases?show_closed=1", &cookie))
app.oneshot(get_with_cookie("/cases?show_closed=1", &cookie))
.await
.unwrap(),
)
@@ -912,7 +912,7 @@ async fn close_redirects_back_to_referer_query() {
let mut req = csrf_form_post(paths::case_close(case_id), &cookie, &csrf, "");
req.headers_mut()
.insert(header::REFERER, "/web/cases?show_closed=1".parse().unwrap());
.insert(header::REFERER, "/cases?show_closed=1".parse().unwrap());
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
@@ -922,13 +922,13 @@ async fn close_redirects_back_to_referer_query() {
.unwrap()
.to_str()
.unwrap(),
"/web/cases?show_closed=1"
"/cases?show_closed=1"
);
}
#[tokio::test]
async fn close_from_detail_redirects_to_list_preserving_query() {
// Close from /web/cases/{uuid}?show_closed=1 must NOT redirect back
// Close from /cases/{uuid}?show_closed=1 must NOT redirect back
// to the detail page (it 404s after the close). The uuid segment
// is stripped; the query survives so the user lands in show-closed.
let (cfg, settings) = cfg_llm("close-ref-det");
@@ -954,7 +954,7 @@ async fn close_from_detail_redirects_to_list_preserving_query() {
.unwrap()
.to_str()
.unwrap(),
"/web/cases?show_closed=1",
"/cases?show_closed=1",
"close from detail must redirect to the LIST with the query intact"
);
}
@@ -985,7 +985,7 @@ async fn reopen_redirects_back_to_referer() {
let mut req = csrf_form_post(paths::case_reopen(case_id), &cookie, &csrf, "");
req.headers_mut()
.insert(header::REFERER, "/web/cases?show_closed=1".parse().unwrap());
.insert(header::REFERER, "/cases?show_closed=1".parse().unwrap());
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
@@ -994,7 +994,7 @@ async fn reopen_redirects_back_to_referer() {
.unwrap()
.to_str()
.unwrap(),
"/web/cases?show_closed=1",
"/cases?show_closed=1",
"reopen must redirect back to the referer path (preserving the query)"
);
}
@@ -1290,7 +1290,7 @@ async fn reset_case_clears_derived_and_unfails_audio() {
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers().get(header::LOCATION).unwrap(),
&format!("/web/cases/{case_id}")
&format!("/cases/{case_id}")
);
// Audio preserved, .failed renamed to .m4a.
+3 -3
View File
@@ -17,7 +17,7 @@ async fn login_dr_a(app: &axum::Router) -> String {
}
// ---------------------------------------------------------------------
// GET /web/cases/{id} — case page
// GET /cases/{id} — case page
// ---------------------------------------------------------------------
#[tokio::test]
@@ -320,7 +320,7 @@ async fn case_page_foreign_user_returns_404() {
}
// ---------------------------------------------------------------------
// GET /web/cases/{id}/recordings — recordings sub-page
// GET /cases/{id}/recordings — recordings sub-page
// ---------------------------------------------------------------------
#[tokio::test]
@@ -373,7 +373,7 @@ async fn case_recordings_back_link_targets_case_page() {
assert!(
body.contains(&format!(r#"href="{}""#, paths::case_detail(case_id))),
"back link must target the case page, not /web/cases"
"back link must target the case page, not /cases"
);
}
+2 -2
View File
@@ -28,7 +28,7 @@ pub fn form_post<U: AsRef<str>>(uri: U, cookie: &str, body: impl Into<String>) -
/// Form-urlencoded POST that appends `csrf_token={csrf}` automatically.
/// Pass an empty `extra_body` when the form has no other fields
/// (e.g. POST /web/cases/{id}/close).
/// (e.g. POST /cases/{id}/close).
pub fn csrf_form_post<U: AsRef<str>>(
uri: U,
cookie: &str,
@@ -43,7 +43,7 @@ pub fn csrf_form_post<U: AsRef<str>>(
form_post(uri, cookie, body)
}
/// GET with a session cookie — authenticated `/web/*` pages.
/// GET with a session cookie — authenticated `/*` pages.
pub fn get_with_cookie<U: AsRef<str>>(uri: U, cookie: &str) -> Request<Body> {
Request::builder()
.method("GET")
+31 -31
View File
@@ -9,65 +9,65 @@
pub use doctate_common::{ONELINERS_PATH, UPLOAD_PATH};
// -- /web/* --
// -- browser UI routes --
/// `POST /web/login` — form login (slug, password).
pub const LOGIN: &str = "/web/login";
/// `POST /web/logout` — CSRF-protected logout.
pub const LOGOUT: &str = "/web/logout";
/// `GET /web/cases` — case list; also triggers the lazy retention sweep.
pub const CASES: &str = "/web/cases";
/// `POST /web/cases/bulk` — admin bulk action (close/analyze/reset).
pub const BULK: &str = "/web/cases/bulk";
/// `POST /web/cases/purge-closed` — admin hard-delete of closed cases.
pub const PURGE_CLOSED: &str = "/web/cases/purge-closed";
/// `GET /web/events` — SSE stream (per-user scoped).
pub const EVENTS: &str = "/web/events";
/// `POST /login` — form login (slug, password).
pub const LOGIN: &str = "/login";
/// `POST /logout` — CSRF-protected logout.
pub const LOGOUT: &str = "/logout";
/// `GET /cases` — case list; also triggers the lazy retention sweep.
pub const CASES: &str = "/cases";
/// `POST /cases/bulk` — admin bulk action (close/analyze/reset).
pub const BULK: &str = "/cases/bulk";
/// `POST /cases/purge-closed` — admin hard-delete of closed cases.
pub const PURGE_CLOSED: &str = "/cases/purge-closed";
/// `GET /events` — SSE stream (per-user scoped).
pub const EVENTS: &str = "/events";
// -- per-case builders --
/// `/web/cases/{case_id}`.
/// `/cases/{case_id}`.
pub fn case_detail(case_id: &str) -> String {
format!("/web/cases/{case_id}")
format!("/cases/{case_id}")
}
/// `/web/cases/{case_id}/recordings`.
/// `/cases/{case_id}/recordings`.
pub fn case_recordings(case_id: &str) -> String {
format!("/web/cases/{case_id}/recordings")
format!("/cases/{case_id}/recordings")
}
/// `POST /web/cases/{case_id}/close`.
/// `POST /cases/{case_id}/close`.
pub fn case_close(case_id: &str) -> String {
format!("/web/cases/{case_id}/close")
format!("/cases/{case_id}/close")
}
/// `POST /web/cases/{case_id}/reopen`.
/// `POST /cases/{case_id}/reopen`.
pub fn case_reopen(case_id: &str) -> String {
format!("/web/cases/{case_id}/reopen")
format!("/cases/{case_id}/reopen")
}
/// `POST /web/cases/{case_id}/analyze`.
/// `POST /cases/{case_id}/analyze`.
pub fn case_analyze(case_id: &str) -> String {
format!("/web/cases/{case_id}/analyze")
format!("/cases/{case_id}/analyze")
}
/// `POST /web/cases/{case_id}/reset` — admin-only hard-reset.
/// `POST /cases/{case_id}/reset` — admin-only hard-reset.
pub fn case_reset(case_id: &str) -> String {
format!("/web/cases/{case_id}/reset")
format!("/cases/{case_id}/reset")
}
/// `POST /web/cases/{case_id}/recordings/delete` — per-recording delete.
/// `POST /cases/{case_id}/recordings/delete` — per-recording delete.
pub fn recording_delete(case_id: &str) -> String {
format!("/web/cases/{case_id}/recordings/delete")
format!("/cases/{case_id}/recordings/delete")
}
/// `POST /web/cases/{case_id}/oneliner` — session-authenticated manual
/// `POST /cases/{case_id}/oneliner` — session-authenticated manual
/// oneliner override (browser web UI).
pub fn case_oneliner_web(case_id: &str) -> String {
format!("/web/cases/{case_id}/oneliner")
format!("/cases/{case_id}/oneliner")
}
/// `GET /web/magic?token={token}` — magic-link consumption.
/// `GET /magic?token={token}` — magic-link consumption.
pub fn magic(token: &str) -> String {
format!("/web/magic?token={token}")
format!("/magic?token={token}")
}
+2 -2
View File
@@ -30,7 +30,7 @@ pub fn extract_session_cookie(resp: &Response) -> Option<String> {
None
}
/// Build the `POST /web/login` request with a URL-encoded form body.
/// Build the `POST /login` request with a URL-encoded form body.
/// Pulled out so tests that need to *inspect* the login response
/// (e.g. session-fixation tests) can reuse the exact wire shape.
pub fn login_request(slug: &str, password: &str) -> Request<Body> {
@@ -62,7 +62,7 @@ pub async fn login(app: &Router, slug: &str, password: &str) -> String {
/// the session store. Pairs with
/// `doctate_server::create_router_and_session_store` — the returned
/// token is what the `CsrfForm<T>` extractor validates on every
/// state-changing POST under `/web/`.
/// state-changing POST under `/`.
///
/// Returns `(cookie_header_value, csrf_token)`.
pub async fn login_with_csrf(
+5 -6
View File
@@ -1,4 +1,4 @@
//! Attack-confirming tests for CSRF protection on /web/ state-changing
//! Attack-confirming tests for CSRF protection on / state-changing
//! endpoints.
//!
//! Each test crafts a request that simulates a real attack shape
@@ -185,7 +185,7 @@ async fn delete_recording_without_csrf_forbidden() {
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
/// Forced-logout CSRF: attacker page auto-POSTs /web/logout to sign the
/// Forced-logout CSRF: attacker page auto-POSTs /logout to sign the
/// victim out of their active session (annoyance / phishing setup where
/// victim re-enters password on a lookalike page).
#[tokio::test]
@@ -334,18 +334,17 @@ async fn my_cases_page_renders_csrf_token_hidden_field() {
let (app, store) = doctate_server::create_router_and_session_store(cfg);
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_admin", "s").await;
let body = html_body(&app, "/web/cases?show_closed=1", &cookie).await;
let body = html_body(&app, "/cases?show_closed=1", &cookie).await;
let expected = hidden_field(&csrf);
assert!(
body.contains(&expected),
"/web/cases should render hidden csrf_token ({expected}), \
"/cases should render hidden csrf_token ({expected}), \
first 200 chars of body: {}",
&body[..body.len().min(200)]
);
assert!(
body.contains(r#"action="/web/logout""#)
&& body.matches(r#"name="csrf_token""#).count() >= 2,
body.contains(r#"action="/logout""#) && body.matches(r#"name="csrf_token""#).count() >= 2,
"expected at least 2 csrf_token hidden fields (logout + bulk); \
got {} occurrences",
body.matches(r#"name="csrf_token""#).count()
+3 -3
View File
@@ -1,4 +1,4 @@
//! Per-recording delete endpoint: POST /web/cases/{case_id}/recordings/delete.
//! Per-recording delete endpoint: POST /cases/{case_id}/recordings/delete.
//!
//! Verifies the endpoint:
//! - removes the audio file and its `<stem>.json` metadata sidecar
@@ -59,7 +59,7 @@ async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() {
"delete should redirect (3xx), got {}",
resp.status()
);
let expected_location = format!("/web/cases/{case_id}/recordings");
let expected_location = format!("/cases/{case_id}/recordings");
assert_eq!(
resp.headers()
.get(axum::http::header::LOCATION)
@@ -217,7 +217,7 @@ async fn delete_last_recording_clears_case() {
"last-recording delete should still redirect, got {}",
resp.status()
);
let expected_location = format!("/web/cases/{case_id}/recordings");
let expected_location = format!("/cases/{case_id}/recordings");
assert_eq!(
resp.headers()
.get(axum::http::header::LOCATION)
+1 -1
View File
@@ -30,7 +30,7 @@ async fn login_success_sets_session_cookie_and_redirects() {
.unwrap()
.to_str()
.unwrap();
assert_eq!(loc, "/web/cases");
assert_eq!(loc, "/cases");
let cookie = extract_session_cookie(&resp).expect("no session cookie");
assert!(cookie.starts_with("session="));
+7 -10
View File
@@ -86,7 +86,7 @@ async fn create_with_valid_key_returns_token() {
let app = test_app();
let resp = app
.oneshot(create_request(
r#"{"return_to":"/web/cases/abc"}"#,
r#"{"return_to":"/cases/abc"}"#,
Some(API_KEY),
))
.await
@@ -106,7 +106,7 @@ async fn consume_valid_token_sets_cookie_and_redirects() {
let create_resp = app
.clone()
.oneshot(create_request(
r#"{"return_to":"/web/cases/abc"}"#,
r#"{"return_to":"/cases/abc"}"#,
Some(API_KEY),
))
.await
@@ -125,7 +125,7 @@ async fn consume_valid_token_sets_cookie_and_redirects() {
.unwrap()
.to_str()
.unwrap(),
"/web/cases/abc"
"/cases/abc"
);
assert_eq!(
resp.headers()
@@ -167,16 +167,13 @@ async fn consume_token_is_one_time_use() {
assert_eq!(first.status(), StatusCode::SEE_OTHER);
assert_eq!(
first.headers().get(header::LOCATION).unwrap(),
"/web/cases" // default
"/cases" // default
);
let second = app.oneshot(consume_request(&token)).await.unwrap();
// Second use: token gone, redirect to login.
assert_eq!(second.status(), StatusCode::SEE_OTHER);
assert_eq!(
second.headers().get(header::LOCATION).unwrap(),
"/web/login"
);
assert_eq!(second.headers().get(header::LOCATION).unwrap(), "/login");
}
#[tokio::test]
@@ -217,7 +214,7 @@ async fn consume_expired_token_redirects_to_login() {
PendingMagicLink {
slug: "dr_a".into(),
role: "doctor".into(),
return_to: "/web/cases".into(),
return_to: "/cases".into(),
expires_at: Instant::now() - Duration::from_secs(1),
},
);
@@ -226,7 +223,7 @@ async fn consume_expired_token_redirects_to_login() {
let app = doctate_server::create_router_with_state(state);
let resp = app.oneshot(consume_request(&token)).await.unwrap();
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/web/login");
assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login");
// And the token must be removed from the store, even though it expired.
assert!(store.read().await.get(&token).is_none());
+3 -3
View File
@@ -423,7 +423,7 @@ async fn parallel_puts_serialize_via_mutex() {
}
// =====================================================================
// 9-15. Web-UI endpoint: POST /web/cases/{case_id}/oneliner
// 9-15. Web-UI endpoint: POST /cases/{case_id}/oneliner
//
// Same disk semantics as the API PUT (both call `apply_manual_override`),
// but with browser auth: session cookie + CSRF form field. The closed-
@@ -515,7 +515,7 @@ async fn web_put_oneliner_without_session_redirects_to_login() {
.await
.unwrap();
// The CsrfForm extractor maps a missing session to a 302 redirect
// to /web/login (`AppError::Redirect`). Browsers follow it as a
// to /login (`AppError::Redirect`). Browsers follow it as a
// GET, which is exactly what we want — the user lands on the login
// page instead of staring at a 401.
assert_eq!(resp.status(), StatusCode::FOUND);
@@ -524,7 +524,7 @@ async fn web_put_oneliner_without_session_redirects_to_login() {
.get(header::LOCATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(loc, "/web/login");
assert_eq!(loc, "/login");
}
#[tokio::test]
+2 -2
View File
@@ -1,4 +1,4 @@
//! Admin-only case reset endpoint: POST /web/cases/{case_id}/reset.
//! Admin-only case reset endpoint: POST /cases/{case_id}/reset.
//!
//! Verifies the sticky-Manual rule for the reset path: a clinician-set
//! `OnelinerState::Manual` must survive an admin-triggered reset, while
@@ -58,7 +58,7 @@ async fn reset_case_preserves_manual_oneliner_override() {
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers().get(header::LOCATION).unwrap(),
&format!("/web/cases/{case_id}")
&format!("/cases/{case_id}")
);
// Recording metadata sidecar was wiped (the rest of the reset
+2 -2
View File
@@ -1,4 +1,4 @@
//! Lazy retention sweep: GET /web/cases must auto-close stale cases
//! Lazy retention sweep: GET /cases must auto-close stale cases
//! and auto-purge old closed cases, driven by per-user retention
//! settings from users.toml. Tests age the *comparison objects*
//! (m4a mtime, closed_at string) instead of faking "now" — gives
@@ -16,7 +16,7 @@ use common::{
seed_recording_with_age, test_user_with_retention, write_closed_marker,
};
/// Trigger the sweep via its natural path: GET /web/cases.
/// Trigger the sweep via its natural path: GET /cases.
async fn trigger_sweep(app: &axum::Router, cookie: &str) {
let resp = app
.clone()
+5 -5
View File
@@ -52,7 +52,7 @@ async fn api_health_has_all_security_headers() {
#[tokio::test]
async fn web_login_page_has_all_security_headers() {
let resp = get(test_app(), "/web/login").await;
let resp = get(test_app(), "/login").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
assert!(header_opt(&resp, "content-security-policy").is_some());
@@ -60,7 +60,7 @@ async fn web_login_page_has_all_security_headers() {
// ---------- per-attack tests ----------
/// Clickjacking: attacker embeds /web/cases in a hidden iframe on their
/// Clickjacking: attacker embeds /cases in a hidden iframe on their
/// own page and tricks the victim into clicking overlaid elements.
/// Defense: `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`.
#[tokio::test]
@@ -147,9 +147,9 @@ async fn permissions_policy_blocks_sensitive_features() {
/// short-circuit on error paths.
#[tokio::test]
async fn error_redirect_still_carries_security_headers() {
// /web/cases without cookie → 302 redirect (error path from the
// /cases without cookie → 302 redirect (error path from the
// session extractor).
let resp = get(test_app(), "/web/cases").await;
let resp = get(test_app(), "/cases").await;
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(header_opt(&resp, "x-content-type-options"), Some("nosniff"));
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
@@ -162,7 +162,7 @@ async fn error_redirect_still_carries_security_headers() {
/// yet) and must keep passing after the layer lands.
#[tokio::test]
async fn magic_route_referrer_policy_not_duplicated() {
let resp = get(test_app(), "/web/magic?token=definitely-invalid").await;
let resp = get(test_app(), "/magic?token=definitely-invalid").await;
assert_eq!(
count_header_values(&resp, "referrer-policy"),
1,
+3 -3
View File
@@ -1,6 +1,6 @@
//! Regression: SSE connection-pool leak via missing `EventSource.close()`.
//!
//! Every HTML template that opens `new EventSource('/web/events')` must
//! Every HTML template that opens `new EventSource('/events')` must
//! also close it on `pagehide`. Without the close, Chrome keeps the
//! socket in a "draining" state after navigation; after ~6 rapid
//! back-and-forth navigations all 6 per-origin pool slots are draining
@@ -24,7 +24,7 @@ fn every_event_source_template_closes_it_on_pagehide() {
continue;
}
let content = fs::read_to_string(&path).unwrap();
if !content.contains("new EventSource('/web/events')") {
if !content.contains("new EventSource('/events')") {
continue;
}
checked += 1;
@@ -37,7 +37,7 @@ fn every_event_source_template_closes_it_on_pagehide() {
assert!(
checked >= 1,
"test itself is stale: no template contains EventSource('/web/events')",
"test itself is stale: no template contains EventSource('/events')",
);
assert!(
missing.is_empty(),
+1 -1
View File
@@ -1,4 +1,4 @@
//! Integration tests for the `/web/events` SSE route.
//! Integration tests for the `/events` SSE route.
//!
//! We test two things end-to-end:
//! 1. Without a session cookie, the route redirects to the login page
+1 -1
View File
@@ -168,7 +168,7 @@ async fn upload_reopens_closed_case() {
// Close the case by writing the marker directly — simulates the
// user hitting the close button in the web UI without having to
// drive the /web/cases/{id}/delete handler here. Direct write also
// drive the /cases/{id}/delete handler here. Direct write also
// means the watermark is NOT bumped here; that way the next GET
// /api/oneliners pins down the pre-reopen ETag cleanly.
let case_dir = data_path.join("dr_test").join(case_id);
+4 -4
View File
@@ -118,7 +118,7 @@ async fn login_with_uppercase_slug_returns_login_failed_page() {
}
/// A malformed magic-link token must not 400 (would leak shape) — it
/// should redirect to /web/login, identical to the unknown-token path.
/// should redirect to /login, identical to the unknown-token path.
#[tokio::test]
async fn magic_consume_with_malformed_token_redirects_to_login() {
let cfg = TestConfig::new()
@@ -131,7 +131,7 @@ async fn magic_consume_with_malformed_token_redirects_to_login() {
let resp = app
.oneshot(
Request::builder()
.uri("/web/magic?token=abc")
.uri("/magic?token=abc")
.body(Body::empty())
.unwrap(),
)
@@ -144,7 +144,7 @@ async fn magic_consume_with_malformed_token_redirects_to_login() {
.expect("redirect must set Location")
.to_str()
.unwrap();
assert_eq!(loc, "/web/login");
assert_eq!(loc, "/login");
}
/// And the same for tokens that pass the length check but contain
@@ -158,7 +158,7 @@ async fn magic_consume_with_non_urlsafe_token_redirects_to_login() {
let app = doctate_server::create_router(cfg);
let bad = "%2E%2E%2Fetc%2Fpasswd%2E%2E%2Fetc"; // url-encoded ./../etc/passwd…
let uri = format!("/web/magic?token={bad}");
let uri = format!("/magic?token={bad}");
let resp = app
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
+7 -7
View File
@@ -29,7 +29,7 @@ async fn web_audio_serves_file() {
let response = app
.oneshot(
Request::builder()
.uri(format!("/web/audio/dr_test/{case_id}/{filename}"))
.uri(format!("/audio/dr_test/{case_id}/{filename}"))
.body(Body::empty())
.unwrap(),
)
@@ -50,7 +50,7 @@ async fn web_audio_path_traversal_rejected() {
let response = app
.oneshot(
Request::builder()
.uri("/web/audio/..%2Fetc/550e8400-e29b-41d4-a716-446655440000/foo.m4a")
.uri("/audio/..%2Fetc/550e8400-e29b-41d4-a716-446655440000/foo.m4a")
.body(Body::empty())
.unwrap(),
)
@@ -67,7 +67,7 @@ async fn web_audio_invalid_case_id_rejected() {
let response = app
.oneshot(
Request::builder()
.uri("/web/audio/dr_test/not-a-uuid/foo.m4a")
.uri("/audio/dr_test/not-a-uuid/foo.m4a")
.body(Body::empty())
.unwrap(),
)
@@ -93,7 +93,7 @@ async fn web_audio_serves_range_as_206() {
let response = app
.oneshot(
Request::builder()
.uri(format!("/web/audio/dr_test/{case_id}/{filename}"))
.uri(format!("/audio/dr_test/{case_id}/{filename}"))
.header("Range", "bytes=10-19")
.body(Body::empty())
.unwrap(),
@@ -129,7 +129,7 @@ async fn web_audio_invalid_range_returns_416() {
let response = app
.oneshot(
Request::builder()
.uri(format!("/web/audio/dr_test/{case_id}/{filename}"))
.uri(format!("/audio/dr_test/{case_id}/{filename}"))
.header("Range", "bytes=1000-2000")
.body(Body::empty())
.unwrap(),
@@ -160,7 +160,7 @@ async fn web_audio_no_range_header_advertises_accept_ranges() {
let response = app
.oneshot(
Request::builder()
.uri(format!("/web/audio/dr_test/{case_id}/{filename}"))
.uri(format!("/audio/dr_test/{case_id}/{filename}"))
.body(Body::empty())
.unwrap(),
)
@@ -182,7 +182,7 @@ async fn web_audio_nonexistent_file_returns_404() {
let response = app
.oneshot(
Request::builder()
.uri("/web/audio/dr_test/550e8400-e29b-41d4-a716-446655440000/missing.m4a")
.uri("/audio/dr_test/550e8400-e29b-41d4-a716-446655440000/missing.m4a")
.body(Body::empty())
.unwrap(),
)