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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -330,13 +330,13 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div><a class="back" href="/web/cases">← Fälle</a></div>
|
||||
<div><a class="back" href="/cases">← 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"
|
||||
>→ 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.
|
||||
|
||||
@@ -123,17 +123,17 @@ try {
|
||||
<body>
|
||||
<header>
|
||||
<div class="breadcrumb">
|
||||
<a href="/web/cases">← Fälle</a>
|
||||
<a href="/cases">← Fälle</a>
|
||||
{% match oneliner %}
|
||||
{% when Some with (t) %}
|
||||
<a href="/web/cases/{{ case_id }}">← {{ t }}</a>
|
||||
<a href="/cases/{{ case_id }}">← {{ t }}</a>
|
||||
{% when None %}
|
||||
<a href="/web/cases/{{ case_id }}">← Fall</a>
|
||||
<a href="/cases/{{ case_id }}">← 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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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="));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user