From ffb8823f4ccac16f6f86de705367f44f822f8e7e Mon Sep 17 00:00:00 2001 From: qaz741wsd856 Date: Fri, 28 Nov 2025 16:23:09 +0000 Subject: [PATCH] feat: implement soft and hard delete operations for ciphers, including bulk actions and restore functionality --- src/handlers/ciphers.rs | 184 +++++++++++++++++++++++++++++++++++++++- src/router.rs | 19 ++++- 2 files changed, 198 insertions(+), 5 deletions(-) diff --git a/src/handlers/ciphers.rs b/src/handlers/ciphers.rs index 6e22ae1..5944ef4 100644 --- a/src/handlers/ciphers.rs +++ b/src/handlers/ciphers.rs @@ -1,5 +1,6 @@ use axum::{extract::State, Json}; use chrono::Utc; +use serde::Deserialize; use std::sync::Arc; use uuid::Uuid; use worker::{query, Env}; @@ -156,15 +157,75 @@ pub async fn update_cipher( Ok(Json(cipher)) } +/// Request body for bulk cipher operations +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CipherIdsData { + pub ids: Vec, +} + +/// Soft delete a single cipher (PUT /api/ciphers/{id}/delete) +/// Sets deleted_at to current timestamp #[worker::send] -pub async fn delete_cipher( +pub async fn soft_delete_cipher( + claims: Claims, + State(env): State>, + Path(id): Path, +) -> Result, AppError> { + let db = db::get_db(&env)?; + let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + + query!( + &db, + "UPDATE ciphers SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND user_id = ?3", + now, + id, + claims.sub + ) + .map_err(|_| AppError::Database)? + .run() + .await?; + + Ok(Json(())) +} + +/// Soft delete multiple ciphers (PUT /api/ciphers/delete) +#[worker::send] +pub async fn soft_delete_ciphers_bulk( + claims: Claims, + State(env): State>, + Json(payload): Json, +) -> Result, AppError> { + let db = db::get_db(&env)?; + let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + + for id in payload.ids { + query!( + &db, + "UPDATE ciphers SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND user_id = ?3", + now, + id, + claims.sub + ) + .map_err(|_| AppError::Database)? + .run() + .await?; + } + + Ok(Json(())) +} + +/// Hard delete a single cipher (DELETE /api/ciphers/{id} or POST /api/ciphers/{id}/delete) +/// Permanently removes the cipher from database +#[worker::send] +pub async fn hard_delete_cipher( claims: Claims, State(env): State>, Path(id): Path, ) -> Result, AppError> { let db = db::get_db(&env)?; - let res = query!( + query!( &db, "DELETE FROM ciphers WHERE id = ?1 AND user_id = ?2", id, @@ -177,6 +238,125 @@ pub async fn delete_cipher( Ok(Json(())) } +/// Hard delete multiple ciphers (DELETE /api/ciphers or POST /api/ciphers/delete) +#[worker::send] +pub async fn hard_delete_ciphers_bulk( + claims: Claims, + State(env): State>, + Json(payload): Json, +) -> Result, AppError> { + let db = db::get_db(&env)?; + + for id in payload.ids { + query!( + &db, + "DELETE FROM ciphers WHERE id = ?1 AND user_id = ?2", + id, + claims.sub + ) + .map_err(|_| AppError::Database)? + .run() + .await?; + } + + Ok(Json(())) +} + +/// Restore a single cipher (PUT /api/ciphers/{id}/restore) +/// Clears the deleted_at timestamp +#[worker::send] +pub async fn restore_cipher( + claims: Claims, + State(env): State>, + Path(id): Path, +) -> Result, AppError> { + let db = db::get_db(&env)?; + let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + + // Update the cipher to clear deleted_at + query!( + &db, + "UPDATE ciphers SET deleted_at = NULL, updated_at = ?1 WHERE id = ?2 AND user_id = ?3", + now, + id, + claims.sub + ) + .map_err(|_| AppError::Database)? + .run() + .await?; + + // Fetch and return the restored cipher + let cipher_db: crate::models::cipher::CipherDBModel = query!( + &db, + "SELECT * FROM ciphers WHERE id = ?1 AND user_id = ?2", + id, + claims.sub + ) + .map_err(|_| AppError::Database)? + .first(None) + .await? + .ok_or(AppError::NotFound("Cipher not found".to_string()))?; + + Ok(Json(cipher_db.into())) +} + +/// Response for bulk restore operation +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BulkRestoreResponse { + pub data: Vec, + pub object: String, + pub continuation_token: Option, +} + +/// Restore multiple ciphers (PUT /api/ciphers/restore) +#[worker::send] +pub async fn restore_ciphers_bulk( + claims: Claims, + State(env): State>, + Json(payload): Json, +) -> Result, AppError> { + let db = db::get_db(&env)?; + let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + + let mut restored_ciphers = Vec::new(); + + for id in payload.ids { + // Update the cipher to clear deleted_at + query!( + &db, + "UPDATE ciphers SET deleted_at = NULL, updated_at = ?1 WHERE id = ?2 AND user_id = ?3", + now, + id, + claims.sub + ) + .map_err(|_| AppError::Database)? + .run() + .await?; + + // Fetch the restored cipher + let cipher_db: Option = query!( + &db, + "SELECT * FROM ciphers WHERE id = ?1 AND user_id = ?2", + id, + claims.sub + ) + .map_err(|_| AppError::Database)? + .first(None) + .await?; + + if let Some(cipher) = cipher_db { + restored_ciphers.push(cipher.into()); + } + } + + Ok(Json(BulkRestoreResponse { + data: restored_ciphers, + object: "list".to_string(), + continuation_token: None, + })) +} + /// Handler for POST /api/ciphers /// Accepts flat JSON structure (camelCase) as sent by Bitwarden clients /// when creating a cipher without collection assignments. diff --git a/src/router.rs b/src/router.rs index c7c2fd1..d490735 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,11 +1,11 @@ use axum::{ - routing::{get, post, put, delete}, + routing::{delete, get, post, put}, Router, }; use std::sync::Arc; use worker::Env; -use crate::handlers::{accounts, ciphers, config, identity, sync, folders, import}; +use crate::handlers::{accounts, ciphers, config, folders, identity, import, sync}; pub fn api_router(env: Env) -> Router { let app_state = Arc::new(env); @@ -34,7 +34,20 @@ pub fn api_router(env: Env) -> Router { .route("/api/ciphers/create", post(ciphers::create_cipher)) .route("/api/ciphers/import", post(import::import_data)) .route("/api/ciphers/{id}", put(ciphers::update_cipher)) - .route("/api/ciphers/{id}/delete", put(ciphers::delete_cipher)) + // Cipher soft delete (PUT sets deleted_at timestamp) + .route("/api/ciphers/{id}/delete", put(ciphers::soft_delete_cipher)) + // Cipher hard delete (DELETE/POST permanently removes cipher) + .route("/api/ciphers/{id}", delete(ciphers::hard_delete_cipher)) + .route("/api/ciphers/{id}/delete", post(ciphers::hard_delete_cipher)) + // Cipher bulk soft delete + .route("/api/ciphers/delete", put(ciphers::soft_delete_ciphers_bulk)) + // Cipher bulk hard delete + .route("/api/ciphers/delete", post(ciphers::hard_delete_ciphers_bulk)) + .route("/api/ciphers", delete(ciphers::hard_delete_ciphers_bulk)) + // Cipher restore (clears deleted_at) + .route("/api/ciphers/{id}/restore", put(ciphers::restore_cipher)) + // Cipher bulk restore + .route("/api/ciphers/restore", put(ciphers::restore_ciphers_bulk)) // Folders CRUD .route("/api/folders", post(folders::create_folder)) .route("/api/folders/{id}", put(folders::update_folder))