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");
}
}
+16 -7
View File
@@ -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 {
</div>
</header>
<h1>
<span class="title">
{% match oneliner %}
{% when Some with (t) %}{{ t }}
{% when None %}Fall
{% endmatch %}
{% if has_document %}<span class="status-badge done">ausgewertet</span>{% else %}<span class="status-badge open">offen</span>{% endif %}
</span>
<form class="delete-form" method="post" action="/web/cases/{{ case_id }}/delete"><button type="submit" class="delete-btn" title="Fall entfernen" aria-label="Fall entfernen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" 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></form>
</h1>
{% if is_admin %}<div class="meta admin-only">{{ case_id }}</div>{% endif %}
<div class="actions">
{% if analyzing %}
<span class="analyzing">wird analysiert …</span>
{% else if can_analyze %}
<form method="post" action="/web/cases/{{ case_id }}/analyze"><button type="submit">{% if has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button></form>
{% else if can_analyze && !has_document %}
<form method="post" action="/web/cases/{{ case_id }}/analyze"><button type="submit">Analysieren</button></form>
{% else if llm_missing && !has_document %}
<span class="llm-missing">LLM-Analyse nicht konfiguriert</span>
{% endif %}
{% if is_admin %}
<form class="admin-only" method="post" action="/web/cases/{{ case_id }}/reset"><button type="submit">Reset</button></form>
{% endif %}
<form class="delete-form" method="post" action="/web/cases/{{ case_id }}/delete"><button type="submit" class="delete-btn" title="Fall entfernen" aria-label="Fall entfernen"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" 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></form>
</div>
{% match document_html %}
{% when Some with (html) %}
<div id="doc-content" class="doc-content">
<div class="doc-actions">
{% if can_analyze %}<form method="post" action="/web/cases/{{ case_id }}/analyze"><button type="submit" class="copy-btn" title="Neu analysieren" aria-label="Neu analysieren"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"></path><path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"></path></svg></button></form>{% endif %}
<button id="copy-btn" type="button" class="copy-btn" title="In Zwischenablage kopieren" hidden>
<svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
<svg class="icon-check" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</button>
</div>
<div id="doc-body">{{ html|safe }}</div>
</div>
<script>
+4 -4
View File
@@ -90,8 +90,8 @@ try {
</a>
</div>
<div class="actions">
<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze"{% if !case.can_analyze %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
{% if is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/reset" class="admin-only">Reset</button>{% endif %}
{% if is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze" class="admin-only"{% if !case.can_analyze %} 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">Reset</button>{% endif %}
<button type="submit" formaction="/web/cases/{{ case.case_id }}/delete" class="delete-btn" title="Fall entfernen" aria-label="Fall 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>
</div>
</li>
@@ -102,8 +102,8 @@ try {
<div class="bulk-bar">
<span>Auswahl:</span>
<button type="button" onclick="const b=document.querySelectorAll('input[name=case_id]');const all=Array.from(b).every(c=>c.checked);b.forEach(c=>c.checked=!all);this.textContent=all?'Alles auswählen':'Keine auswählen'">Alles auswählen</button>
<button type="submit" name="action" value="analyze">Analysieren</button>
{% if is_admin %}<button type="submit" name="action" value="reset" class="admin-only">Reset</button>{% endif %}
{% if is_admin %}<button type="submit" name="action" value="analyze" class="admin-only">Analysieren</button>
<button type="submit" name="action" value="reset" class="admin-only">Reset</button>{% endif %}
<button type="submit" name="action" value="delete">Entfernen</button>
</div>
</form>