Merge pull request #598 from EdouardVanbelle/feat/readonly-drive
This commit is contained in:
@@ -488,6 +488,16 @@ impl DriveManagementService {
|
||||
),
|
||||
})?;
|
||||
|
||||
// Flush the cached typed policy view so the very next mutating
|
||||
// authz check on any resource in this drive sees the fresh
|
||||
// `read_only` value (and every other policy field). Without this,
|
||||
// a policy change would take up to `DRIVE_POLICIES_CACHE_TTL` (30 s)
|
||||
// to take effect on the hot path — unacceptable for the read_only
|
||||
// freeze, which admins expect to be effective immediately.
|
||||
self.authz
|
||||
.invalidate_drive_policies_cache_for_drive(drive_id)
|
||||
.await;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "drive.policy_changed",
|
||||
@@ -500,6 +510,7 @@ impl DriveManagementService {
|
||||
forbid_owner_role_change = merged.forbid_owner_role_change,
|
||||
include_in_photo_index = merged.include_in_photo_index,
|
||||
include_in_music_index = merged.include_in_music_index,
|
||||
read_only = merged.read_only,
|
||||
"📜 drive policies updated",
|
||||
);
|
||||
Ok(merged)
|
||||
|
||||
@@ -201,6 +201,26 @@ pub struct DrivePolicies {
|
||||
/// scrutiny (voicemail MP3s in a work drive shouldn't bleed into
|
||||
/// the personal library). See §15.
|
||||
pub include_in_music_index: bool,
|
||||
/// **Full freeze / legal-hold.** When `true`, every mutation on
|
||||
/// resources in this drive is refused — user-initiated and
|
||||
/// background alike. Compliance-grade guarantee:
|
||||
///
|
||||
/// - User-initiated: enforced at `PgAclEngine::check_inner`, which
|
||||
/// short-circuits `Create` / `Update` / `Delete` / `Share`
|
||||
/// permissions on any resource in a read-only drive. Read still
|
||||
/// passes. Manage-on-Drive still passes so admins can un-freeze.
|
||||
/// - Background jobs: the periodic trash-retention purge and
|
||||
/// orphan-upload sweep filter out read-only drives at SELECT
|
||||
/// time (SQL-side `JOIN storage.drives … WHERE (policies->>
|
||||
/// 'read_only')::boolean IS NOT TRUE`). Retention clock keeps
|
||||
/// ticking; on unfreeze, the next sweep tick catches up.
|
||||
///
|
||||
/// Applies to both personal and shared drives — a user winding
|
||||
/// down their account, freezing a secondary personal archive, or
|
||||
/// putting a shared drive on legal hold all use the same knob.
|
||||
/// Mutation is admin-only via `PATCH /api/drives/{id}/policies`
|
||||
/// (per §8 — same carve-out as every other policy).
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
impl DrivePolicies {
|
||||
|
||||
@@ -252,15 +252,35 @@ impl TrashRepository for TrashDbRepository {
|
||||
async fn delete_expired_bulk(&self) -> Result<(u64, u64)> {
|
||||
let cutoff = Utc::now() - chrono::Duration::days(self.retention_days);
|
||||
|
||||
// The `read_only` policy on a drive is a compliance-grade freeze:
|
||||
// NO state on the drive changes while the policy is on, including
|
||||
// background retention. The `JOIN storage.drives d ... AND
|
||||
// (d.policies->>'read_only')::boolean IS NOT TRUE` filter excludes
|
||||
// frozen drives at SELECT time. Retention clock keeps ticking; on
|
||||
// unfreeze, the next sweep tick catches up on anything past its
|
||||
// TTL. Legal-hold guarantee documented in `docs/plan/drive.md` §8
|
||||
// and `docs/guide/trash.md`.
|
||||
//
|
||||
// `(policies->>'read_only')::boolean IS NOT TRUE` semantics:
|
||||
// - key missing → NULL::boolean → IS NOT TRUE → included
|
||||
// - explicit `false` → FALSE → IS NOT TRUE → included
|
||||
// - explicit `true` → TRUE → IS TRUE → excluded
|
||||
// Correct for both current data (most drives omit the key) and
|
||||
// freshly-frozen drives.
|
||||
|
||||
// 1. Bulk-delete expired trashed files in batches.
|
||||
// The PG trigger `trg_files_decrement_blob_ref` automatically
|
||||
// decrements blob ref_count for every deleted row.
|
||||
let files_deleted = self
|
||||
.delete_expired_batch_loop(
|
||||
"DELETE FROM storage.files
|
||||
WHERE id IN (SELECT id FROM storage.files
|
||||
WHERE is_trashed = TRUE AND trashed_at < $1
|
||||
ORDER BY trashed_at
|
||||
WHERE id IN (SELECT f.id
|
||||
FROM storage.files f
|
||||
JOIN storage.drives d ON d.id = f.drive_id
|
||||
WHERE f.is_trashed = TRUE
|
||||
AND f.trashed_at < $1
|
||||
AND (d.policies->>'read_only')::boolean IS NOT TRUE
|
||||
ORDER BY f.trashed_at
|
||||
LIMIT $2)",
|
||||
cutoff,
|
||||
1_000,
|
||||
@@ -270,13 +290,19 @@ impl TrashRepository for TrashDbRepository {
|
||||
// 2. Bulk-delete expired trashed folders in batches.
|
||||
// FK ON DELETE CASCADE handles descendant folders and their
|
||||
// files, so each row can fan out to an entire subtree — hence
|
||||
// the smaller batch size.
|
||||
// the smaller batch size. Same read_only exclusion applies:
|
||||
// a subtree rooted in a frozen drive isn't purged even if the
|
||||
// folder's own trashed_at is past retention.
|
||||
let folders_deleted = self
|
||||
.delete_expired_batch_loop(
|
||||
"DELETE FROM storage.folders
|
||||
WHERE id IN (SELECT id FROM storage.folders
|
||||
WHERE is_trashed = TRUE AND trashed_at < $1
|
||||
ORDER BY trashed_at
|
||||
WHERE id IN (SELECT f.id
|
||||
FROM storage.folders f
|
||||
JOIN storage.drives d ON d.id = f.drive_id
|
||||
WHERE f.is_trashed = TRUE
|
||||
AND f.trashed_at < $1
|
||||
AND (d.policies->>'read_only')::boolean IS NOT TRUE
|
||||
ORDER BY f.trashed_at
|
||||
LIMIT $2)",
|
||||
cutoff,
|
||||
100,
|
||||
|
||||
@@ -40,6 +40,7 @@ use sqlx::PgPool;
|
||||
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::drive::DrivePolicies;
|
||||
use crate::domain::entities::subject_group::INTERNAL_GROUP_ID;
|
||||
use crate::domain::repositories::subject_group_repository::SubjectGroupRepository;
|
||||
use crate::domain::services::authorization::{
|
||||
@@ -91,6 +92,17 @@ const DRIVE_ROLE_CACHE_CAPACITY: u64 = 100_000;
|
||||
/// enough that any oversight self-heals in <1 minute.
|
||||
const DRIVE_ROLE_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// `drive_policies_cache` bound: entries are `(Uuid, DrivePolicies)` — a
|
||||
/// handful of bools per drive. 100k is generous headroom for the drive
|
||||
/// population of any realistic deployment.
|
||||
const DRIVE_POLICIES_CACHE_CAPACITY: u64 = 100_000;
|
||||
/// `drive_policies_cache` TTL. Policy mutations explicitly invalidate
|
||||
/// (see `invalidate_drive_policies_cache_for_drive`) so the TTL is the
|
||||
/// self-heal net for edge cases (direct SQL PATCH by an operator, migration
|
||||
/// backfill). Short enough that a manually-flipped `read_only` becomes
|
||||
/// effective within a minute on the hot path.
|
||||
const DRIVE_POLICIES_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
pub struct PgAclEngine {
|
||||
pool: Arc<PgPool>,
|
||||
folder_repo: Arc<FolderDbRepository>,
|
||||
@@ -132,6 +144,22 @@ pub struct PgAclEngine {
|
||||
/// `DriveManagementService`, the grant handler's revoke path) hit the
|
||||
/// invalidator inline.
|
||||
drive_role_cache: Cache<(Subject, Uuid), Option<Role>>,
|
||||
|
||||
/// Memoise `drive_id → DrivePolicies` (the typed view of the JSONB
|
||||
/// `storage.drives.policies` column). Read on every mutating authz
|
||||
/// check on a resource that lives in a drive (File/Folder/Drive) to
|
||||
/// gate the `read_only` freeze.
|
||||
///
|
||||
/// Subject-independent — policies are the same for every caller, so a
|
||||
/// single entry per drive covers the whole tenant. Kept separate from
|
||||
/// `drive_role_cache` (subject-keyed) so policy changes only flush this
|
||||
/// cache, and membership changes only flush that one.
|
||||
///
|
||||
/// **Invalidation**: explicit on every `DriveManagementService::update_policies`
|
||||
/// call — a policy PATCH invalidates the entry before the response
|
||||
/// returns, so the next check sees the fresh values. Short 30 s TTL
|
||||
/// as the self-heal net for direct-SQL edits and migration backfills.
|
||||
drive_policies_cache: Cache<Uuid, DrivePolicies>,
|
||||
}
|
||||
|
||||
impl PgAclEngine {
|
||||
@@ -165,6 +193,10 @@ impl PgAclEngine {
|
||||
.max_capacity(DRIVE_ROLE_CACHE_CAPACITY)
|
||||
.time_to_live(DRIVE_ROLE_CACHE_TTL)
|
||||
.build(),
|
||||
drive_policies_cache: Cache::builder()
|
||||
.max_capacity(DRIVE_POLICIES_CACHE_CAPACITY)
|
||||
.time_to_live(DRIVE_POLICIES_CACHE_TTL)
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +263,10 @@ impl PgAclEngine {
|
||||
.max_capacity(1)
|
||||
.time_to_live(Duration::from_secs(1))
|
||||
.build(),
|
||||
drive_policies_cache: Cache::builder()
|
||||
.max_capacity(1)
|
||||
.time_to_live(Duration::from_secs(1))
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +293,15 @@ impl PgAclEngine {
|
||||
/// `drive_role_cache` initialiser above), otherwise moka returns
|
||||
/// `InvalidationClosuresDisabled` and the mutation silently leaves
|
||||
/// stale role rows in cache for the full TTL.
|
||||
/// Drop the cached `DrivePolicies` entry for one drive. Called by
|
||||
/// `DriveManagementService::update_policies` after every JSONB PATCH so
|
||||
/// the next mutating authz check sees the fresh `read_only` flag and
|
||||
/// other policy values without waiting for the TTL. Single-entry
|
||||
/// invalidate is a cheap concurrent-map op.
|
||||
pub async fn invalidate_drive_policies_cache_for_drive(&self, drive_id: Uuid) {
|
||||
self.drive_policies_cache.invalidate(&drive_id).await;
|
||||
}
|
||||
|
||||
pub async fn invalidate_drive_role_cache_for_drive(&self, drive_id: Uuid) {
|
||||
// `invalidate_entries_if` rejects predicates returning errors —
|
||||
// simple Fn(K, V) -> bool. We capture `drive_id` by value (Copy)
|
||||
@@ -711,6 +756,65 @@ impl PgAclEngine {
|
||||
Ok(role)
|
||||
}
|
||||
|
||||
/// Fetch a drive's typed `DrivePolicies`, going through `drive_policies_cache`
|
||||
/// (30 s TTL, explicit invalidation on policy PATCH). Malformed JSONB
|
||||
/// falls back to the all-false default — consistent with
|
||||
/// `DrivePolicies::from_value` — so enforcement can't panic on legacy
|
||||
/// or partial data.
|
||||
async fn drive_policies_cached(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
counters: &QueryCounters,
|
||||
) -> Result<DrivePolicies, DomainError> {
|
||||
if let Some(cached) = self.drive_policies_cache.get(&drive_id).await {
|
||||
counters.cache_hit.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(cached);
|
||||
}
|
||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||
let row: Option<(serde_json::Value,)> =
|
||||
sqlx::query_as("SELECT policies FROM storage.drives WHERE id = $1")
|
||||
.bind(drive_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("PgAcl", format!("policies lookup: {e}"))
|
||||
})?;
|
||||
// Missing drive: cache the default (all-false). Anti-enum handled by
|
||||
// the caller — a missing drive returns NotFound at the resource-resolve
|
||||
// step upstream; here we just make sure the cache doesn't panic-loop
|
||||
// if the read happens post-drive-delete.
|
||||
let policies = row
|
||||
.map(|(v,)| DrivePolicies::from_value(&v))
|
||||
.unwrap_or_default();
|
||||
self.drive_policies_cache
|
||||
.insert(drive_id, policies.clone())
|
||||
.await;
|
||||
Ok(policies)
|
||||
}
|
||||
|
||||
/// Every permission except `Read` mutates persistent state on a
|
||||
/// drive-scoped resource and is therefore refused when the drive is
|
||||
/// `read_only=true`:
|
||||
///
|
||||
/// - `Create` / `Update` / `Delete` — the obvious file/folder mutations.
|
||||
/// - `Share` — persists a new `role_grants` row.
|
||||
/// - `Comment` — adds user-generated content (reserved feature).
|
||||
/// - `Manage` — mutates drive-level membership (add/remove/promote
|
||||
/// members) on `Resource::Drive`.
|
||||
///
|
||||
/// **Admin escape hatch does NOT rely on this gate.** Un-freezing a
|
||||
/// drive goes through `PATCH /api/drives/{id}/policies`, which is
|
||||
/// admin-only via `admin_guard` at the handler layer — it never
|
||||
/// enters `authz.require`. So blocking `Manage` here doesn't lock
|
||||
/// admins out; it locks OWNERS out of membership mutation while the
|
||||
/// freeze holds, which is exactly the legal-hold guarantee.
|
||||
///
|
||||
/// Only `Read` passes: members can still list, download, and PROPFIND
|
||||
/// the drive's contents.
|
||||
fn read_only_gate_applies(p: Permission) -> bool {
|
||||
!matches!(p, Permission::Read)
|
||||
}
|
||||
|
||||
/// Look up a single role grant by id, returning the actors a revoke /
|
||||
/// notify handler needs to make a decision without a second round-trip.
|
||||
/// Returns `(subject, resource, granted_by)` or `None` if no such row.
|
||||
@@ -826,6 +930,36 @@ impl PgAclEngine {
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
// Read-only drive freeze — every mutating permission on any
|
||||
// resource in this drive is refused, regardless of the caller's
|
||||
// role. Compliance-grade guarantee: paired with the background-
|
||||
// job SQL filters, no state on this drive changes until the
|
||||
// policy is flipped. See `docs/plan/drive.md` §8 (`read_only`).
|
||||
//
|
||||
// Anti-enumeration: emit an audit line with the specific
|
||||
// `drive_read_only` reason, then return `false`. The generic
|
||||
// `authz.denied` line at `require` also fires — operators
|
||||
// filter on the specific event to find freeze-caused denials.
|
||||
if Self::read_only_gate_applies(permission)
|
||||
&& self
|
||||
.drive_policies_cached(drive_id, counters)
|
||||
.await?
|
||||
.read_only
|
||||
{
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "authz.denied",
|
||||
reason = "drive_read_only",
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
permission = permission.as_str(),
|
||||
resource_type = resource.type_str(),
|
||||
resource_id = %resource.id(),
|
||||
drive_id = %drive_id,
|
||||
"🧊 mutation refused: drive is read-only",
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(role) = self
|
||||
.caller_role_on_drive_cached(subject, drive_id, counters)
|
||||
.await?
|
||||
@@ -866,6 +1000,28 @@ impl PgAclEngine {
|
||||
.await
|
||||
}
|
||||
Resource::Drive(id) => {
|
||||
// Same read_only gate as the File/Folder branch: a frozen
|
||||
// drive refuses every mutating permission (Create / Update /
|
||||
// Delete / Share) targeting the drive resource itself.
|
||||
// Manage stays permitted so admins can toggle the policy
|
||||
// back off; Read stays permitted so members can still list.
|
||||
if Self::read_only_gate_applies(permission)
|
||||
&& self.drive_policies_cached(id, counters).await?.read_only
|
||||
{
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "authz.denied",
|
||||
reason = "drive_read_only",
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
permission = permission.as_str(),
|
||||
resource_type = "drive",
|
||||
resource_id = %id,
|
||||
drive_id = %id,
|
||||
"🧊 mutation refused: drive is read-only",
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
// Same cache-aware path the precheck uses — keeps the
|
||||
// single-source-of-truth for drive role resolution and
|
||||
// benefits identically from `drive_role_cache`.
|
||||
|
||||
@@ -404,6 +404,8 @@ pub struct UpdateDrivePoliciesDto {
|
||||
pub include_in_photo_index: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub include_in_music_index: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub read_only: Option<bool>,
|
||||
}
|
||||
|
||||
/// `PATCH /api/drives/{id}/policies` — **OxiCloud-admin only** policy
|
||||
@@ -488,6 +490,9 @@ pub async fn update_drive_policies(
|
||||
if let Some(v) = dto.include_in_music_index {
|
||||
partial_obj.insert("include_in_music_index".into(), serde_json::Value::Bool(v));
|
||||
}
|
||||
if let Some(v) = dto.read_only {
|
||||
partial_obj.insert("read_only".into(), serde_json::Value::Bool(v));
|
||||
}
|
||||
// Pass the raw JSON straight through so the JSONB `||` merge in
|
||||
// the repo only touches keys the caller supplied. Round-tripping
|
||||
// via `DrivePolicies` (which has `#[serde(default)]`) would
|
||||
|
||||
@@ -97,7 +97,7 @@ pub async fn move_file_to_trash(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(item_id): Path<String>,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
) -> axum::response::Response {
|
||||
let user_id = auth_user.id;
|
||||
debug!(
|
||||
"Request to move file to trash: id={}, user={}",
|
||||
@@ -112,7 +112,8 @@ pub async fn move_file_to_trash(
|
||||
Json(json!({
|
||||
"error": "Trash feature is not enabled"
|
||||
})),
|
||||
);
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -129,15 +130,11 @@ pub async fn move_file_to_trash(
|
||||
"message": "File moved to trash successfully"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error moving file to trash: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": "Error moving file to trash"
|
||||
})),
|
||||
)
|
||||
warn!("move_file_to_trash failed: {:?}", e);
|
||||
AppError::from(e).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +156,7 @@ pub async fn move_folder_to_trash(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(item_id): Path<String>,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
) -> axum::response::Response {
|
||||
let user_id = auth_user.id;
|
||||
debug!(
|
||||
"Request to move folder to trash: id={}, user={}",
|
||||
@@ -174,7 +171,8 @@ pub async fn move_folder_to_trash(
|
||||
Json(json!({
|
||||
"error": "Trash feature is not enabled"
|
||||
})),
|
||||
);
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -193,15 +191,11 @@ pub async fn move_folder_to_trash(
|
||||
"message": "Folder moved to trash successfully"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error moving folder to trash: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": "Error moving folder to trash"
|
||||
})),
|
||||
)
|
||||
warn!("move_folder_to_trash failed: {:?}", e);
|
||||
AppError::from(e).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,7 +217,7 @@ pub async fn restore_from_trash(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(trash_id): Path<String>,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
) -> axum::response::Response {
|
||||
debug!("Request to restore item {} from trash", trash_id);
|
||||
|
||||
let trash_service = match state.trash_service.as_ref() {
|
||||
@@ -234,7 +228,8 @@ pub async fn restore_from_trash(
|
||||
Json(json!({
|
||||
"error": "Trash feature is not enabled"
|
||||
})),
|
||||
);
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let result = trash_service.restore_item(&trash_id, auth_user.id).await;
|
||||
@@ -249,31 +244,11 @@ pub async fn restore_from_trash(
|
||||
"message": "Item restored successfully"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = format!("{}", e);
|
||||
// If item not found, report success (it was already restored or removed)
|
||||
if err_str.contains("not found") || err_str.contains("NotFound") {
|
||||
warn!(
|
||||
"Item not found in trash, but reporting success: {}",
|
||||
trash_id
|
||||
);
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"message": "Item restored (or was already removed from trash)"
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
error!("Error restoring item from trash: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": "Error restoring item from trash"
|
||||
})),
|
||||
)
|
||||
warn!("restore_from_trash failed: {:?}", e);
|
||||
AppError::from(e).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,7 +270,7 @@ pub async fn delete_permanently(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(trash_id): Path<String>,
|
||||
) -> (StatusCode, Json<serde_json::Value>) {
|
||||
) -> axum::response::Response {
|
||||
debug!("Request to permanently delete item {}", trash_id);
|
||||
|
||||
let trash_service = match state.trash_service.as_ref() {
|
||||
@@ -306,7 +281,8 @@ pub async fn delete_permanently(
|
||||
Json(json!({
|
||||
"error": "Trash feature is not enabled"
|
||||
})),
|
||||
);
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let result = trash_service
|
||||
@@ -323,31 +299,11 @@ pub async fn delete_permanently(
|
||||
"message": "Item deleted permanently"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = format!("{}", e);
|
||||
// If item not found, report success (it was already deleted)
|
||||
if err_str.contains("not found") || err_str.contains("NotFound") {
|
||||
warn!(
|
||||
"Item not found in trash, but reporting success: {}",
|
||||
trash_id
|
||||
);
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"message": "Item deleted (or was already removed from trash)"
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
error!("Error permanently deleting item: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": "Error deleting item permanently"
|
||||
})),
|
||||
)
|
||||
warn!("delete_permanently failed: {:?}", e);
|
||||
AppError::from(e).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user