refactor: directly pass json body to d1 api in attachment handling to avoid parsing

This commit is contained in:
qaz741wsd856 2025-12-09 16:18:13 +00:00
parent eb6a1d8a63
commit b1ea9183bc
3 changed files with 76 additions and 81 deletions

View file

@ -406,7 +406,8 @@ pub async fn hydrate_cipher_attachments(
return Ok(());
}
let mut map = load_attachment_map(db, &[cipher.id.clone()]).await?;
let ids_json = serde_json::to_string(&[&cipher.id]).map_err(|_| AppError::Internal)?;
let mut map = load_attachment_map_json(db, &ids_json, "$").await?;
if let Some(list) = map.remove(&cipher.id) {
if !list.is_empty() {
cipher.attachments = Some(list);
@ -415,11 +416,15 @@ pub async fn hydrate_cipher_attachments(
Ok(())
}
/// Batch attach attachments to multiple Ciphers
/// Batch attach attachments to multiple Ciphers.
/// - If `json_body` and `ids_path` are provided, use them directly (e.g. body + "$.ids").
/// - Otherwise extract ids from ciphers and use "$" as path.
pub async fn hydrate_ciphers_attachments(
db: &D1Database,
env: &Env,
ciphers: &mut [Cipher],
json_body: Option<&str>,
ids_path: Option<&str>,
) -> Result<(), AppError> {
if !attachments_enabled(env) {
for cipher in ciphers.iter_mut() {
@ -428,8 +433,17 @@ pub async fn hydrate_ciphers_attachments(
return Ok(());
}
let ids: Vec<String> = ciphers.iter().map(|c| c.id.clone()).collect();
let mut map = load_attachment_map(db, &ids).await?;
let owned_json: String;
let (body, path) = match (json_body, ids_path) {
(Some(b), Some(p)) => (b, p),
_ => {
let ids: Vec<&str> = ciphers.iter().map(|c| c.id.as_str()).collect();
owned_json = serde_json::to_string(&ids).map_err(|_| AppError::Internal)?;
(owned_json.as_str(), "$")
}
};
let mut map = load_attachment_map_json(db, body, path).await?;
for cipher in ciphers.iter_mut() {
if let Some(list) = map.remove(&cipher.id) {
@ -473,22 +487,21 @@ fn map_rows_to_keys(rows: Vec<AttachmentKeyRow>) -> Vec<String> {
.collect()
}
pub(crate) async fn list_attachment_keys_for_cipher_ids(
/// List attachment keys for given cipher IDs.
/// - `json_body`: JSON text containing the ids array
/// - `ids_path`: path to ids array within json_body (e.g. "$.ids" or "$" if top-level)
pub(crate) async fn list_attachment_keys_for_cipher_ids_json(
db: &D1Database,
cipher_ids: &[String],
json_body: &str,
ids_path: &str,
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()];
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, ?2))".to_string();
let mut params: Vec<worker::wasm_bindgen::JsValue> =
vec![json_body.to_owned().into(), ids_path.to_owned().into()];
if let Some(uid) = user_id {
sql.push_str(" AND c.user_id = ?2");
sql.push_str(" AND c.user_id = ?3");
params.push(uid.into());
}
@ -598,19 +611,16 @@ async fn fetch_attachment(db: &D1Database, attachment_id: &str) -> Result<Attach
.ok_or_else(|| AppError::NotFound("Attachment not found".to_string()))
}
async fn load_attachment_map(
async fn load_attachment_map_json(
db: &D1Database,
cipher_ids: &[String],
json_body: &str,
ids_path: &str,
) -> Result<HashMap<String, Vec<AttachmentResponse>>, AppError> {
if cipher_ids.is_empty() {
return Ok(HashMap::new());
}
let ids_json = serde_json::to_string(cipher_ids).map_err(|_| AppError::Internal)?;
let attachments: Vec<AttachmentDB> = db
.prepare("SELECT * FROM attachments WHERE cipher_id IN (SELECT value FROM json_each(?1))")
.bind(&[ids_json.into()])?
.prepare(
"SELECT * FROM attachments WHERE cipher_id IN (SELECT value FROM json_each(?1, ?2))",
)
.bind(&[json_body.to_owned().into(), ids_path.to_owned().into()])?
.all()
.await
.map_err(|_| AppError::Database)?

View file

@ -1,12 +1,10 @@
use super::get_batch_size;
use axum::{extract::State, Extension, Json};
use chrono::{DateTime, Utc};
use log; // Used for warning logs on parse failures
use serde::Deserialize;
use serde_json::Value;
use std::sync::Arc;
use uuid::Uuid;
use worker::{query, D1PreparedStatement, Env};
use worker::{query, Env};
use crate::auth::Claims;
use crate::db;
@ -235,7 +233,7 @@ pub async fn list_ciphers(
let mut ciphers: Vec<Cipher> = ciphers_db.into_iter().map(|c| c.into()).collect();
attachments::hydrate_ciphers_attachments(&db, env.as_ref(), &mut ciphers).await?;
attachments::hydrate_ciphers_attachments(&db, env.as_ref(), &mut ciphers, None, None).await?;
Ok(Json(CipherListResponse {
data: ciphers,
@ -324,13 +322,6 @@ pub async fn update_cipher_partial(
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]
@ -359,28 +350,22 @@ pub async fn soft_delete_cipher(
}
/// Soft delete multiple ciphers (PUT /api/ciphers/delete)
/// Accepts raw JSON body and uses json_each with path to extract ids directly.
#[worker::send]
pub async fn soft_delete_ciphers_bulk(
claims: Claims,
State(env): State<Arc<Env>>,
Json(payload): Json<CipherIdsData>,
body: String,
) -> Result<Json<()>, AppError> {
let db = db::get_db(&env)?;
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let ids = payload.ids;
if ids.is_empty() {
return Ok(Json(()));
}
let ids_json = serde_json::to_string(&ids).map_err(|_| AppError::Internal)?;
query!(
&db,
"UPDATE ciphers SET deleted_at = ?1, updated_at = ?1 WHERE user_id = ?2 AND id IN (SELECT value FROM json_each(?3))",
"UPDATE ciphers SET deleted_at = ?1, updated_at = ?1 WHERE user_id = ?2 AND id IN (SELECT value FROM json_each(?3, '$.ids'))",
now,
claims.sub,
ids_json
body
)
.map_err(|_| AppError::Database)?
.run()
@ -403,9 +388,14 @@ pub async fn hard_delete_cipher(
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?;
let id_json = serde_json::to_string(&[&id]).map_err(|_| AppError::Internal)?;
let keys = attachments::list_attachment_keys_for_cipher_ids_json(
&db,
&id_json,
"$",
Some(&claims.sub),
)
.await?;
attachments::delete_r2_objects(&bucket, &keys).await?;
}
@ -425,33 +415,32 @@ pub async fn hard_delete_cipher(
}
/// Hard delete multiple ciphers (DELETE /api/ciphers or POST /api/ciphers/delete)
/// Accepts raw JSON body and uses json_each with path to extract ids directly.
#[worker::send]
pub async fn hard_delete_ciphers_bulk(
claims: Claims,
State(env): State<Arc<Env>>,
Json(payload): Json<CipherIdsData>,
body: String,
) -> Result<Json<()>, AppError> {
let db = db::get_db(&env)?;
let ids = payload.ids;
if ids.is_empty() {
return Ok(Json(()));
}
let ids_json = serde_json::to_string(&ids).map_err(|_| AppError::Internal)?;
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?;
let keys = attachments::list_attachment_keys_for_cipher_ids_json(
&db,
&body,
"$.ids",
Some(&claims.sub),
)
.await?;
attachments::delete_r2_objects(&bucket, &keys).await?;
}
query!(
&db,
"DELETE FROM ciphers WHERE user_id = ?1 AND id IN (SELECT value FROM json_each(?2))",
"DELETE FROM ciphers WHERE user_id = ?1 AND id IN (SELECT value FROM json_each(?2, '$.ids'))",
claims.sub,
ids_json
body
)
.map_err(|_| AppError::Database)?
.run()
@ -515,34 +504,23 @@ pub struct BulkRestoreResponse {
}
/// Restore multiple ciphers (PUT /api/ciphers/restore)
/// Accepts raw JSON body and uses json_each with path to extract ids directly.
#[worker::send]
pub async fn restore_ciphers_bulk(
claims: Claims,
State(env): State<Arc<Env>>,
Json(payload): Json<CipherIdsData>,
body: String,
) -> 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 ids = payload.ids;
if ids.is_empty() {
return Ok(Json(BulkRestoreResponse {
data: vec![],
object: "list".to_string(),
continuation_token: None,
}));
}
// Precompute JSON array for ids, used by both UPDATE and SELECT
let ids_json = serde_json::to_string(&ids).map_err(|_| AppError::Internal)?;
// Single bulk UPDATE using json_each()
// Single bulk UPDATE using json_each() with path
query!(
&db,
"UPDATE ciphers SET deleted_at = NULL, updated_at = ?1 WHERE user_id = ?2 AND id IN (SELECT value FROM json_each(?3))",
"UPDATE ciphers SET deleted_at = NULL, updated_at = ?1 WHERE user_id = ?2 AND id IN (SELECT value FROM json_each(?3, '$.ids'))",
now,
claims.sub,
ids_json.clone()
body
)
.map_err(|_| AppError::Database)?
.run()
@ -550,9 +528,9 @@ pub async fn restore_ciphers_bulk(
let mut restored_ciphers: Vec<Cipher> = db
.prepare(
"SELECT * FROM ciphers WHERE user_id = ?1 AND id IN (SELECT value FROM json_each(?2))",
"SELECT * FROM ciphers WHERE user_id = ?1 AND id IN (SELECT value FROM json_each(?2, '$.ids'))",
)
.bind(&[claims.sub.clone().into(), ids_json.into()])?
.bind(&[claims.sub.clone().into(), body.clone().into()])?
.all()
.await?
.results::<crate::models::cipher::CipherDBModel>()?
@ -560,7 +538,14 @@ pub async fn restore_ciphers_bulk(
.map(|cipher| cipher.into())
.collect();
attachments::hydrate_ciphers_attachments(&db, env.as_ref(), &mut restored_ciphers).await?;
attachments::hydrate_ciphers_attachments(
&db,
env.as_ref(),
&mut restored_ciphers,
Some(&body),
Some("$.ids"),
)
.await?;
db::touch_user_updated_at(&db, &claims.sub).await?;

View file

@ -65,7 +65,7 @@ pub async fn get_sync_data(
.collect::<Vec<Cipher>>();
let mut ciphers = ciphers;
attachments::hydrate_ciphers_attachments(&db, env.as_ref(), &mut ciphers).await?;
attachments::hydrate_ciphers_attachments(&db, env.as_ref(), &mut ciphers, None, None).await?;
let profile = Profile::from_user(user)?;