From 36b3e5f6c155440a4267941a747c98e5bc966f87 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 15 Apr 2026 22:41:47 +0200 Subject: [PATCH] Add delete marker support to paths Introduce DeleteMarker struct and associated functions for managing soft-delete markers within case directories. This enables tracking deleted cases and supports undo functionality. feat: Add delete marker support to paths Introduces a `.deleted` file to mark cases as soft-deleted. The marker contains a `batch` UUID for grouping deletions and a `deleted_at` timestamp for ordering. New functions `is_deleted`, `read_delete_marker`, and `write_delete_marker` are added to manage this marker. The `locate_case` function is updated to also consider soft-deleted cases as not found. --- server/Cargo.lock | 1 + server/Cargo.toml | 2 +- server/src/paths.rs | 41 +++++++++++++++++++++++++++++++++++ server/src/routes/user_web.rs | 13 ++++++----- 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index 0f73593..467deeb 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -2045,6 +2045,7 @@ checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] diff --git a/server/Cargo.toml b/server/Cargo.toml index 494d1ff..6bdc59f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -14,7 +14,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" dotenvy = "0.15" -uuid = { version = "1", features = ["v4"] } +uuid = { version = "1", features = ["v4", "serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" diff --git a/server/src/paths.rs b/server/src/paths.rs index b3e2535..a8c76ed 100644 --- a/server/src/paths.rs +++ b/server/src/paths.rs @@ -1,5 +1,8 @@ use std::path::{Path, PathBuf}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + /// Absolute on-disk location of a case directory. /// /// Single source of truth for the layout `///`. @@ -8,3 +11,41 @@ use std::path::{Path, PathBuf}; pub fn case_dir(data_path: &Path, slug: &str, case_id: &str) -> PathBuf { data_path.join(slug).join(case_id) } + +/// Filename of the soft-delete marker inside a case directory. +pub const DELETE_MARKER: &str = ".deleted"; + +/// Soft-delete marker payload. One file per deleted case; all cases removed +/// in the same user click share the same `batch` UUID. `deleted_at` is an +/// RFC3339 timestamp — used to find the most recent batch for "Undo last +/// delete". +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteMarker { + pub batch: Uuid, + pub deleted_at: String, +} + +/// True iff the case directory contains a `.deleted` marker file. +pub async fn is_deleted(case_dir: &Path) -> bool { + tokio::fs::try_exists(case_dir.join(DELETE_MARKER)) + .await + .unwrap_or(false) +} + +/// Read and parse the `.deleted` marker. Returns `None` if absent or malformed +/// (treated identically — a malformed marker still means "deleted" via +/// `is_deleted`, but cannot participate in batch-undo). +pub async fn read_delete_marker(case_dir: &Path) -> Option { + let bytes = tokio::fs::read(case_dir.join(DELETE_MARKER)).await.ok()?; + serde_json::from_slice(&bytes).ok() +} + +/// Write the `.deleted` marker as JSON. Overwrites any existing marker. +pub async fn write_delete_marker( + case_dir: &Path, + marker: &DeleteMarker, +) -> std::io::Result<()> { + let bytes = serde_json::to_vec(marker) + .map_err(|e| std::io::Error::other(format!("serialize delete marker: {e}")))?; + tokio::fs::write(case_dir.join(DELETE_MARKER), bytes).await +} diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 9398f94..5f505c2 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -166,14 +166,17 @@ pub async fn handle_case_detail( } /// Locate a case directory under `//`. Returns `None` -/// if the directory does not exist (treated as a 404 / IDOR probe by callers). +/// if the directory does not exist OR is soft-deleted (`.deleted` marker +/// present). Both are treated as 404 / IDOR probe by callers. pub(crate) async fn locate_case(user_root: &Path, case_id: &str) -> Option { let p = user_root.join(case_id); - if tokio::fs::try_exists(&p).await.unwrap_or(false) { - Some(p) - } else { - None + if !tokio::fs::try_exists(&p).await.unwrap_or(false) { + return None; } + if crate::paths::is_deleted(&p).await { + return None; + } + Some(p) } /// Scan all of the given user's cases. Returned vec is sorted by most