refactor: streamline cipher and sync handlers to utilize raw JSON responses from sqlite json query
This commit is contained in:
parent
96749af57f
commit
89137fb150
5 changed files with 189 additions and 193 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Reference in a new issue