feat: implement soft and hard delete operations for ciphers, including bulk actions and restore functionality
This commit is contained in:
parent
744a05906c
commit
ffb8823f4c
2 changed files with 198 additions and 5 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use worker::{query, Env};
|
use worker::{query, Env};
|
||||||
|
|
@ -156,15 +157,75 @@ pub async fn update_cipher(
|
||||||
Ok(Json(cipher))
|
Ok(Json(cipher))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request body for bulk cipher operations
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CipherIdsData {
|
||||||
|
pub ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft delete a single cipher (PUT /api/ciphers/{id}/delete)
|
||||||
|
/// Sets deleted_at to current timestamp
|
||||||
#[worker::send]
|
#[worker::send]
|
||||||
pub async fn delete_cipher(
|
pub async fn soft_delete_cipher(
|
||||||
|
claims: Claims,
|
||||||
|
State(env): State<Arc<Env>>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<()>, 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<Arc<Env>>,
|
||||||
|
Json(payload): Json<CipherIdsData>,
|
||||||
|
) -> Result<Json<()>, 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,
|
claims: Claims,
|
||||||
State(env): State<Arc<Env>>,
|
State(env): State<Arc<Env>>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<()>, AppError> {
|
) -> Result<Json<()>, AppError> {
|
||||||
let db = db::get_db(&env)?;
|
let db = db::get_db(&env)?;
|
||||||
|
|
||||||
let res = query!(
|
query!(
|
||||||
&db,
|
&db,
|
||||||
"DELETE FROM ciphers WHERE id = ?1 AND user_id = ?2",
|
"DELETE FROM ciphers WHERE id = ?1 AND user_id = ?2",
|
||||||
id,
|
id,
|
||||||
|
|
@ -177,6 +238,125 @@ pub async fn delete_cipher(
|
||||||
Ok(Json(()))
|
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<Arc<Env>>,
|
||||||
|
Json(payload): Json<CipherIdsData>,
|
||||||
|
) -> Result<Json<()>, 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<Arc<Env>>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<Cipher>, 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<Cipher>,
|
||||||
|
pub object: String,
|
||||||
|
pub continuation_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore multiple ciphers (PUT /api/ciphers/restore)
|
||||||
|
#[worker::send]
|
||||||
|
pub async fn restore_ciphers_bulk(
|
||||||
|
claims: Claims,
|
||||||
|
State(env): State<Arc<Env>>,
|
||||||
|
Json(payload): Json<CipherIdsData>,
|
||||||
|
) -> Result<Json<BulkRestoreResponse>, 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<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?;
|
||||||
|
|
||||||
|
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
|
/// Handler for POST /api/ciphers
|
||||||
/// Accepts flat JSON structure (camelCase) as sent by Bitwarden clients
|
/// Accepts flat JSON structure (camelCase) as sent by Bitwarden clients
|
||||||
/// when creating a cipher without collection assignments.
|
/// when creating a cipher without collection assignments.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{get, post, put, delete},
|
routing::{delete, get, post, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use worker::Env;
|
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 {
|
pub fn api_router(env: Env) -> Router {
|
||||||
let app_state = Arc::new(env);
|
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/create", post(ciphers::create_cipher))
|
||||||
.route("/api/ciphers/import", post(import::import_data))
|
.route("/api/ciphers/import", post(import::import_data))
|
||||||
.route("/api/ciphers/{id}", put(ciphers::update_cipher))
|
.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
|
// Folders CRUD
|
||||||
.route("/api/folders", post(folders::create_folder))
|
.route("/api/folders", post(folders::create_folder))
|
||||||
.route("/api/folders/{id}", put(folders::update_folder))
|
.route("/api/folders/{id}", put(folders::update_folder))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue