fix: add R2 cleanup to every delete route

This commit is contained in:
qaz741wsd856 2025-12-07 16:24:48 +00:00
parent d6ef6113fd
commit 4f61077ca9
4 changed files with 148 additions and 10 deletions

View file

@ -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)?

View file

@ -81,6 +81,12 @@ pub struct AttachmentDownloadQuery {
pub token: Option<String>,
}
#[derive(Deserialize)]
struct AttachmentKeyRow {
cipher_id: String,
id: String,
}
impl NumberOrString {
pub fn into_i64(self) -> Result<i64, AppError> {
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<Bucket, AppError> {
pub(crate) fn require_bucket(env: &Env) -> Result<Bucket, AppError> {
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<AttachmentKeyRow>) -> Vec<String> {
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<Vec<String>, 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<worker::wasm_bindgen::JsValue> = 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<AttachmentKeyRow> = db
.prepare(&sql)
.bind(&params)?
.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<Vec<String>, AppError> {
let rows: Vec<AttachmentKeyRow> = 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<Vec<String>, AppError> {
let rows: Vec<AttachmentKeyRow> = 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}")
}

View file

@ -416,6 +416,12 @@ pub async fn hard_delete_cipher(
) -> Result<Json<()>, 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<Json<()>, AppError> {
let db = db::get_db(&env)?;
let batch_size = get_batch_size(&env);
let mut statements: Vec<D1PreparedStatement> = 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<D1PreparedStatement> = 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)?

View file

@ -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<u32, worker::Error> {
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,