diff --git a/src/handlers/attachments.rs b/src/handlers/attachments.rs index 495bafd..ea7559e 100644 --- a/src/handlers/attachments.rs +++ b/src/handlers/attachments.rs @@ -416,63 +416,6 @@ pub async fn hydrate_cipher_attachments( Ok(()) } -/// 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() { - cipher.attachments = None; - } - return Ok(()); - } - - 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 map = load_attachment_map_json(db, body, path).await?; - apply_attachment_map(ciphers, map); - - Ok(()) -} - -/// Batch hydrate attachments for all ciphers belonging to a user without serializing ids. -pub async fn hydrate_ciphers_attachments_for_user( - db: &D1Database, - env: &Env, - ciphers: &mut [Cipher], - user_id: &str, -) -> Result<(), AppError> { - if ciphers.is_empty() { - return Ok(()); - } - - if !attachments_enabled(env) { - for cipher in ciphers.iter_mut() { - cipher.attachments = None; - } - return Ok(()); - } - - let map = load_attachment_map_for_user(db, user_id).await?; - apply_attachment_map(ciphers, map); - - Ok(()) -} - pub(crate) fn attachments_enabled(env: &Env) -> bool { env.bucket(ATTACHMENTS_BUCKET).is_ok() } @@ -647,26 +590,6 @@ async fn load_attachment_map_json( Ok(build_attachment_map(attachments)) } -async fn load_attachment_map_for_user( - db: &D1Database, - user_id: &str, -) -> Result>, AppError> { - let attachments: Vec = db - .prepare( - "SELECT a.* 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(build_attachment_map(attachments)) -} - fn build_attachment_map( attachments: Vec, ) -> HashMap> { @@ -682,16 +605,6 @@ fn build_attachment_map( map } -fn apply_attachment_map(ciphers: &mut [Cipher], mut map: HashMap>) { - for cipher in ciphers.iter_mut() { - if let Some(list) = map.remove(&cipher.id) { - if !list.is_empty() { - cipher.attachments = Some(list); - } - } - } -} - async fn upload_to_r2( bucket: &Bucket, key: &str, diff --git a/src/handlers/ciphers.rs b/src/handlers/ciphers.rs index e7e4c66..7148f2d 100644 --- a/src/handlers/ciphers.rs +++ b/src/handlers/ciphers.rs @@ -1,22 +1,34 @@ +use axum::extract::Path; +use axum::http::header; +use axum::response::{IntoResponse, Response}; 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, Env}; +use worker::{query, wasm_bindgen::JsValue, Env}; use crate::auth::Claims; use crate::db; use crate::error::AppError; use crate::handlers::attachments; use crate::models::cipher::{ - Cipher, CipherDBModel, CipherData, CipherListResponse, CipherRequestData, CreateCipherRequest, - PartialCipherData, + Cipher, CipherDBModel, CipherData, CipherRequestData, CreateCipherRequest, PartialCipherData, }; use crate::models::user::{PasswordOrOtpData, User}; use crate::BaseUrl; -use axum::extract::Path; + +/// A wrapper for raw JSON strings that implements IntoResponse. +/// Use this to return pre-built JSON without re-parsing/re-serializing. +pub struct RawJson(pub String); + +impl IntoResponse for RawJson { + fn into_response(self) -> Response { + ([(header::CONTENT_TYPE, "application/json")], self.0).into_response() + } +} /// Helper to fetch a cipher by id for a user or return NotFound. async fn fetch_cipher_for_user( @@ -217,35 +229,25 @@ pub async fn update_cipher( pub async fn list_ciphers( claims: Claims, State(env): State>, -) -> Result, AppError> { +) -> Result { let db = db::get_db(&env)?; - - let ciphers_db: Vec = db - .prepare( - "SELECT * FROM ciphers - WHERE user_id = ?1 AND deleted_at IS NULL - ORDER BY updated_at DESC", - ) - .bind(&[claims.sub.clone().into()])? - .all() - .await? - .results()?; - - let mut ciphers: Vec = ciphers_db.into_iter().map(|c| c.into()).collect(); - - attachments::hydrate_ciphers_attachments_for_user( + let include_attachments = attachments::attachments_enabled(env.as_ref()); + let ciphers_json = fetch_cipher_json_array_raw( &db, - env.as_ref(), - &mut ciphers, - &claims.sub, + include_attachments, + "WHERE c.user_id = ?1 AND c.deleted_at IS NULL", + &[claims.sub.clone().into()], + "ORDER BY c.updated_at DESC", ) .await?; - Ok(Json(CipherListResponse { - data: ciphers, - object: "list".to_string(), - continuation_token: None, - })) + // Build response JSON via string concatenation (no parsing!) + let response = format!( + r#"{{"data":{},"object":"list","continuationToken":null}}"#, + ciphers_json + ); + + Ok(RawJson(response)) } /// GET /api/ciphers/{id} @@ -504,15 +506,6 @@ pub async fn restore_cipher( Ok(Json(cipher)) } -/// Response for bulk restore operation -#[derive(Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BulkRestoreResponse { - pub data: Vec, - pub object: String, - pub continuation_token: Option, -} - /// Restore multiple ciphers (PUT /api/ciphers/restore) /// Accepts raw JSON body and uses json_each with path to extract ids directly. /// Expected JSON: {"ids": ["cipher_id1", "cipher_id2", ...]} @@ -521,7 +514,7 @@ pub async fn restore_ciphers_bulk( claims: Claims, State(env): State>, body: String, -) -> Result, AppError> { +) -> Result { let db = db::get_db(&env)?; let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); @@ -538,35 +531,25 @@ pub async fn restore_ciphers_bulk( .await .map_err(db::map_d1_json_error)?; - let mut restored_ciphers: Vec = db - .prepare( - "SELECT * FROM ciphers WHERE user_id = ?1 AND id IN (SELECT value FROM json_each(?2, '$.ids'))", - ) - .bind(&[claims.sub.clone().into(), body.clone().into()])? - .all() - .await - .map_err(db::map_d1_json_error)? - .results::()? - .into_iter() - .map(|cipher| cipher.into()) - .collect(); - - attachments::hydrate_ciphers_attachments( + let include_attachments = attachments::attachments_enabled(env.as_ref()); + let ciphers_json = fetch_cipher_json_array_raw( &db, - env.as_ref(), - &mut restored_ciphers, - Some(&body), - Some("$.ids"), + include_attachments, + "WHERE c.user_id = ?1 AND c.id IN (SELECT value FROM json_each(?2, '$.ids'))", + &[claims.sub.clone().into(), body.clone().into()], + "", ) .await?; db::touch_user_updated_at(&db, &claims.sub).await?; - Ok(Json(BulkRestoreResponse { - data: restored_ciphers, - object: "list".to_string(), - continuation_token: None, - })) + // Build response JSON via string concatenation (no parsing!) + let response = format!( + r#"{{"data":{},"object":"list","continuationToken":null}}"#, + ciphers_json + ); + + Ok(RawJson(response)) } /// Handler for POST /api/ciphers @@ -745,3 +728,115 @@ pub async fn purge_vault( Ok(Json(())) } + +#[derive(Deserialize)] +struct CipherJsonArrayRow { + ciphers_json: String, +} + +/// Build the SQL expression for a single cipher as JSON. +fn cipher_json_expr(attachments_enabled: bool) -> String { + let attachments_expr = if attachments_enabled { + " + ( + SELECT CASE WHEN COUNT(1)=0 THEN NULL ELSE json_group_array( + json_object( + 'id', a.id, + 'url', NULL, + 'fileName', a.file_name, + 'size', CAST(a.file_size AS TEXT), + 'sizeName', + CASE + WHEN a.file_size < 1024 THEN printf('%d B', a.file_size) + WHEN a.file_size < 1048576 THEN printf('%.1f KB', a.file_size / 1024.0) + WHEN a.file_size < 1073741824 THEN printf('%.1f MB', a.file_size / 1048576.0) + WHEN a.file_size < 1099511627776 THEN printf('%.1f GB', a.file_size / 1073741824.0) + ELSE printf('%.1f TB', a.file_size / 1099511627776.0) + END, + 'key', a.akey, + 'object', 'attachment' + ) + ) END + FROM attachments a + WHERE a.cipher_id = c.id + ) + " + } else { + "NULL" + }; + + format!( + "json_object( + 'object', 'cipherDetails', + 'id', c.id, + 'userId', c.user_id, + 'organizationId', c.organization_id, + 'folderId', c.folder_id, + 'type', c.type, + 'favorite', CASE WHEN c.favorite THEN json('true') ELSE json('false') END, + 'edit', json('true'), + 'viewPassword', json('true'), + 'permissions', json_object('delete', json('true'), 'restore', json('true')), + 'organizationUseTotp', json('false'), + 'collectionIds', NULL, + 'revisionDate', c.updated_at, + 'creationDate', c.created_at, + 'deletedDate', c.deleted_at, + 'attachments', {attachments_expr}, + 'name', json_extract(c.data, '$.name'), + 'notes', json_extract(c.data, '$.notes'), + 'fields', json_extract(c.data, '$.fields'), + 'passwordHistory', json_extract(c.data, '$.passwordHistory'), + 'reprompt', COALESCE(json_extract(c.data, '$.reprompt'), 0), + 'login', CASE WHEN c.type = 1 THEN json_extract(c.data, '$.login') ELSE NULL END, + 'secureNote', CASE WHEN c.type = 2 THEN json_extract(c.data, '$.secureNote') ELSE NULL END, + 'card', CASE WHEN c.type = 3 THEN json_extract(c.data, '$.card') ELSE NULL END, + 'identity', CASE WHEN c.type = 4 THEN json_extract(c.data, '$.identity') ELSE NULL END, + 'sshKey', CASE WHEN c.type = 5 THEN json_extract(c.data, '$.sshKey') ELSE NULL END + )", + attachments_expr = attachments_expr, + ) +} + +/// Build SQL that returns ciphers as a JSON array string (using json_group_array). +fn cipher_json_array_sql( + attachments_enabled: bool, + where_clause: &str, + order_clause: &str, +) -> String { + let cipher_expr = cipher_json_expr(attachments_enabled); + // Use a subquery to ensure ORDER BY is applied before json_group_array + format!( + "SELECT COALESCE(json_group_array(json(sub.cipher_json)), '[]') AS ciphers_json + FROM ( + SELECT {cipher_expr} AS cipher_json + FROM ciphers c + {where_clause} + {order_clause} + ) sub", + cipher_expr = cipher_expr, + where_clause = where_clause, + order_clause = order_clause, + ) +} + +/// Execute a cipher JSON projection query and return the raw JSON array string. +/// This avoids JSON parsing in Rust, significantly reducing CPU time. +pub(crate) async fn fetch_cipher_json_array_raw( + db: &worker::D1Database, + attachments_enabled: bool, + where_clause: &str, + params: &[JsValue], + order_clause: &str, +) -> Result { + let sql = cipher_json_array_sql(attachments_enabled, where_clause, order_clause); + + let row: Option = db + .prepare(&sql) + .bind(params)? + .first(None) + .await + .map_err(db::map_d1_json_error)?; + + Ok(row.map(|r| r.ciphers_json).unwrap_or_else(|| "[]".to_string())) +} diff --git a/src/handlers/sync.rs b/src/handlers/sync.rs index a0771f3..e1aa6a3 100644 --- a/src/handlers/sync.rs +++ b/src/handlers/sync.rs @@ -1,5 +1,4 @@ -use axum::{extract::State, Json}; -use serde_json::Value; +use axum::extract::State; use std::sync::Arc; use worker::Env; @@ -7,20 +6,21 @@ use crate::{ auth::Claims, db, error::AppError, - handlers::attachments, + handlers::{attachments, ciphers}, models::{ - cipher::{Cipher, CipherDBModel}, folder::{Folder, FolderResponse}, - sync::{Profile, SyncResponse}, + sync::Profile, user::User, }, }; +use ciphers::RawJson; + #[worker::send] pub async fn get_sync_data( claims: Claims, State(env): State>, -) -> Result, AppError> { +) -> Result { let user_id = claims.sub; let db = db::get_db(&env)?; @@ -42,44 +42,27 @@ pub async fn get_sync_data( let folders: Vec = folders_db.into_iter().map(|f| f.into()).collect(); - // Fetch ciphers - let ciphers: Vec = db - .prepare("SELECT * FROM ciphers WHERE user_id = ?1") - .bind(&[user_id.clone().into()])? - .all() - .await? - .results()?; - - let ciphers = ciphers - .into_iter() - .filter_map( - |cipher| match serde_json::from_value::(cipher.clone()) { - Ok(cipher) => Some(cipher), - Err(err) => { - log::warn!("Cannot parse {err:?} {cipher:?}"); - None - } - }, - ) - .map(|cipher| cipher.into()) - .collect::>(); - - let mut ciphers = ciphers; - attachments::hydrate_ciphers_attachments_for_user(&db, env.as_ref(), &mut ciphers, &user_id) - .await?; + // Fetch ciphers as raw JSON array string (no parsing in Rust!) + let include_attachments = attachments::attachments_enabled(env.as_ref()); + let ciphers_json = ciphers::fetch_cipher_json_array_raw( + &db, + include_attachments, + "WHERE c.user_id = ?1", + &[user_id.clone().into()], + "", + ) + .await?; + // Serialize profile and folders (small data, acceptable CPU cost) let profile = Profile::from_user(user)?; + let profile_json = serde_json::to_string(&profile).map_err(|_| AppError::Internal)?; + let folders_json = serde_json::to_string(&folders).map_err(|_| AppError::Internal)?; - let response = SyncResponse { - profile, - folders, - collections: Vec::new(), - policies: Vec::new(), - ciphers, - domains: serde_json::Value::Null, // Ignored for basic implementation - sends: Vec::new(), - object: "sync".to_string(), - }; + // Build response JSON via string concatenation (ciphers already raw JSON) + let response = format!( + r#"{{"profile":{},"folders":{},"collections":[],"policies":[],"ciphers":{},"domains":null,"sends":[],"object":"sync"}}"#, + profile_json, folders_json, ciphers_json + ); - Ok(Json(response)) + Ok(RawJson(response)) } diff --git a/src/models/cipher.rs b/src/models/cipher.rs index 361c0b7..796f674 100644 --- a/src/models/cipher.rs +++ b/src/models/cipher.rs @@ -323,10 +323,12 @@ pub struct CreateCipherRequest { } /// Response for listing ciphers (GET /api/ciphers) +/// Now we don't use this struct, we use RawJson instead. But we keep it here for reference. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] +#[allow(dead_code)] pub struct CipherListResponse { - pub data: Vec, + pub data: Vec, pub object: String, pub continuation_token: Option, } diff --git a/src/models/sync.rs b/src/models/sync.rs index b22c5da..a33703d 100644 --- a/src/models/sync.rs +++ b/src/models/sync.rs @@ -1,4 +1,4 @@ -use super::{cipher::Cipher, folder::FolderResponse, user::User}; +use super::{folder::FolderResponse, user::User}; use crate::error::AppError; use chrono::SecondsFormat; use serde::Serialize; @@ -55,8 +55,11 @@ impl Profile { } } +/// Response for sync (GET /api/sync) +/// Now we don't use this struct, we use RawJson instead. But we keep it here for reference. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] +#[allow(dead_code)] pub struct SyncResponse { pub profile: Profile, pub folders: Vec, @@ -64,7 +67,7 @@ pub struct SyncResponse { pub collections: Vec, #[serde(default)] pub policies: Vec, - pub ciphers: Vec, + pub ciphers: Vec, pub domains: Value, #[serde(default)] pub sends: Vec,