fix: Inline recording-delete confirm + stable redirects
- Replace native confirm() in the recording list with an inline
two-step confirm row; hide the player via visibility:hidden
while open so the row height stays stable.
- Hard-redirect recording delete back to /web/cases/{id}/recordings
instead of relying on the Referer header (which silently fell
back to /web/cases when stripped).
- Switch analyze/reset to a hidden return_to form field so the
source page (case detail vs. case list) is preserved reliably,
with same-origin /web/ prefix validation.
- Tighten delete_recording_test on the concrete Location header;
adjust analyze/reset redirect expectations to the new
case-detail fallback.
This commit is contained in:
@@ -101,10 +101,17 @@ where
|
||||
/// state-changing but carry no other user-submitted data
|
||||
/// (close/reopen/reset/analyze/logout). Using one shared type beats
|
||||
/// five wordless copy-paste structs.
|
||||
///
|
||||
/// `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.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CsrfOnlyForm {
|
||||
#[serde(default)]
|
||||
pub csrf_token: String,
|
||||
#[serde(default)]
|
||||
pub return_to: Option<String>,
|
||||
}
|
||||
|
||||
impl HasCsrfToken for CsrfOnlyForm {
|
||||
|
||||
@@ -43,8 +43,7 @@ pub async fn handle_analyze_case(
|
||||
State(analyze_tx): State<AnalyzeSender>,
|
||||
State(events_tx): State<EventSender>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
headers: HeaderMap,
|
||||
_csrf: CsrfForm<CsrfOnlyForm>,
|
||||
CsrfForm(form): CsrfForm<CsrfOnlyForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
if !config.llm_configured() {
|
||||
return Err(AppError::ServiceUnavailable(
|
||||
@@ -95,7 +94,23 @@ pub async fn handle_analyze_case(
|
||||
CaseEventKind::AnalysisQueued,
|
||||
);
|
||||
|
||||
Ok(Redirect::to(&resolve_return_path(&headers)))
|
||||
Ok(Redirect::to(&validated_return_path(
|
||||
form.return_to,
|
||||
format!("/web/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)
|
||||
/// 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/"))
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
/// Resolve the post-action redirect target from the `Referer` header.
|
||||
@@ -417,8 +432,7 @@ pub async fn handle_reset_case(
|
||||
State(config): State<Arc<Config>>,
|
||||
State(events_tx): State<EventSender>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
headers: HeaderMap,
|
||||
_csrf: CsrfForm<CsrfOnlyForm>,
|
||||
CsrfForm(form): CsrfForm<CsrfOnlyForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
if !user.is_admin() {
|
||||
return Err(AppError::Forbidden("Nur für Admins".into()));
|
||||
@@ -438,7 +452,10 @@ pub async fn handle_reset_case(
|
||||
case_id.to_string(),
|
||||
CaseEventKind::CaseReset,
|
||||
);
|
||||
Ok(Redirect::to(&resolve_return_path(&headers)))
|
||||
Ok(Redirect::to(&validated_return_path(
|
||||
form.return_to,
|
||||
format!("/web/cases/{case_id}"),
|
||||
)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -558,7 +575,6 @@ pub async fn handle_delete_recording(
|
||||
State(config): State<Arc<Config>>,
|
||||
State(events_tx): State<EventSender>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
headers: HeaderMap,
|
||||
CsrfForm(form): CsrfForm<DeleteRecordingForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
validate_filename(&form.filename)?;
|
||||
@@ -600,7 +616,7 @@ pub async fn handle_delete_recording(
|
||||
CaseEventKind::RecordingDeleted,
|
||||
);
|
||||
|
||||
Ok(Redirect::to(&resolve_return_path(&headers)))
|
||||
Ok(Redirect::to(&format!("/web/cases/{case_id}/recordings")))
|
||||
}
|
||||
|
||||
async fn remove_if_exists(path: &Path) -> Result<(), AppError> {
|
||||
|
||||
Reference in New Issue
Block a user