feat: Add endpoint to delete recordings
This commit introduces a new API endpoint for deleting recordings. The endpoint handles the removal of the audio file and its associated sidecar files (transcript, duration). It also invalidates derived artifacts like oneliner.json, document.md, and analysis_input.json. Additionally, it includes checks for path traversal, correct file extensions, and case ownership to ensure security and data integrity. A new `RecordingDeleted` event is also added to the `CaseEventKind` enum.
This commit is contained in:
@@ -24,6 +24,7 @@ pub const EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CaseEventKind {
|
||||
RecordingUploaded,
|
||||
RecordingDeleted,
|
||||
TranscriptReady,
|
||||
TranscriptFailed,
|
||||
OnelinerUpdated,
|
||||
|
||||
@@ -20,6 +20,7 @@ use axum::extract::FromRef;
|
||||
|
||||
use analyze::AnalyzeSender;
|
||||
use config::Config;
|
||||
use gazetteer::Gazetteer;
|
||||
use magic_link::MagicLinkStore;
|
||||
use transcribe::TranscribeSender;
|
||||
use web_session::SessionStore;
|
||||
@@ -80,6 +81,8 @@ pub struct AppState {
|
||||
pub analyze_busy: AnalyzeBusy,
|
||||
pub transcribe_busy: TranscribeBusy,
|
||||
pub events_tx: events::EventSender,
|
||||
pub http_client: reqwest::Client,
|
||||
pub vocab: Arc<Gazetteer>,
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for Arc<Config> {
|
||||
@@ -141,6 +144,18 @@ impl FromRef<AppState> for events::EventSender {
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for reqwest::Client {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.http_client.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for Arc<Gazetteer> {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.vocab.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Test/simple entrypoint: jobs pushed into either channel are dropped
|
||||
/// because the receivers are not retained. Use [`create_router_with_state`]
|
||||
/// from `main.rs` where real workers own the receivers.
|
||||
@@ -156,6 +171,8 @@ pub fn create_router(config: Arc<Config>) -> Router {
|
||||
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
|
||||
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
|
||||
events_tx: events::channel(),
|
||||
http_client: reqwest::Client::new(),
|
||||
vocab: Arc::new(Gazetteer::empty()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,8 @@ async fn main() {
|
||||
analyze_busy: doctate_server::AnalyzeBusy(analyze_busy),
|
||||
transcribe_busy: doctate_server::TranscribeBusy(transcribe_busy),
|
||||
events_tx,
|
||||
http_client,
|
||||
vocab,
|
||||
};
|
||||
|
||||
let addr = format!("0.0.0.0:{}", config.server_port);
|
||||
|
||||
@@ -2,9 +2,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::extract::{Form, 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;
|
||||
@@ -23,6 +24,7 @@ use crate::error::AppError;
|
||||
use crate::events::{self, CaseEventKind, EventSender};
|
||||
use crate::paths::{DELETE_MARKER, DeleteMarker, read_delete_marker, write_delete_marker};
|
||||
use crate::routes::user_web::locate_case_or_404;
|
||||
use crate::routes::web::validate_filename;
|
||||
|
||||
/// POST /web/cases/{case_id}/analyze
|
||||
///
|
||||
@@ -460,6 +462,85 @@ async fn restore_batch(
|
||||
count
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteRecordingForm {
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<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 sidecars. NotFound on any of these is fine: the
|
||||
// transcript / duration may never have been written (silence, failed
|
||||
// job, or the file got deleted twice).
|
||||
let targets: [PathBuf; 5] = [
|
||||
case_dir.join(&form.filename),
|
||||
case_dir.join(format!("{stem}.transcript.txt")),
|
||||
case_dir.join(format!("{stem}.duration.txt")),
|
||||
case_dir.join(DOCUMENT_FILE),
|
||||
case_dir.join(ANALYSIS_INPUT_FILE),
|
||||
];
|
||||
for p in &targets {
|
||||
remove_if_exists(p).await?;
|
||||
}
|
||||
remove_if_exists(&case_dir.join(ONELINER_FILENAME)).await?;
|
||||
|
||||
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(&resolve_return_path(&headers)))
|
||||
}
|
||||
|
||||
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_return_path;
|
||||
|
||||
@@ -34,6 +34,10 @@ pub fn api_router() -> Router<AppState> {
|
||||
"/web/cases/{case_id}/recordings",
|
||||
get(user_web::handle_case_recordings),
|
||||
)
|
||||
.route(
|
||||
"/web/cases/{case_id}/recordings/delete",
|
||||
post(case_actions::handle_delete_recording),
|
||||
)
|
||||
.route(
|
||||
"/web/cases/{case_id}/analyze",
|
||||
post(case_actions::handle_analyze_case),
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::case_id::{CaseId, CaseIdPath};
|
||||
use crate::config::Config;
|
||||
use crate::error::AppError;
|
||||
use crate::events::EventSender;
|
||||
use crate::gazetteer::Gazetteer;
|
||||
use crate::paths;
|
||||
use crate::routes::case_actions::read_document;
|
||||
use crate::routes::web::{RecordingView, scan_recordings};
|
||||
@@ -251,14 +252,39 @@ pub(crate) async fn any_document_exists(case_dir: &Path) -> bool {
|
||||
/// Self-heal: if a worker is idle but orphans exist on disk for this user,
|
||||
/// re-enqueue them. Page-load is the trigger; no cron, no periodic task.
|
||||
/// Runs for both pipelines so a page-load on either view cleans both.
|
||||
///
|
||||
/// Also opportunistically regenerates missing oneliners for the user — a
|
||||
/// recording-delete leaves `oneliner.json` gone, and without this heal
|
||||
/// call the stale state would only recover on server restart.
|
||||
impl PipelineState {
|
||||
pub async fn heal_orphans_if_idle(&self, user_root: &Path, slug: &str) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn heal_orphans_if_idle(
|
||||
&self,
|
||||
user_root: &Path,
|
||||
slug: &str,
|
||||
http_client: &reqwest::Client,
|
||||
config: &Config,
|
||||
vocab: &Gazetteer,
|
||||
events_tx: &EventSender,
|
||||
) {
|
||||
if !self.analyze_busy.0.load(Ordering::Acquire) {
|
||||
analyze_recovery::enqueue_pending_for_user(user_root, &self.analyze_tx).await;
|
||||
}
|
||||
if !self.transcribe_busy.0.load(Ordering::Acquire) {
|
||||
transcribe_recovery::enqueue_pending_for_user(user_root, slug, &self.transcribe_tx)
|
||||
.await;
|
||||
// Oneliner self-heal: if the transcribe worker is busy, it will
|
||||
// write a fresh oneliner at batch-end anyway — skip to avoid a
|
||||
// redundant LLM call.
|
||||
transcribe_recovery::regenerate_missing_oneliners_for_user(
|
||||
user_root,
|
||||
slug,
|
||||
http_client,
|
||||
config,
|
||||
vocab,
|
||||
events_tx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,9 +294,20 @@ pub async fn handle_my_cases(
|
||||
State(config): State<Arc<Config>>,
|
||||
State(pipeline): State<PipelineState>,
|
||||
State(events_tx): State<EventSender>,
|
||||
State(http_client): State<reqwest::Client>,
|
||||
State(vocab): State<Arc<Gazetteer>>,
|
||||
) -> Result<Html<String>, AppError> {
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
pipeline.heal_orphans_if_idle(&user_root, &user.slug).await;
|
||||
pipeline
|
||||
.heal_orphans_if_idle(
|
||||
&user_root,
|
||||
&user.slug,
|
||||
&http_client,
|
||||
&config,
|
||||
&vocab,
|
||||
&events_tx,
|
||||
)
|
||||
.await;
|
||||
// Auto-analysis: hand every eligible case to the worker before we
|
||||
// render. The common case is "nothing to do" and costs a handful of
|
||||
// stat-calls per case; actual enqueues happen only when pre-conditions
|
||||
@@ -310,10 +347,21 @@ pub async fn handle_case_page(
|
||||
State(config): State<Arc<Config>>,
|
||||
State(pipeline): State<PipelineState>,
|
||||
State(events_tx): State<EventSender>,
|
||||
State(http_client): State<reqwest::Client>,
|
||||
State(vocab): State<Arc<Gazetteer>>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
) -> Result<Html<String>, AppError> {
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
pipeline.heal_orphans_if_idle(&user_root, &user.slug).await;
|
||||
pipeline
|
||||
.heal_orphans_if_idle(
|
||||
&user_root,
|
||||
&user.slug,
|
||||
&http_client,
|
||||
&config,
|
||||
&vocab,
|
||||
&events_tx,
|
||||
)
|
||||
.await;
|
||||
|
||||
let case_dir = locate_case_or_404(
|
||||
&user_root,
|
||||
@@ -381,10 +429,22 @@ pub async fn handle_case_recordings(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
State(pipeline): State<PipelineState>,
|
||||
State(events_tx): State<EventSender>,
|
||||
State(http_client): State<reqwest::Client>,
|
||||
State(vocab): State<Arc<Gazetteer>>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
) -> Result<Html<String>, AppError> {
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
pipeline.heal_orphans_if_idle(&user_root, &user.slug).await;
|
||||
pipeline
|
||||
.heal_orphans_if_idle(
|
||||
&user_root,
|
||||
&user.slug,
|
||||
&http_client,
|
||||
&config,
|
||||
&vocab,
|
||||
&events_tx,
|
||||
)
|
||||
.await;
|
||||
|
||||
let case_dir = locate_case_or_404(
|
||||
&user_root,
|
||||
|
||||
@@ -140,7 +140,7 @@ fn validate_user_slug(user: &str) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_filename(filename: &str) -> Result<(), AppError> {
|
||||
pub(crate) fn validate_filename(filename: &str) -> Result<(), AppError> {
|
||||
let ok_suffix = filename.ends_with(".m4a") || filename.ends_with(".m4a.failed");
|
||||
if !ok_suffix || filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||
return Err(AppError::BadRequest("Invalid filename".into()));
|
||||
|
||||
@@ -110,31 +110,43 @@ pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec<(PathBuf, St
|
||||
let Some(slug) = user_entry.file_name().to_str().map(str::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(mut cases) = tokio::fs::read_dir(&user_path).await else {
|
||||
continue;
|
||||
};
|
||||
while let Ok(Some(case_entry)) = cases.next_entry().await {
|
||||
let case_dir = case_entry.path();
|
||||
if !case_dir.is_dir() || paths::is_deleted(&case_dir).await {
|
||||
continue;
|
||||
}
|
||||
let (state, _) = paths::read_oneliner_state(&case_dir).await;
|
||||
let needs_retry = match state {
|
||||
None | Some(OnelinerState::Error { .. }) => true,
|
||||
Some(OnelinerState::Ready { .. } | OnelinerState::Empty { .. }) => false,
|
||||
};
|
||||
if !needs_retry {
|
||||
continue;
|
||||
}
|
||||
if has_non_empty_transcript(&case_dir).await {
|
||||
out.push((case_dir, slug.clone()));
|
||||
}
|
||||
for case_dir in cases_needing_oneliner_in(&user_path).await {
|
||||
out.push((case_dir, slug.clone()));
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out
|
||||
}
|
||||
|
||||
/// Per-user variant of [`cases_needing_oneliner`]: scans only `user_root`'s
|
||||
/// case dirs. Used both as the building block for the all-users scan and
|
||||
/// from the page-load self-heal path.
|
||||
pub(crate) async fn cases_needing_oneliner_in(user_root: &Path) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
let Ok(mut cases) = tokio::fs::read_dir(user_root).await else {
|
||||
return out;
|
||||
};
|
||||
while let Ok(Some(case_entry)) = cases.next_entry().await {
|
||||
let case_dir = case_entry.path();
|
||||
if !case_dir.is_dir() || paths::is_deleted(&case_dir).await {
|
||||
continue;
|
||||
}
|
||||
let (state, _) = paths::read_oneliner_state(&case_dir).await;
|
||||
let needs_retry = match state {
|
||||
None | Some(OnelinerState::Error { .. }) => true,
|
||||
Some(OnelinerState::Ready { .. } | OnelinerState::Empty { .. }) => false,
|
||||
};
|
||||
if !needs_retry {
|
||||
continue;
|
||||
}
|
||||
if has_non_empty_transcript(&case_dir).await {
|
||||
out.push(case_dir);
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
async fn has_non_empty_transcript(case_dir: &Path) -> bool {
|
||||
let Ok(mut files) = tokio::fs::read_dir(case_dir).await else {
|
||||
return false;
|
||||
@@ -178,6 +190,27 @@ pub async fn regenerate_missing_oneliners(
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-user variant of [`regenerate_missing_oneliners`]: fixes only the
|
||||
/// given user's cases. Intended for the page-load self-heal path, so a
|
||||
/// single missing oneliner is restored as soon as its owner opens a page
|
||||
/// — no server restart needed.
|
||||
pub async fn regenerate_missing_oneliners_for_user(
|
||||
user_root: &Path,
|
||||
slug: &str,
|
||||
client: &reqwest::Client,
|
||||
config: &Config,
|
||||
vocab: &Gazetteer,
|
||||
events_tx: &EventSender,
|
||||
) {
|
||||
let cases = cases_needing_oneliner_in(user_root).await;
|
||||
if cases.is_empty() {
|
||||
return;
|
||||
}
|
||||
for case_dir in cases {
|
||||
super::worker::update_oneliner(&case_dir, slug, client, config, vocab, events_tx).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -14,7 +14,7 @@ header form { margin: 0; }
|
||||
.recording { margin: 1em 0; padding: 0.7em; border: 1px solid #ddd; border-radius: 4px; }
|
||||
.recording.failed-row { background: #fff7f7; border-left: 3px solid #e24a4a; }
|
||||
.recording-head { display: flex; align-items: center; gap: 1em; flex-wrap: wrap; }
|
||||
.rec-time { font-family: monospace; font-size: 0.95em; min-width: 11em; color: #333; }
|
||||
.rec-time { font-size: 0.95em; color: #666; }
|
||||
.filename { font-family: monospace; font-size: 0.8em; color: #999; }
|
||||
.transcript { margin: 0.6em 0 0; padding: 0.6em; background: #f6f6f6; border-radius: 4px; white-space: pre-wrap; font-family: serif; }
|
||||
.pending { color: #888; font-style: italic; margin-top: 0.5em; }
|
||||
@@ -61,6 +61,15 @@ header form { margin: 0; }
|
||||
.header-right { display: flex; align-items: center; gap: 0.8em; }
|
||||
.admin-toggle { font-size: 0.85em; color: #666; display: inline-flex; align-items: center; gap: 0.3em; cursor: pointer; user-select: none; }
|
||||
html.admin-view-off .admin-only { display: none !important; }
|
||||
|
||||
.rec-delete-form { margin: 0; }
|
||||
.rec-delete-form .delete-btn {
|
||||
padding: 0.3em; 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;
|
||||
}
|
||||
.rec-delete-form .delete-btn:hover { color: #e24a4a; background: #fff0f0; }
|
||||
</style>
|
||||
<script>
|
||||
try {
|
||||
@@ -98,8 +107,12 @@ try {
|
||||
<input type="range" class="seek" min="0" max="1000" value="0" step="1" aria-label="Position">
|
||||
<span class="time">0:00 / 0:00</span>
|
||||
</div>
|
||||
{% if is_admin %}<span class="filename admin-only">{{ rec.filename }}</span>{% endif %}
|
||||
<form method="post" action="/web/cases/{{ case_id }}/recordings/delete" class="rec-delete-form" data-ts="{{ rec.recorded_at_iso }}">
|
||||
<input type="hidden" name="filename" value="{{ rec.filename }}">
|
||||
<button type="submit" class="delete-btn" title="Aufnahme entfernen" aria-label="Aufnahme 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>
|
||||
</form>
|
||||
</div>
|
||||
{% if is_admin %}<div class="filename admin-only">{{ rec.filename }}</div>{% endif %}
|
||||
{% if rec.failed %}
|
||||
<div class="failed">Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.</div>
|
||||
{% else %}
|
||||
@@ -121,6 +134,9 @@ try {
|
||||
</div>
|
||||
{% endfor %}
|
||||
<script>
|
||||
{% include "partials/time_format.js" %}
|
||||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
const cb = document.getElementById('admin-view-toggle');
|
||||
if (cb) {
|
||||
@@ -132,18 +148,30 @@ try {
|
||||
});
|
||||
}
|
||||
|
||||
// Render the timestamp in the browser's local timezone. Server renders UTC
|
||||
// (RFC3339) into `datetime=""`; JS formats it for display.
|
||||
const timeFmt = new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
// Render the timestamp in the browser's local timezone using the same
|
||||
// "Heute/Gestern/DD.MM.YYYY - HH:MM" format as the case page. Server
|
||||
// renders the raw ISO into `datetime=""`; JS replaces the text.
|
||||
const formatRec = d => TimeFmt.dateLabel(d) + ' - ' + TimeFmt.timeLabel(d);
|
||||
document.querySelectorAll('.rec-time[datetime]').forEach(el => {
|
||||
const iso = el.getAttribute('datetime');
|
||||
if (!iso) return;
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return;
|
||||
el.textContent = timeFmt.format(d);
|
||||
el.textContent = formatRec(d);
|
||||
});
|
||||
|
||||
// Native confirm() before destructive delete. If JS is off, the form
|
||||
// submits unconfirmed — acceptable for an internal tool.
|
||||
document.querySelectorAll('.rec-delete-form').forEach(form => {
|
||||
form.addEventListener('submit', e => {
|
||||
const iso = form.dataset.ts;
|
||||
let label = 'diese Aufnahme';
|
||||
if (iso) {
|
||||
const d = new Date(iso);
|
||||
if (!isNaN(d.getTime())) label = 'die Aufnahme vom ' + formatRec(d);
|
||||
}
|
||||
if (!confirm(`Soll ${label} wirklich gelöscht werden?`)) e.preventDefault();
|
||||
});
|
||||
});
|
||||
|
||||
const fmtDur = s => {
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Per-recording delete endpoint: POST /web/cases/{case_id}/recordings/delete.
|
||||
//!
|
||||
//! Verifies the endpoint:
|
||||
//! - removes the audio file and its transcript / duration sidecars
|
||||
//! - invalidates derived artefacts (oneliner.json, document.md, analysis_input.json)
|
||||
//! - leaves other recordings in the same case untouched
|
||||
//! - rejects path-traversal filenames with 400
|
||||
//! - enforces case ownership (returns 404 on cross-user access)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use doctate_server::config::{Config, User};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn make_user(slug: &str, password_plain: &str) -> User {
|
||||
User {
|
||||
slug: slug.into(),
|
||||
api_key: format!("key-{slug}"),
|
||||
web_password: bcrypt::hash(password_plain, 4).unwrap(),
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_config(users: Vec<User>) -> Arc<Config> {
|
||||
let data_path = std::env::temp_dir().join(format!(
|
||||
"doctate-delete-rec-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let api_keys: HashMap<String, String> = users
|
||||
.iter()
|
||||
.map(|u| (u.api_key.clone(), u.slug.clone()))
|
||||
.collect();
|
||||
Arc::new(Config {
|
||||
data_path,
|
||||
users,
|
||||
api_keys,
|
||||
..Config::test_default()
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_session_cookie(resp: &axum::response::Response) -> Option<String> {
|
||||
for v in resp.headers().get_all(header::SET_COOKIE).iter() {
|
||||
let s = v.to_str().ok()?;
|
||||
if let Some(pair) = s.split(';').next()
|
||||
&& pair.starts_with("session=")
|
||||
{
|
||||
return Some(pair.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn login_request(slug: &str, password: &str) -> Request<Body> {
|
||||
let body = format!("slug={slug}&password={password}");
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/web/login")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn login(app: &axum::Router, slug: &str, password: &str) -> String {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(login_request(slug, password))
|
||||
.await
|
||||
.unwrap();
|
||||
extract_session_cookie(&resp).expect("login should set session cookie")
|
||||
}
|
||||
|
||||
fn seed_case_dir(data_path: &Path, slug: &str, case_id: &str) -> PathBuf {
|
||||
let case_dir = data_path.join(slug).join(case_id);
|
||||
std::fs::create_dir_all(&case_dir).unwrap();
|
||||
case_dir
|
||||
}
|
||||
|
||||
/// Write `<ts>.m4a` + matching `.transcript.txt` + `.duration.txt`, all
|
||||
/// with placeholder content. Returns the audio filename (no path).
|
||||
fn seed_recording(case_dir: &Path, ts_stem: &str, transcript: &str) -> String {
|
||||
let filename = format!("{ts_stem}.m4a");
|
||||
std::fs::write(case_dir.join(&filename), b"fake audio").unwrap();
|
||||
std::fs::write(
|
||||
case_dir.join(format!("{ts_stem}.transcript.txt")),
|
||||
transcript,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(case_dir.join(format!("{ts_stem}.duration.txt")), "42").unwrap();
|
||||
filename
|
||||
}
|
||||
|
||||
fn delete_request(case_id: &str, filename: &str, cookie: &str) -> Request<Body> {
|
||||
let body = format!("filename={filename}");
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/web/cases/{case_id}/recordings/delete"))
|
||||
.header(header::COOKIE, cookie)
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recording_removes_file_and_sidecars_and_invalidates_derived() {
|
||||
let config = test_config(vec![make_user("dr_a", "s")]);
|
||||
let data_path = config.data_path.clone();
|
||||
let case_id = "11111111-1111-1111-1111-111111111111";
|
||||
let case_dir = seed_case_dir(&data_path, "dr_a", case_id);
|
||||
|
||||
let victim = seed_recording(&case_dir, "2026-04-19T10-00-00Z", "delete me");
|
||||
let survivor = seed_recording(&case_dir, "2026-04-19T11-00-00Z", "keep me");
|
||||
|
||||
// Derived artefacts the delete must also purge.
|
||||
std::fs::write(case_dir.join("oneliner.json"), b"{}").unwrap();
|
||||
std::fs::write(case_dir.join("document.md"), b"# stale").unwrap();
|
||||
std::fs::write(case_dir.join("analysis_input.json"), b"{}").unwrap();
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(&app, "dr_a", "s").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(delete_request(case_id, &victim, &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status().as_u16() / 100,
|
||||
3,
|
||||
"delete should redirect (3xx), got {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// Victim + its sidecars are gone.
|
||||
assert!(!case_dir.join(&victim).exists(), "m4a should be deleted");
|
||||
assert!(
|
||||
!case_dir
|
||||
.join("2026-04-19T10-00-00Z.transcript.txt")
|
||||
.exists(),
|
||||
"transcript sidecar should be deleted"
|
||||
);
|
||||
assert!(
|
||||
!case_dir.join("2026-04-19T10-00-00Z.duration.txt").exists(),
|
||||
"duration sidecar should be deleted"
|
||||
);
|
||||
|
||||
// Survivor is intact.
|
||||
assert!(case_dir.join(&survivor).exists(), "other m4a must remain");
|
||||
assert!(
|
||||
case_dir
|
||||
.join("2026-04-19T11-00-00Z.transcript.txt")
|
||||
.exists(),
|
||||
"other transcript must remain"
|
||||
);
|
||||
|
||||
// Derived artefacts are invalidated.
|
||||
assert!(
|
||||
!case_dir.join("oneliner.json").exists(),
|
||||
"oneliner.json must be cleared"
|
||||
);
|
||||
assert!(
|
||||
!case_dir.join("document.md").exists(),
|
||||
"document.md must be cleared"
|
||||
);
|
||||
assert!(
|
||||
!case_dir.join("analysis_input.json").exists(),
|
||||
"analysis_input.json must be cleared"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recording_rejects_path_traversal() {
|
||||
let config = test_config(vec![make_user("dr_a", "s")]);
|
||||
let data_path = config.data_path.clone();
|
||||
let case_id = "22222222-2222-2222-2222-222222222222";
|
||||
let case_dir = seed_case_dir(&data_path, "dr_a", case_id);
|
||||
seed_recording(&case_dir, "2026-04-19T10-00-00Z", "safe");
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(&app, "dr_a", "s").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(delete_request(case_id, "../../../etc/passwd.m4a", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"path traversal must be rejected"
|
||||
);
|
||||
|
||||
// Safe recording is untouched.
|
||||
assert!(case_dir.join("2026-04-19T10-00-00Z.m4a").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recording_rejects_wrong_extension() {
|
||||
let config = test_config(vec![make_user("dr_a", "s")]);
|
||||
let data_path = config.data_path.clone();
|
||||
let case_id = "33333333-3333-3333-3333-333333333333";
|
||||
seed_case_dir(&data_path, "dr_a", case_id);
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(&app, "dr_a", "s").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(delete_request(case_id, "document.md", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"non-audio extension must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recording_cross_user_returns_404() {
|
||||
// User B owns the case; User A tries to delete a recording in it.
|
||||
let config = test_config(vec![make_user("dr_a", "s"), make_user("dr_b", "s")]);
|
||||
let data_path = config.data_path.clone();
|
||||
let case_id = "44444444-4444-4444-4444-444444444444";
|
||||
let case_dir = seed_case_dir(&data_path, "dr_b", case_id);
|
||||
let filename = seed_recording(&case_dir, "2026-04-19T10-00-00Z", "bob's recording");
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie_a = login(&app, "dr_a", "s").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(delete_request(case_id, &filename, &cookie_a))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::NOT_FOUND,
|
||||
"cross-user access must return 404, not 403 (IDOR hardening)"
|
||||
);
|
||||
assert!(
|
||||
case_dir.join(&filename).exists(),
|
||||
"Bob's file must not have been touched"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_last_recording_clears_case() {
|
||||
// Deleting the only recording must succeed, not error out.
|
||||
let config = test_config(vec![make_user("dr_a", "s")]);
|
||||
let data_path = config.data_path.clone();
|
||||
let case_id = "55555555-5555-5555-5555-555555555555";
|
||||
let case_dir = seed_case_dir(&data_path, "dr_a", case_id);
|
||||
let only = seed_recording(&case_dir, "2026-04-19T10-00-00Z", "lonely");
|
||||
std::fs::write(case_dir.join("oneliner.json"), b"{}").unwrap();
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(&app, "dr_a", "s").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(delete_request(case_id, &only, &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status().as_u16() / 100,
|
||||
3,
|
||||
"last-recording delete should still redirect, got {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
assert!(!case_dir.join(&only).exists());
|
||||
assert!(!case_dir.join("oneliner.json").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_recording_accepts_failed_suffix() {
|
||||
// Recordings whose transcription failed are renamed to <ts>.m4a.failed.
|
||||
// The delete button is shown for them too — they should be deletable.
|
||||
let config = test_config(vec![make_user("dr_a", "s")]);
|
||||
let data_path = config.data_path.clone();
|
||||
let case_id = "66666666-6666-6666-6666-666666666666";
|
||||
let case_dir = seed_case_dir(&data_path, "dr_a", case_id);
|
||||
std::fs::write(case_dir.join("2026-04-19T10-00-00Z.m4a.failed"), b"x").unwrap();
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let cookie = login(&app, "dr_a", "s").await;
|
||||
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(delete_request(
|
||||
case_id,
|
||||
"2026-04-19T10-00-00Z.m4a.failed",
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status().as_u16() / 100, 3);
|
||||
assert!(!case_dir.join("2026-04-19T10-00-00Z.m4a.failed").exists());
|
||||
}
|
||||
@@ -55,6 +55,8 @@ fn build_state() -> (AppState, Arc<Config>) {
|
||||
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
|
||||
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
|
||||
events_tx: doctate_server::events::channel(),
|
||||
http_client: reqwest::Client::new(),
|
||||
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
|
||||
};
|
||||
|
||||
(state, config)
|
||||
|
||||
Reference in New Issue
Block a user