23ef84d9d8
Replace the single [llm] block in settings.toml with a curated catalog of LLM "backends" (provider + model + sampling params + system prompt) in server/src/analyze/backend.rs. Two backends ship initially: gpt_oss_120b (default, reasoning_effort=medium) and llama_3_1_405b (uses response_format: json_schema to sidestep the <|eot_id|> leak). The case page now renders one submit button per available backend; the clicked button's name=backend value rides through AnalysisInput.backend_id to the worker, which looks it up via find_backend() with default fallback. A submitted unknown backend id is rejected as 400. .analysis_failed.json records the failed backend and the failure banner surfaces its label. The API key now comes from IONOS_API_KEY (env), not settings.toml; the [llm] section is read into a discarded field so old configs still load. Backends without a satisfied requires_api_key are filtered from the UI (any_backend_available()). Three end-to-end tests that mocked the LLM endpoint via settings.llm.url are #[ignore]'d with a clear note — they need per-test backend injection (Arc<Vec<LlmBackend>> through the worker) before they can be re-enabled.
794 lines
28 KiB
Rust
794 lines
28 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::SystemTime;
|
|
|
|
use axum::extract::State;
|
|
use axum::http::HeaderMap;
|
|
use axum::response::Redirect;
|
|
use serde::Deserialize;
|
|
use time::OffsetDateTime;
|
|
use time::format_description::well_known::Rfc3339;
|
|
use tokio::io::AsyncWriteExt;
|
|
use tracing::{info, warn};
|
|
|
|
use doctate_common::timestamp::{filename_stem_to_recorded_at, now_rfc3339};
|
|
use doctate_common::{RECORDING_META_SUFFIX, TranscriptState};
|
|
|
|
use crate::analyze::backend::{any_backend_available, find_backend};
|
|
use crate::analyze::{
|
|
ANALYSIS_INPUT_FILE, AnalysisInput, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE, RecordingInput,
|
|
};
|
|
use crate::auth::AuthenticatedWebUser;
|
|
use crate::case_id::CaseIdPath;
|
|
use crate::config::Config;
|
|
use crate::csrf::{CsrfForm, CsrfOnlyForm, HasCsrfToken};
|
|
use crate::error::AppError;
|
|
use crate::events::{self, CaseEventKind, EventSender};
|
|
use crate::oneliner_locks::OnelinerLocks;
|
|
use crate::paths::{
|
|
CLOSE_MARKER, CloseMarker, delete_oneliner_unless_manual, read_close_marker,
|
|
read_transcript_state, write_close_marker,
|
|
};
|
|
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
|
|
/// by the clicked submit button (`<button name="backend" value="…">`); an
|
|
/// empty value falls back to the default backend.
|
|
#[derive(Deserialize)]
|
|
pub struct AnalyzeForm {
|
|
#[serde(default)]
|
|
pub csrf_token: String,
|
|
#[serde(default)]
|
|
pub backend: String,
|
|
#[serde(default)]
|
|
pub return_to: Option<String>,
|
|
}
|
|
|
|
impl HasCsrfToken for AnalyzeForm {
|
|
fn csrf_token(&self) -> &str {
|
|
&self.csrf_token
|
|
}
|
|
}
|
|
|
|
/// POST /web/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`
|
|
/// where N is the current latest document version. The worker picks up the
|
|
/// new input and produces `document_v{version}.md`. Available to all logged-in
|
|
/// users (no admin check). Race protection via `create_new` on the input file.
|
|
pub async fn handle_analyze_case(
|
|
user: AuthenticatedWebUser,
|
|
State(config): State<Arc<Config>>,
|
|
State(analyze_tx): State<AnalyzeSender>,
|
|
State(events_tx): State<EventSender>,
|
|
CaseIdPath(case_id): CaseIdPath,
|
|
CsrfForm(form): CsrfForm<AnalyzeForm>,
|
|
) -> Result<Redirect, AppError> {
|
|
if !any_backend_available() {
|
|
return Err(AppError::ServiceUnavailable(
|
|
"LLM-Analyse nicht konfiguriert".into(),
|
|
));
|
|
}
|
|
|
|
// Resolve the requested backend. Empty string falls back to the default
|
|
// backend; an unknown id is rejected as a 400 so the user notices the
|
|
// misconfiguration instead of silently getting a different model.
|
|
let backend_id = if form.backend.is_empty() {
|
|
crate::analyze::backend::default_backend().id.clone()
|
|
} else {
|
|
find_backend(&form.backend)
|
|
.ok_or_else(|| AppError::BadRequest(format!("Unbekanntes Backend: {}", form.backend)))?
|
|
.id
|
|
.clone()
|
|
};
|
|
|
|
let user_root = config.data_path.join(&user.slug);
|
|
let case_dir = locate_case_or_404(&user_root, &case_id, &user.slug, "analyze").await?;
|
|
|
|
// Re-analyze: delete the existing document first so the UI stops showing
|
|
// the stale "ausgewertet" state while the worker re-computes. Ignore
|
|
// NotFound (first-time analysis path).
|
|
let document_path = case_dir.join(DOCUMENT_FILE);
|
|
match tokio::fs::remove_file(&document_path).await {
|
|
Ok(_) => {}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(e) => return Err(AppError::Internal(format!("remove old document: {e}"))),
|
|
}
|
|
|
|
let input = build_analysis_input(&case_dir, &backend_id).await?;
|
|
let input_path = case_dir.join(ANALYSIS_INPUT_FILE);
|
|
write_input_create_new(&input_path, &input).await?;
|
|
|
|
// Unbounded send: only fails if the worker has gone away (programmer error
|
|
// or shutdown race). Log but don't fail the request — the file is already
|
|
// on disk and recovery will pick it up on next startup.
|
|
if analyze_tx
|
|
.send(AnalyzeJob {
|
|
case_dir: case_dir.clone(),
|
|
})
|
|
.is_err()
|
|
{
|
|
warn!(case_id = %case_id, "analyze send failed (worker gone)");
|
|
}
|
|
|
|
info!(
|
|
slug = %user.slug,
|
|
case_id = %case_id,
|
|
recordings = input.recordings.len(),
|
|
"analyze requested; analysis enqueued"
|
|
);
|
|
|
|
events::emit(
|
|
&events_tx,
|
|
&user.slug,
|
|
case_id.to_string(),
|
|
CaseEventKind::AnalysisQueued,
|
|
);
|
|
|
|
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.
|
|
///
|
|
/// 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()
|
|
}
|
|
}
|
|
|
|
/// Like `resolve_return_path`, but collapses a `/web/cases/{uuid}` detail
|
|
/// path to `/web/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 {
|
|
return raw;
|
|
};
|
|
let (case_part, query) = match tail.split_once('?') {
|
|
Some((c, q)) => (c, Some(q)),
|
|
None => (tail, None),
|
|
};
|
|
if uuid::Uuid::parse_str(case_part).is_err() {
|
|
return raw;
|
|
}
|
|
match query {
|
|
Some(q) => format!("/web/cases?{q}"),
|
|
None => "/web/cases".into(),
|
|
}
|
|
}
|
|
|
|
/// Build an `AnalysisInput` for the given case directory.
|
|
/// Collects all `.m4a` (excluding `.m4a.failed`), reads the matching
|
|
/// `<stem>.json` for each, skips blank transcripts, and computes
|
|
/// `last_recording_mtime` as the max mtime over ALL `.m4a` (including blank
|
|
/// ones — a late blank addendum still counts as activity).
|
|
///
|
|
/// `backend_id` is stored as-is; an empty string lets the worker fall back
|
|
/// to the default backend at dequeue time.
|
|
pub(crate) async fn build_analysis_input(
|
|
case_dir: &Path,
|
|
backend_id: &str,
|
|
) -> Result<AnalysisInput, AppError> {
|
|
let m4as = collect_m4as(case_dir).await?;
|
|
if m4as.is_empty() {
|
|
return Err(AppError::BadRequest("Keine Aufnahmen im Fall".into()));
|
|
}
|
|
|
|
// last_mtime is derived from ALL m4as (including ones that turn out
|
|
// to have blank transcripts) — a late blank addendum still counts as
|
|
// case activity. Computed up-front so `read_recordings` can focus on
|
|
// I/O + validation.
|
|
let last_mtime = compute_last_mtime(&m4as);
|
|
let recordings = read_recordings(&m4as).await?;
|
|
|
|
// Silent-only case: `recordings` may be empty here. The analyze worker
|
|
// handles that by writing a stub document instead of calling the LLM.
|
|
let last_recording_mtime = OffsetDateTime::from(last_mtime)
|
|
.format(&Rfc3339)
|
|
.map_err(|e| AppError::Internal(format!("format mtime: {e}")))?;
|
|
|
|
Ok(AnalysisInput {
|
|
last_recording_mtime,
|
|
recordings,
|
|
backend_id: backend_id.to_owned(),
|
|
})
|
|
}
|
|
|
|
/// Max modification time across all m4as. Falls back to UNIX_EPOCH for
|
|
/// an empty slice — callers should have already rejected that case.
|
|
fn compute_last_mtime(m4as: &[(PathBuf, SystemTime)]) -> SystemTime {
|
|
m4as.iter()
|
|
.map(|(_, mtime)| *mtime)
|
|
.max()
|
|
.unwrap_or(SystemTime::UNIX_EPOCH)
|
|
}
|
|
|
|
/// Build the analyze-worker input from each m4a's three-way transcript
|
|
/// state. `Pending` (no sidecar yet) is the only fatal case — the
|
|
/// analyze endpoint requires a settled batch — and surfaces as the
|
|
/// legacy 400 "Nicht alle Aufnahmen sind transkribiert" body. `Silent`
|
|
/// recordings are silently skipped: the transcriber deliberately
|
|
/// produced no text, so there's nothing to feed the LLM. Only
|
|
/// `Content` transcripts contribute to the input.
|
|
async fn read_recordings(m4as: &[(PathBuf, SystemTime)]) -> Result<Vec<RecordingInput>, AppError> {
|
|
let mut recordings: Vec<RecordingInput> = Vec::new();
|
|
for (m4a_path, _) in m4as {
|
|
let text = match read_transcript_state(m4a_path).await {
|
|
TranscriptState::Pending => {
|
|
return Err(AppError::BadRequest(
|
|
"Nicht alle Aufnahmen sind transkribiert".into(),
|
|
));
|
|
}
|
|
TranscriptState::Silent => continue,
|
|
TranscriptState::Content { text } => text,
|
|
};
|
|
|
|
let stem = m4a_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
|
recordings.push(RecordingInput {
|
|
recorded_at: filename_stem_to_recorded_at(stem),
|
|
text,
|
|
});
|
|
}
|
|
Ok(recordings)
|
|
}
|
|
|
|
/// Return `(path, mtime)` for every `.m4a` file directly under `case_dir`,
|
|
/// sorted by filename (chronological, since filenames are UTC timestamps).
|
|
/// `.m4a.failed` entries are skipped.
|
|
async fn collect_m4as(case_dir: &Path) -> Result<Vec<(PathBuf, SystemTime)>, AppError> {
|
|
let mut out = Vec::new();
|
|
let mut entries = tokio::fs::read_dir(case_dir).await?;
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let filename = match entry.file_name().into_string() {
|
|
Ok(s) => s,
|
|
Err(_) => continue,
|
|
};
|
|
if !filename.ends_with(".m4a") {
|
|
continue;
|
|
}
|
|
let path = entry.path();
|
|
let mtime = entry.metadata().await?.modified()?;
|
|
out.push((path, mtime));
|
|
}
|
|
out.sort_by(|a, b| a.0.cmp(&b.0));
|
|
Ok(out)
|
|
}
|
|
|
|
/// Serialize `input` and write it to `path` using `create_new` to atomically
|
|
/// reserve the destination. Returns `Conflict` if another close already won
|
|
/// the race.
|
|
///
|
|
/// Trade-off: if the process crashes between `open` and `write_all`/`sync_all`,
|
|
/// a zero-byte or partially-written JSON remains. The worker will fail to
|
|
/// deserialize it and log an error — manual cleanup required. For MVP the
|
|
/// simpler check-and-create is acceptable.
|
|
pub(crate) async fn write_input_create_new(
|
|
path: &Path,
|
|
input: &AnalysisInput,
|
|
) -> Result<(), AppError> {
|
|
let bytes = serde_json::to_vec_pretty(input)
|
|
.map_err(|e| AppError::Internal(format!("serialize input: {e}")))?;
|
|
|
|
let mut f = match tokio::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(path)
|
|
.await
|
|
{
|
|
Ok(f) => f,
|
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
|
return Err(AppError::Conflict(
|
|
"Analyse läuft bereits für diesen Fall".into(),
|
|
));
|
|
}
|
|
Err(e) => return Err(AppError::Internal(e.to_string())),
|
|
};
|
|
f.write_all(&bytes).await?;
|
|
f.sync_all().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Read `case_dir/document.md`. Returns `None` if no document exists.
|
|
pub(crate) async fn read_document(case_dir: &Path) -> Option<String> {
|
|
tokio::fs::read_to_string(case_dir.join(DOCUMENT_FILE))
|
|
.await
|
|
.ok()
|
|
}
|
|
|
|
/// Reset a case to its raw audio: delete derived artefacts
|
|
/// (transcripts, analysis input, document, non-Manual oneliner) and rename every
|
|
/// `*.m4a.failed` back to `*.m4a` so the transcribe self-heal picks it
|
|
/// up again. Idempotent: missing files are no-ops.
|
|
///
|
|
/// `oneliner.json` is special: a [`OnelinerState::Manual`] override is
|
|
/// preserved (per the sticky-Manual rule). Routed through
|
|
/// [`delete_oneliner_unless_manual`] so the per-case lock serializes
|
|
/// the wipe with concurrent worker writes.
|
|
pub(crate) async fn reset_case_artefacts(
|
|
case_dir: &Path,
|
|
locks: &OnelinerLocks,
|
|
) -> std::io::Result<()> {
|
|
for name in [DOCUMENT_FILE, ANALYSIS_INPUT_FILE] {
|
|
match tokio::fs::remove_file(case_dir.join(name)).await {
|
|
Ok(_) => {}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(e) => return Err(e),
|
|
}
|
|
}
|
|
delete_oneliner_unless_manual(case_dir, locks).await?;
|
|
let mut entries = tokio::fs::read_dir(case_dir).await?;
|
|
let meta_suffix = format!(".{RECORDING_META_SUFFIX}");
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let name = entry.file_name();
|
|
let Some(s) = name.to_str() else { continue };
|
|
// Per-recording metadata sidecar: paired with `<stem>.m4a` or
|
|
// its `.failed` twin. The pair check guards case-level JSONs
|
|
// (preserved Manual oneliner, future siblings) from being
|
|
// misidentified as recording artefacts.
|
|
if let Some(stem) = s.strip_suffix(&meta_suffix) {
|
|
let paired = tokio::fs::try_exists(case_dir.join(format!("{stem}.m4a")))
|
|
.await
|
|
.unwrap_or(false)
|
|
|| tokio::fs::try_exists(case_dir.join(format!("{stem}.m4a.failed")))
|
|
.await
|
|
.unwrap_or(false);
|
|
if paired {
|
|
tokio::fs::remove_file(entry.path()).await?;
|
|
}
|
|
continue;
|
|
}
|
|
if s.ends_with(".m4a.failed") {
|
|
let new_name = &s[..s.len() - ".failed".len()];
|
|
tokio::fs::rename(entry.path(), case_dir.join(new_name)).await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// POST /web/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).
|
|
/// Reversible via `handle_reopen_case` (explicit button) or by uploading
|
|
/// a new recording for the same case (automatic via `handle_upload`).
|
|
///
|
|
/// Redirects back to the `Referer`-derived list path (see
|
|
/// `resolve_list_return_path`) so a close from the `?show_closed=1`
|
|
/// view keeps the user there — but drops the case-id segment if the
|
|
/// referer was the case detail, since that page 404s after the close.
|
|
pub async fn handle_close_case(
|
|
user: AuthenticatedWebUser,
|
|
State(config): State<Arc<Config>>,
|
|
State(events_tx): State<EventSender>,
|
|
CaseIdPath(case_id): CaseIdPath,
|
|
headers: HeaderMap,
|
|
_csrf: CsrfForm<CsrfOnlyForm>,
|
|
) -> Result<Redirect, AppError> {
|
|
let user_root = config.data_path.join(&user.slug);
|
|
let case_dir = locate_case_or_404(&user_root, &case_id, &user.slug, "close").await?;
|
|
|
|
let marker = CloseMarker {
|
|
closed_at: now_rfc3339(),
|
|
};
|
|
write_close_marker(&case_dir, &marker).await?;
|
|
|
|
info!(
|
|
slug = %user.slug,
|
|
case_id = %case_id,
|
|
closed_at = %marker.closed_at,
|
|
"case closed (manual)"
|
|
);
|
|
|
|
events::emit(
|
|
&events_tx,
|
|
&user.slug,
|
|
case_id.to_string(),
|
|
CaseEventKind::CaseClosed,
|
|
);
|
|
|
|
Ok(Redirect::to(&resolve_list_return_path(&headers)))
|
|
}
|
|
|
|
/// POST /web/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
|
|
/// indistinguishable from one that was never closed — no status
|
|
/// remainders on disk. Idempotent: an already-open case is a no-op.
|
|
///
|
|
/// Redirects back to the `Referer`-path (filtered via
|
|
/// `resolve_return_path`) so a reopen from the `?show_closed=1` view
|
|
/// stays in that view instead of jumping back to the default listing.
|
|
pub async fn handle_reopen_case(
|
|
user: AuthenticatedWebUser,
|
|
State(config): State<Arc<Config>>,
|
|
State(events_tx): State<EventSender>,
|
|
CaseIdPath(case_id): CaseIdPath,
|
|
headers: HeaderMap,
|
|
_csrf: CsrfForm<CsrfOnlyForm>,
|
|
) -> Result<Redirect, AppError> {
|
|
let user_root = config.data_path.join(&user.slug);
|
|
let case_dir = user_root.join(case_id.to_string());
|
|
let return_path = resolve_return_path(&headers);
|
|
|
|
if !crate::paths::is_closed(&case_dir).await {
|
|
info!(
|
|
slug = %user.slug,
|
|
case_id = %case_id,
|
|
"reopen requested but case is not closed"
|
|
);
|
|
return Ok(Redirect::to(&return_path));
|
|
}
|
|
|
|
match tokio::fs::remove_file(case_dir.join(CLOSE_MARKER)).await {
|
|
Ok(_) => {}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(e) => return Err(AppError::Internal(format!("remove close marker: {e}"))),
|
|
}
|
|
|
|
info!(
|
|
slug = %user.slug,
|
|
case_id = %case_id,
|
|
"case reopened (manual)"
|
|
);
|
|
|
|
events::emit(
|
|
&events_tx,
|
|
&user.slug,
|
|
case_id.to_string(),
|
|
CaseEventKind::CaseReopened,
|
|
);
|
|
|
|
Ok(Redirect::to(&return_path))
|
|
}
|
|
|
|
/// POST /web/cases/{case_id}/reset
|
|
///
|
|
/// Admin-only. Wipes all derived artefacts (transcripts, oneliner,
|
|
/// analysis input, document) and un-fails audio (`.m4a.failed` → `.m4a`)
|
|
/// so the pipeline starts over on the raw recordings. Best-effort: a
|
|
/// currently-running worker may race and write a fresh artefact back;
|
|
/// admin can click Reset again.
|
|
pub async fn handle_reset_case(
|
|
user: AuthenticatedWebUser,
|
|
State(config): State<Arc<Config>>,
|
|
State(events_tx): State<EventSender>,
|
|
State(locks): State<OnelinerLocks>,
|
|
CaseIdPath(case_id): CaseIdPath,
|
|
CsrfForm(form): CsrfForm<CsrfOnlyForm>,
|
|
) -> Result<Redirect, AppError> {
|
|
if !user.is_admin() {
|
|
return Err(AppError::Forbidden("Nur für Admins".into()));
|
|
}
|
|
|
|
let user_root = config.data_path.join(&user.slug);
|
|
let case_dir = locate_case_or_404(&user_root, &case_id, &user.slug, "reset").await?;
|
|
|
|
reset_case_artefacts(&case_dir, &locks)
|
|
.await
|
|
.map_err(|e| AppError::Internal(format!("reset: {e}")))?;
|
|
|
|
info!(slug = %user.slug, case_id = %case_id, "case reset (admin)");
|
|
events::emit(
|
|
&events_tx,
|
|
&user.slug,
|
|
case_id.to_string(),
|
|
CaseEventKind::CaseReset,
|
|
);
|
|
Ok(Redirect::to(&validated_return_path(
|
|
form.return_to,
|
|
format!("/web/cases/{case_id}"),
|
|
)))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct PurgeClosedForm {
|
|
// Optional so a missing field yields a clean 400 from our handler
|
|
// rather than a 422 from axum's form extractor.
|
|
#[serde(default)]
|
|
pub confirm: String,
|
|
#[serde(default)]
|
|
pub csrf_token: String,
|
|
}
|
|
|
|
impl HasCsrfToken for PurgeClosedForm {
|
|
fn csrf_token(&self) -> &str {
|
|
&self.csrf_token
|
|
}
|
|
}
|
|
|
|
/// POST /web/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
|
|
/// guard against accidental browser-history re-POSTs — the UI additionally
|
|
/// shows a JS confirm() dialog, but that can be defeated by re-submitting
|
|
/// the form from history, so the server double-checks.
|
|
///
|
|
/// Per-case failures are logged but do not abort the sweep. Emits
|
|
/// `CasePurged` per successfully removed case.
|
|
pub async fn handle_purge_closed(
|
|
user: AuthenticatedWebUser,
|
|
State(config): State<Arc<Config>>,
|
|
State(events_tx): State<EventSender>,
|
|
CsrfForm(form): CsrfForm<PurgeClosedForm>,
|
|
) -> Result<Redirect, AppError> {
|
|
// Destructive, irreversible — admin-only. UI hides the button for
|
|
// non-admins; this is the server-side defense-in-depth gate.
|
|
if !user.is_admin() {
|
|
warn!(slug = %user.slug, "purge-closed: not admin");
|
|
return Err(AppError::Forbidden("Nur für Admins".into()));
|
|
}
|
|
if form.confirm != "yes" {
|
|
warn!(slug = %user.slug, "purge-closed: missing confirm=yes");
|
|
return Err(AppError::BadRequest("Missing confirm=yes".into()));
|
|
}
|
|
|
|
let user_root = config.data_path.join(&user.slug);
|
|
let mut ok = 0usize;
|
|
let mut skipped = 0usize;
|
|
let mut entries = match tokio::fs::read_dir(&user_root).await {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
warn!(slug = %user.slug, error = %e, "purge-closed: read_dir failed");
|
|
return Ok(Redirect::to("/web/cases"));
|
|
}
|
|
};
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
let case_path = entry.path();
|
|
if !case_path.is_dir() {
|
|
continue;
|
|
}
|
|
if !crate::paths::is_closed(&case_path).await {
|
|
continue;
|
|
}
|
|
// Read the marker up-front so the log line has context even
|
|
// if remove_dir_all later fails partway through.
|
|
let closed_at = read_close_marker(&case_path)
|
|
.await
|
|
.map(|m| m.closed_at)
|
|
.unwrap_or_else(|| "<malformed>".to_string());
|
|
let case_id = crate::events::case_id_of(&case_path);
|
|
if let Err(e) = tokio::fs::remove_dir_all(&case_path).await {
|
|
warn!(slug = %user.slug, case_id = %case_id, error = %e, "purge-closed: remove_dir_all failed");
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
info!(
|
|
slug = %user.slug,
|
|
case_id = %case_id,
|
|
closed_at = %closed_at,
|
|
"case purged (manual bulk)"
|
|
);
|
|
events::emit(&events_tx, &user.slug, case_id, CaseEventKind::CasePurged);
|
|
ok += 1;
|
|
}
|
|
info!(slug = %user.slug, ok, skipped, "purge-closed completed");
|
|
Ok(Redirect::to("/web/cases"))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct DeleteRecordingForm {
|
|
pub filename: String,
|
|
#[serde(default)]
|
|
pub csrf_token: String,
|
|
}
|
|
|
|
impl HasCsrfToken for DeleteRecordingForm {
|
|
fn csrf_token(&self) -> &str {
|
|
&self.csrf_token
|
|
}
|
|
}
|
|
|
|
/// POST /web/cases/{case_id}/recordings/delete
|
|
///
|
|
/// Hard-delete a single recording plus its transcript / duration sidecars
|
|
/// and invalidate derived artefacts (`oneliner.json`, `document.md`,
|
|
/// `analysis_input.json`). Regeneration is left to the existing
|
|
/// auto-trigger paths that run on every view request: the analyze
|
|
/// auto-trigger (`analyze::auto_trigger::try_enqueue`) and the oneliner
|
|
/// self-heal folded into `PipelineState::heal_orphans_if_idle` will pick
|
|
/// the case back up on the next page load or SSE-driven reload.
|
|
///
|
|
/// Security: `validate_filename` rejects path traversal and non-audio
|
|
/// extensions; `locate_case_or_404` enforces case ownership (IDOR) and
|
|
/// hides soft-deleted cases.
|
|
pub async fn handle_delete_recording(
|
|
user: AuthenticatedWebUser,
|
|
State(config): State<Arc<Config>>,
|
|
State(events_tx): State<EventSender>,
|
|
State(locks): State<OnelinerLocks>,
|
|
CaseIdPath(case_id): CaseIdPath,
|
|
CsrfForm(form): CsrfForm<DeleteRecordingForm>,
|
|
) -> Result<Redirect, AppError> {
|
|
validate_filename(&form.filename)?;
|
|
|
|
let user_root = config.data_path.join(&user.slug);
|
|
let case_dir = locate_case_or_404(&user_root, &case_id, &user.slug, "delete recording").await?;
|
|
|
|
let stem = form
|
|
.filename
|
|
.trim_end_matches(".failed")
|
|
.trim_end_matches(".m4a");
|
|
|
|
// Audio and its single metadata sidecar. NotFound on any of these
|
|
// is fine: the JSON sidecar may never have been written (the
|
|
// worker hadn't reached the atomic write yet, or the file got
|
|
// deleted twice).
|
|
let targets: [PathBuf; 4] = [
|
|
case_dir.join(&form.filename),
|
|
case_dir.join(format!("{stem}.{RECORDING_META_SUFFIX}")),
|
|
case_dir.join(DOCUMENT_FILE),
|
|
case_dir.join(ANALYSIS_INPUT_FILE),
|
|
];
|
|
for p in &targets {
|
|
remove_if_exists(p).await?;
|
|
}
|
|
// Oneliner is the only artefact carrying a possibly-manual override —
|
|
// route through the wrapper so a clinician's edit survives the wipe.
|
|
delete_oneliner_unless_manual(&case_dir, &locks)
|
|
.await
|
|
.map_err(|e| AppError::Internal(format!("oneliner: {e}")))?;
|
|
|
|
info!(
|
|
slug = %user.slug,
|
|
case_id = %case_id,
|
|
filename = %form.filename,
|
|
"recording deleted"
|
|
);
|
|
|
|
events::emit(
|
|
&events_tx,
|
|
&user.slug,
|
|
case_id.to_string(),
|
|
CaseEventKind::RecordingDeleted,
|
|
);
|
|
|
|
Ok(Redirect::to(&format!("/web/cases/{case_id}/recordings")))
|
|
}
|
|
|
|
async fn remove_if_exists(path: &Path) -> Result<(), AppError> {
|
|
match tokio::fs::remove_file(path).await {
|
|
Ok(_) => Ok(()),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(e) => Err(AppError::Internal(format!(
|
|
"remove {}: {e}",
|
|
path.display()
|
|
))),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{resolve_list_return_path, 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");
|
|
}
|
|
|
|
// `resolve_list_return_path` is `resolve_return_path` plus a case-
|
|
// detail collapse. It leaves non-detail paths alone and strips the
|
|
// `/{uuid}` segment from a detail path while preserving the query.
|
|
|
|
#[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");
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|