From 1daf8db470ba9fa794659b0195a69c77393c17e8 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 20 Apr 2026 10:39:21 +0200 Subject: [PATCH] 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. --- server/src/routes/case_actions.rs | 95 ++++++++++++++++++++++++++++++- server/templates/case_page.html | 23 +++++--- server/templates/my_cases.html | 8 +-- 3 files changed, 114 insertions(+), 12 deletions(-) diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index 3f8872b..77fa4e4 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -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, State(events_tx): State, CaseIdPath(case_id): CaseIdPath, + headers: HeaderMap, ) -> Result { 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"); + } +} diff --git a/server/templates/case_page.html b/server/templates/case_page.html index 18c5ab2..52cd42c 100644 --- a/server/templates/case_page.html +++ b/server/templates/case_page.html @@ -9,6 +9,8 @@ header { display: flex; justify-content: space-between; align-items: center; } header form { margin: 0; } .back { color: #4a90e2; text-decoration: none; } h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; } +h1 .title { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; flex: 1; min-width: 0; } +h1 .delete-form { margin: 0 0 0 auto; } .meta { color: #999; font-family: monospace; font-size: 0.8em; margin: 0 0 1em; } .status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.6em; font-weight: bold; color: white; vertical-align: middle; } .status-badge.open { background: #4a90e2; } @@ -18,9 +20,9 @@ h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; } .actions button { font-size: 1em; padding: 0.5em 1em; } .actions .analyzing { color: #888; font-style: italic; } .actions .llm-missing { color: #a66; font-style: italic; } -.actions .delete-form { margin-left: auto; } -.actions .delete-btn { padding: 0.4em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; } -.actions .delete-btn:hover { color: #e24a4a; background: #fff0f0; } +.actions:empty { display: none; } +.delete-btn { padding: 0.4em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; font-size: 1em; } +.delete-btn:hover { color: #e24a4a; background: #fff0f0; } .placeholder { color: #888; font-style: italic; margin: 1em 0; } .status-panel { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; } .recordings-link { display: inline-block; margin: 1em 0; color: #4a90e2; text-decoration: none; font-size: 0.95em; } @@ -30,7 +32,9 @@ h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; } .doc-content p:first-child { margin-top: 0; } .doc-content p:last-child { margin-bottom: 0; } .doc-content mark { background: #fff3a8; padding: 0 0.2em; border-radius: 2px; } -.copy-btn { position: absolute; bottom: 0.5em; right: 0.5em; padding: 0.35em; background: transparent; border: none; border-radius: 4px; cursor: pointer; color: #888; display: inline-flex; align-items: center; justify-content: center; line-height: 0; opacity: 0.45; transition: opacity 0.15s, color 0.15s; } +.doc-actions { position: absolute; bottom: 0.5em; right: 0.5em; display: flex; gap: 0.2em; } +.doc-actions form { margin: 0; } +.copy-btn { padding: 0.35em; background: transparent; border: none; border-radius: 4px; cursor: pointer; color: #888; display: inline-flex; align-items: center; justify-content: center; line-height: 0; opacity: 0.45; transition: opacity 0.15s, color 0.15s; } .copy-btn:hover { color: #4a90e2; opacity: 1; } .copy-btn .icon-check { display: none; } .copy-btn.copied { color: #2d8c2d; opacity: 1; } @@ -58,35 +62,40 @@ try {

+ {% match oneliner %} {% when Some with (t) %}{{ t }} {% when None %}Fall {% endmatch %} {% if has_document %}ausgewertet{% else %}offen{% endif %} + +

{% if is_admin %}
{{ case_id }}
{% endif %}
{% if analyzing %} wird analysiert … -{% else if can_analyze %} -
+{% else if can_analyze && !has_document %} +
{% else if llm_missing && !has_document %} LLM-Analyse nicht konfiguriert {% endif %} {% if is_admin %}
{% endif %} -
{% match document_html %} {% when Some with (html) %}
+
+{% if can_analyze %}
{% endif %} +
{{ html|safe }}