diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs index b55b9b8..697e144 100644 --- a/src/handlers/accounts.rs +++ b/src/handlers/accounts.rs @@ -12,6 +12,7 @@ use crate::{ crypto::{generate_salt, hash_password_for_storage}, db, error::AppError, + handlers::attachments, models::{ cipher::CipherData, sync::Profile, @@ -477,6 +478,12 @@ pub async fn delete_account( return Err(AppError::Unauthorized("Invalid password".to_string())); } + if attachments::attachments_enabled(env.as_ref()) { + let bucket = attachments::require_bucket(env.as_ref())?; + let keys = attachments::list_attachment_keys_for_user(&db, user_id).await?; + attachments::delete_r2_objects(&bucket, &keys).await?; + } + // Delete all user's ciphers query!(&db, "DELETE FROM ciphers WHERE user_id = ?1", user_id) .map_err(|_| AppError::Database)? diff --git a/src/handlers/attachments.rs b/src/handlers/attachments.rs index 46beb22..e57aa0d 100644 --- a/src/handlers/attachments.rs +++ b/src/handlers/attachments.rs @@ -81,6 +81,12 @@ pub struct AttachmentDownloadQuery { pub token: Option, } +#[derive(Deserialize)] +struct AttachmentKeyRow { + cipher_id: String, + id: String, +} + impl NumberOrString { pub fn into_i64(self) -> Result { match self { @@ -368,12 +374,7 @@ pub async fn delete_attachment( } // Delete R2 object; ignore missing objects - if let Err(err) = bucket.delete(attachment.r2_key()).await { - let msg = err.to_string(); - if !(msg.contains("NoSuchKey") || msg.contains("404") || msg.contains("NotFound")) { - return Err(AppError::Worker(err)); - } - } + delete_r2_objects(&bucket, &[attachment.r2_key()]).await?; query!(&db, "DELETE FROM attachments WHERE id = ?1", attachment.id) .map_err(|_| AppError::Database)? @@ -521,15 +522,111 @@ pub async fn hydrate_ciphers_attachments( Ok(()) } -fn attachments_enabled(env: &Env) -> bool { +pub(crate) fn attachments_enabled(env: &Env) -> bool { env.bucket(ATTACHMENTS_BUCKET).is_ok() } -fn require_bucket(env: &Env) -> Result { +pub(crate) fn require_bucket(env: &Env) -> Result { env.bucket(ATTACHMENTS_BUCKET) .map_err(|_| AppError::BadRequest("Attachments are not enabled".to_string())) } +fn is_not_found_error(err: &worker::Error) -> bool { + let msg = err.to_string(); + msg.contains("NoSuchKey") || msg.contains("404") || msg.contains("NotFound") +} + +pub(crate) async fn delete_r2_objects( + bucket: &Bucket, + keys: &[String], +) -> Result<(), AppError> { + for key in keys { + if let Err(err) = bucket.delete(key).await { + if !is_not_found_error(&err) { + return Err(AppError::Worker(err)); + } + } + } + Ok(()) +} + +fn map_rows_to_keys(rows: Vec) -> Vec { + rows.into_iter() + .map(|row| format!("{}/{}", row.cipher_id, row.id)) + .collect() +} + +pub(crate) async fn list_attachment_keys_for_cipher_ids( + db: &D1Database, + cipher_ids: &[String], + user_id: Option<&str>, +) -> Result, AppError> { + if cipher_ids.is_empty() { + return Ok(Vec::new()); + } + + let ids_json = serde_json::to_string(cipher_ids).map_err(|_| AppError::Internal)?; + + let mut sql = "SELECT a.cipher_id, a.id FROM attachments a JOIN ciphers c ON a.cipher_id = c.id WHERE c.id IN (SELECT value FROM json_each(?1))".to_string(); + let mut params: Vec = vec![ids_json.into()]; + + if let Some(uid) = user_id { + sql.push_str(" AND c.user_id = ?2"); + params.push(uid.into()); + } + + let rows: Vec = db + .prepare(&sql) + .bind(¶ms)? + .all() + .await + .map_err(|_| AppError::Database)? + .results() + .map_err(|_| AppError::Database)?; + + Ok(map_rows_to_keys(rows)) +} + +pub(crate) async fn list_attachment_keys_for_user( + db: &D1Database, + user_id: &str, +) -> Result, AppError> { + let rows: Vec = db + .prepare( + "SELECT a.cipher_id, a.id FROM attachments a \ + JOIN ciphers c ON a.cipher_id = c.id \ + WHERE c.user_id = ?1", + ) + .bind(&[user_id.into()])? + .all() + .await + .map_err(|_| AppError::Database)? + .results() + .map_err(|_| AppError::Database)?; + + Ok(map_rows_to_keys(rows)) +} + +pub(crate) async fn list_attachment_keys_for_soft_deleted_before( + db: &D1Database, + cutoff_exclusive: &str, +) -> Result, AppError> { + let rows: Vec = db + .prepare( + "SELECT a.cipher_id, a.id FROM attachments a \ + JOIN ciphers c ON a.cipher_id = c.id \ + WHERE c.deleted_at IS NOT NULL AND c.deleted_at < ?1", + ) + .bind(&[cutoff_exclusive.into()])? + .all() + .await + .map_err(|_| AppError::Database)? + .results() + .map_err(|_| AppError::Database)?; + + Ok(map_rows_to_keys(rows)) +} + fn upload_url(cipher_id: &str, attachment_id: &str) -> String { format!("/api/ciphers/{cipher_id}/attachment/{attachment_id}") } diff --git a/src/handlers/ciphers.rs b/src/handlers/ciphers.rs index 74c76db..f7d0485 100644 --- a/src/handlers/ciphers.rs +++ b/src/handlers/ciphers.rs @@ -416,6 +416,12 @@ pub async fn hard_delete_cipher( ) -> Result, AppError> { let db = db::get_db(&env)?; + if attachments::attachments_enabled(env.as_ref()) { + let bucket = attachments::require_bucket(env.as_ref())?; + let keys = attachments::list_attachment_keys_for_cipher_ids(&db, &[id.clone()], Some(&claims.sub)).await?; + attachments::delete_r2_objects(&bucket, &keys).await?; + } + query!( &db, "DELETE FROM ciphers WHERE id = ?1 AND user_id = ?2", @@ -440,9 +446,18 @@ pub async fn hard_delete_ciphers_bulk( ) -> Result, AppError> { let db = db::get_db(&env)?; let batch_size = get_batch_size(&env); - let mut statements: Vec = Vec::with_capacity(payload.ids.len()); + let ids = payload.ids; - for id in payload.ids { + if attachments::attachments_enabled(env.as_ref()) { + let bucket = attachments::require_bucket(env.as_ref())?; + let keys = + attachments::list_attachment_keys_for_cipher_ids(&db, &ids, Some(&claims.sub)).await?; + attachments::delete_r2_objects(&bucket, &keys).await?; + } + + let mut statements: Vec = Vec::with_capacity(ids.len()); + + for id in ids { let stmt = query!( &db, "DELETE FROM ciphers WHERE id = ?1 AND user_id = ?2", @@ -747,6 +762,12 @@ pub async fn purge_vault( return Err(AppError::Unauthorized("Invalid password".to_string())); } + if attachments::attachments_enabled(env.as_ref()) { + let bucket = attachments::require_bucket(env.as_ref())?; + let keys = attachments::list_attachment_keys_for_user(&db, user_id).await?; + attachments::delete_r2_objects(&bucket, &keys).await?; + } + // Delete all user's ciphers (both active and soft-deleted) query!(&db, "DELETE FROM ciphers WHERE user_id = ?1", user_id) .map_err(|_| AppError::Database)? diff --git a/src/handlers/purge.rs b/src/handlers/purge.rs index 6e7b59e..320fc4f 100644 --- a/src/handlers/purge.rs +++ b/src/handlers/purge.rs @@ -5,6 +5,7 @@ //! retention period. use chrono::{Duration, Utc}; +use crate::handlers::attachments; use std::collections::HashSet; use worker::{query, D1Database, Env}; @@ -78,6 +79,18 @@ pub async fn purge_deleted_ciphers(env: &Env) -> Result { let count = count_result.map(|r| r.count).unwrap_or(0); if count > 0 { + if attachments::attachments_enabled(env) { + let bucket = attachments::require_bucket(env) + .map_err(|e| worker::Error::RustError(e.to_string()))?; + let keys = attachments::list_attachment_keys_for_soft_deleted_before(&db, &cutoff_str) + .await + .map_err(|e| worker::Error::RustError(e.to_string()))?; + + attachments::delete_r2_objects(&bucket, &keys) + .await + .map_err(|e| worker::Error::RustError(e.to_string()))?; + } + // Delete the records query!( &db,