refactor: streamline cipher and sync handlers to utilize raw JSON responses from sqlite json query

This commit is contained in:
qaz741wsd856 2025-12-10 12:34:12 +00:00
parent 96749af57f
commit 89137fb150
5 changed files with 189 additions and 193 deletions

View file

@ -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<HashMap<String, Vec<AttachmentResponse>>, AppError> {
let attachments: Vec<AttachmentDB> = 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<AttachmentDB>,
) -> HashMap<String, Vec<AttachmentResponse>> {
@ -682,16 +605,6 @@ fn build_attachment_map(
map
}
fn apply_attachment_map(ciphers: &mut [Cipher], mut map: HashMap<String, Vec<AttachmentResponse>>) {
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,

View file

@ -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<Arc<Env>>,
) -> Result<Json<CipherListResponse>, AppError> {
) -> Result<RawJson, AppError> {
let db = db::get_db(&env)?;
let ciphers_db: Vec<CipherDBModel> = 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<Cipher> = 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<Cipher>,
pub object: String,
pub continuation_token: Option<String>,
}
/// 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<Arc<Env>>,
body: String,
) -> Result<Json<BulkRestoreResponse>, AppError> {
) -> Result<RawJson, AppError> {
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<Cipher> = 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::<crate::models::cipher::CipherDBModel>()?
.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<String, AppError> {
let sql = cipher_json_array_sql(attachments_enabled, where_clause, order_clause);
let row: Option<CipherJsonArrayRow> = 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()))
}

View file

@ -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<Arc<Env>>,
) -> Result<Json<SyncResponse>, AppError> {
) -> Result<RawJson, AppError> {
let user_id = claims.sub;
let db = db::get_db(&env)?;
@ -42,44 +42,27 @@ pub async fn get_sync_data(
let folders: Vec<FolderResponse> = folders_db.into_iter().map(|f| f.into()).collect();
// Fetch ciphers
let ciphers: Vec<Value> = 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::<CipherDBModel>(cipher.clone()) {
Ok(cipher) => Some(cipher),
Err(err) => {
log::warn!("Cannot parse {err:?} {cipher:?}");
None
}
},
)
.map(|cipher| cipher.into())
.collect::<Vec<Cipher>>();
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))
}

View file

@ -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<Cipher>,
pub data: Vec<Value>,
pub object: String,
pub continuation_token: Option<String>,
}

View file

@ -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<FolderResponse>,
@ -64,7 +67,7 @@ pub struct SyncResponse {
pub collections: Vec<Value>,
#[serde(default)]
pub policies: Vec<Value>,
pub ciphers: Vec<Cipher>,
pub ciphers: Vec<Value>,
pub domains: Value,
#[serde(default)]
pub sends: Vec<Value>,