Refactor case action redirects and UI

Introduces a `resolve_return_path` function to determine the redirect
target after a case action. This function prioritizes the `Referer`
header for a more contextual redirect, falling back to the default case
list page if the header is absent or invalid.

Additionally, this commit refactors the UI elements for actions on the
case detail and case list pages, improving organization and clarity.
This commit is contained in:
2026-04-20 10:39:21 +02:00
parent 2941bb370c
commit 1daf8db470
3 changed files with 114 additions and 12 deletions
+94 -1
View File
@@ -3,6 +3,7 @@ use std::sync::Arc;
use std::time::SystemTime;
use axum::extract::State;
use axum::http::HeaderMap;
use axum::response::Redirect;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
@@ -35,6 +36,7 @@ pub async fn handle_analyze_case(
State(analyze_tx): State<AnalyzeSender>,
State(events_tx): State<EventSender>,
CaseIdPath(case_id): CaseIdPath,
headers: HeaderMap,
) -> Result<Redirect, AppError> {
if !config.llm_configured() {
return Err(AppError::ServiceUnavailable(
@@ -85,7 +87,36 @@ pub async fn handle_analyze_case(
CaseEventKind::AnalysisQueued,
);
Ok(Redirect::to("/web/cases"))
Ok(Redirect::to(&resolve_return_path(&headers)))
}
/// 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
/// same-origin — a hostile `Referer` cannot cause an open redirect.
fn resolve_return_path(headers: &HeaderMap) -> String {
const FALLBACK: &str = "/web/cases";
let Some(referer) = headers
.get(axum::http::header::REFERER)
.and_then(|v| v.to_str().ok())
else {
return FALLBACK.into();
};
// Strip `scheme://host` prefix if present; keep everything from the first
// `/` onward. A relative Referer (rare but valid) passes through unchanged.
let path_and_query = match referer.split_once("://") {
Some((_, rest)) => match rest.find('/') {
Some(idx) => &rest[idx..],
None => return FALLBACK.into(),
},
None => referer,
};
if path_and_query.starts_with("/web/") {
path_and_query.to_string()
} else {
FALLBACK.into()
}
}
/// Build an `AnalysisInput` for the given case directory.
@@ -426,3 +457,65 @@ async fn restore_batch(
}
count
}
#[cfg(test)]
mod tests {
use super::resolve_return_path;
use axum::http::{HeaderMap, HeaderValue, header::REFERER};
fn headers_with_referer(value: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(REFERER, HeaderValue::from_str(value).unwrap());
h
}
#[test]
fn no_referer_falls_back_to_case_list() {
assert_eq!(resolve_return_path(&HeaderMap::new()), "/web/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");
}
#[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");
}
#[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");
}
#[test]
fn non_web_path_falls_back() {
let h = headers_with_referer("https://doctate.internal/admin/secret");
assert_eq!(resolve_return_path(&h), "/web/cases");
}
#[test]
fn relative_referer_passes_through() {
let h = headers_with_referer("/web/cases/XYZ");
assert_eq!(resolve_return_path(&h), "/web/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");
}
#[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");
}
}