fix: add R2 cleanup to every delete route
This commit is contained in:
parent
d6ef6113fd
commit
4f61077ca9
4 changed files with 148 additions and 10 deletions
|
|
@ -12,6 +12,7 @@ use crate::{
|
||||||
crypto::{generate_salt, hash_password_for_storage},
|
crypto::{generate_salt, hash_password_for_storage},
|
||||||
db,
|
db,
|
||||||
error::AppError,
|
error::AppError,
|
||||||
|
handlers::attachments,
|
||||||
models::{
|
models::{
|
||||||
cipher::CipherData,
|
cipher::CipherData,
|
||||||
sync::Profile,
|
sync::Profile,
|
||||||
|
|
@ -477,6 +478,12 @@ pub async fn delete_account(
|
||||||
return Err(AppError::Unauthorized("Invalid password".to_string()));
|
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
|
// Delete all user's ciphers
|
||||||
query!(&db, "DELETE FROM ciphers WHERE user_id = ?1", user_id)
|
query!(&db, "DELETE FROM ciphers WHERE user_id = ?1", user_id)
|
||||||
.map_err(|_| AppError::Database)?
|
.map_err(|_| AppError::Database)?
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,12 @@ pub struct AttachmentDownloadQuery {
|
||||||
pub token: Option<String>,
|
pub token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AttachmentKeyRow {
|
||||||
|
cipher_id: String,
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl NumberOrString {
|
impl NumberOrString {
|
||||||
pub fn into_i64(self) -> Result<i64, AppError> {
|
pub fn into_i64(self) -> Result<i64, AppError> {
|
||||||
match self {
|
match self {
|
||||||
|
|
@ -368,12 +374,7 @@ pub async fn delete_attachment(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete R2 object; ignore missing objects
|
// Delete R2 object; ignore missing objects
|
||||||
if let Err(err) = bucket.delete(attachment.r2_key()).await {
|
delete_r2_objects(&bucket, &[attachment.r2_key()]).await?;
|
||||||
let msg = err.to_string();
|
|
||||||
if !(msg.contains("NoSuchKey") || msg.contains("404") || msg.contains("NotFound")) {
|
|
||||||
return Err(AppError::Worker(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
query!(&db, "DELETE FROM attachments WHERE id = ?1", attachment.id)
|
query!(&db, "DELETE FROM attachments WHERE id = ?1", attachment.id)
|
||||||
.map_err(|_| AppError::Database)?
|
.map_err(|_| AppError::Database)?
|
||||||
|
|
@ -521,15 +522,111 @@ pub async fn hydrate_ciphers_attachments(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn attachments_enabled(env: &Env) -> bool {
|
pub(crate) fn attachments_enabled(env: &Env) -> bool {
|
||||||
env.bucket(ATTACHMENTS_BUCKET).is_ok()
|
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)
|
env.bucket(ATTACHMENTS_BUCKET)
|
||||||
.map_err(|_| AppError::BadRequest("Attachments are not enabled".to_string()))
|
.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(¶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<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 {
|
fn upload_url(cipher_id: &str, attachment_id: &str) -> String {
|
||||||
format!("/api/ciphers/{cipher_id}/attachment/{attachment_id}")
|
format!("/api/ciphers/{cipher_id}/attachment/{attachment_id}")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -416,6 +416,12 @@ pub async fn hard_delete_cipher(
|
||||||
) -> Result<Json<()>, AppError> {
|
) -> Result<Json<()>, AppError> {
|
||||||
let db = db::get_db(&env)?;
|
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!(
|
query!(
|
||||||
&db,
|
&db,
|
||||||
"DELETE FROM ciphers WHERE id = ?1 AND user_id = ?2",
|
"DELETE FROM ciphers WHERE id = ?1 AND user_id = ?2",
|
||||||
|
|
@ -440,9 +446,18 @@ pub async fn hard_delete_ciphers_bulk(
|
||||||
) -> Result<Json<()>, AppError> {
|
) -> Result<Json<()>, AppError> {
|
||||||
let db = db::get_db(&env)?;
|
let db = db::get_db(&env)?;
|
||||||
let batch_size = get_batch_size(&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!(
|
let stmt = query!(
|
||||||
&db,
|
&db,
|
||||||
"DELETE FROM ciphers WHERE id = ?1 AND user_id = ?2",
|
"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()));
|
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)
|
// Delete all user's ciphers (both active and soft-deleted)
|
||||||
query!(&db, "DELETE FROM ciphers WHERE user_id = ?1", user_id)
|
query!(&db, "DELETE FROM ciphers WHERE user_id = ?1", user_id)
|
||||||
.map_err(|_| AppError::Database)?
|
.map_err(|_| AppError::Database)?
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
//! retention period.
|
//! retention period.
|
||||||
|
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
|
use crate::handlers::attachments;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use worker::{query, D1Database, Env};
|
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);
|
let count = count_result.map(|r| r.count).unwrap_or(0);
|
||||||
|
|
||||||
if count > 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
|
// Delete the records
|
||||||
query!(
|
query!(
|
||||||
&db,
|
&db,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue