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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user