diff --git a/src/handlers/ciphers.rs b/src/handlers/ciphers.rs index 9eaa07f..175b0de 100644 --- a/src/handlers/ciphers.rs +++ b/src/handlers/ciphers.rs @@ -10,7 +10,7 @@ use worker::{query, D1PreparedStatement, Env}; use crate::auth::Claims; use crate::db; use crate::error::AppError; -use crate::models::cipher::{Cipher, CipherData, CipherRequestData, CreateCipherRequest}; +use crate::models::cipher::{Cipher, CipherData, CipherRequestData, CreateCipherRequest, MoveCipherData}; use crate::models::user::{PasswordOrOtpData, User}; use axum::extract::Path; @@ -443,6 +443,65 @@ pub async fn create_cipher_simple( Ok(Json(cipher)) } +/// Move selected ciphers to a folder (POST/PUT /api/ciphers/move) +#[worker::send] +pub async fn move_cipher_selected( + claims: Claims, + State(env): State>, + Json(payload): Json, +) -> Result, AppError> { + let db = db::get_db(&env)?; + let user_id = &claims.sub; + let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + + // Validate folder exists and belongs to user (if folder_id is provided) + if let Some(ref folder_id) = payload.folder_id { + let folder_exists: Option = db + .prepare("SELECT id FROM folders WHERE id = ?1 AND user_id = ?2") + .bind(&[folder_id.clone().into(), user_id.clone().into()])? + .first(None) + .await?; + + if folder_exists.is_none() { + return Err(AppError::BadRequest( + "Invalid folder: Folder does not exist or belongs to another user".to_string(), + )); + } + } + + if payload.ids.is_empty() { + return Ok(Json(())); + } + + // Use json_each() to update all matching ciphers in a single query + // This avoids N+1 query problem by updating all ciphers at once + let ids_json = serde_json::to_string(&payload.ids).map_err(|_| AppError::Internal)?; + + // Update folder_id for all ciphers that belong to the user and are in the ids list + // Using json_each() allows us to update all matching ciphers in a single query + db.prepare( + "UPDATE ciphers SET folder_id = ?1, updated_at = ?2 + WHERE user_id = ?3 AND id IN (SELECT value FROM json_each(?4))", + ) + .bind(&[ + payload + .folder_id + .clone() + .map(|s| s.into()) + .unwrap_or(worker::wasm_bindgen::JsValue::NULL), + now.into(), + user_id.clone().into(), + ids_json.into(), + ])? + .run() + .await?; + + // Update user's revision date + db::touch_user_updated_at(&db, user_id).await?; + + Ok(Json(())) +} + /// Purge the user's vault - delete all ciphers and folders /// POST /api/ciphers/purge /// diff --git a/src/models/cipher.rs b/src/models/cipher.rs index 0b7dd2d..e723665 100644 --- a/src/models/cipher.rs +++ b/src/models/cipher.rs @@ -312,3 +312,11 @@ pub struct CreateCipherRequest { #[serde(alias = "CollectionIds")] pub collection_ids: Vec, } + +/// Request body for moving ciphers to a folder +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MoveCipherData { + pub folder_id: Option, + pub ids: Vec, +} \ No newline at end of file diff --git a/src/router.rs b/src/router.rs index fedcfb9..8406be7 100644 --- a/src/router.rs +++ b/src/router.rs @@ -67,6 +67,9 @@ pub fn api_router(env: Env) -> Router { .route("/api/ciphers/{id}/restore", put(ciphers::restore_cipher)) // Cipher bulk restore .route("/api/ciphers/restore", put(ciphers::restore_ciphers_bulk)) + // Move ciphers to folder + .route("/api/ciphers/move", post(ciphers::move_cipher_selected)) + .route("/api/ciphers/move", put(ciphers::move_cipher_selected)) // Purge vault - delete all ciphers and folders (requires password verification) .route("/api/ciphers/purge", post(ciphers::purge_vault)) // Folders CRUD