156 lines
5.6 KiB
Rust
156 lines
5.6 KiB
Rust
use axum::extract::{Query, State};
|
|
use std::sync::Arc;
|
|
use worker::Env;
|
|
|
|
use crate::{
|
|
auth::Claims,
|
|
db,
|
|
error::AppError,
|
|
handlers::{
|
|
attachments, ciphers, ciphers_default_row_query, domains, sync_response_prealloc_bytes,
|
|
two_factor_enabled,
|
|
},
|
|
models::{
|
|
folder::{Folder, FolderResponse},
|
|
sync::Profile,
|
|
user::User,
|
|
},
|
|
};
|
|
|
|
use ciphers::RawJson;
|
|
use serde::Deserialize;
|
|
use serde_json::{json, Value};
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct SyncQuery {
|
|
/// If true, omit domains data from sync (vaultwarden sets domains to null).
|
|
#[serde(rename = "excludeDomains", default)]
|
|
pub exclude_domains: bool,
|
|
}
|
|
|
|
#[worker::send]
|
|
pub async fn get_sync_data(
|
|
claims: Claims,
|
|
State(env): State<Arc<Env>>,
|
|
Query(query): Query<SyncQuery>,
|
|
) -> Result<RawJson, AppError> {
|
|
let user_id = claims.sub;
|
|
let db = db::get_db(&env)?;
|
|
|
|
// Fetch profile
|
|
let user: User = db
|
|
.prepare("SELECT * FROM users WHERE id = ?1")
|
|
.bind(&[user_id.clone().into()])?
|
|
.first(None)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
|
|
|
|
let two_factor_enabled = two_factor_enabled(&db, &user_id).await?;
|
|
|
|
let has_master_password = !user.master_password_hash.is_empty();
|
|
let equivalent_domains = user.equivalent_domains.clone();
|
|
let excluded_globals = user.excluded_globals.clone();
|
|
let master_password_unlock = if has_master_password {
|
|
// Mirrors vaultwarden's `ciphers::sync` casing (lower camelCase).
|
|
// We don't support SSO, so this is always derived from the current user record.
|
|
json!({
|
|
"kdf": {
|
|
"kdfType": user.kdf_type,
|
|
"iterations": user.kdf_iterations,
|
|
"memory": user.kdf_memory,
|
|
"parallelism": user.kdf_parallelism
|
|
},
|
|
// This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps.
|
|
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
|
"masterKeyEncryptedUserKey": user.key,
|
|
"masterKeyWrappedUserKey": user.key,
|
|
"salt": user.email
|
|
})
|
|
} else {
|
|
Value::Null
|
|
};
|
|
|
|
// Fetch folders
|
|
let folders_db: Vec<Folder> = db
|
|
.prepare("SELECT * FROM folders WHERE user_id = ?1")
|
|
.bind(&[user_id.clone().into()])?
|
|
.all()
|
|
.await?
|
|
.results()?;
|
|
|
|
let folders: Vec<FolderResponse> = folders_db.into_iter().map(|f| f.into()).collect();
|
|
|
|
// Fetch ciphers as raw JSON array string (no parsing in Rust!)
|
|
let include_attachments = attachments::attachments_enabled(env.as_ref());
|
|
let force_row_query = ciphers_default_row_query(env.as_ref());
|
|
|
|
// Serialize profile and folders (small data, acceptable CPU cost)
|
|
let mut profile = Profile::from_user(user, two_factor_enabled)?;
|
|
// Match vaultwarden semantics: `_status` is `Invited` when no master password is set.
|
|
// We don't implement org invitations, but this helps clients interpret the account state.
|
|
profile.status = if has_master_password { 0 } else { 1 };
|
|
let profile_json = serde_json::to_string(&profile).map_err(|_| AppError::Internal)?;
|
|
let folders_json = serde_json::to_string(&folders).map_err(|_| AppError::Internal)?;
|
|
|
|
// Build response JSON via string concatenation (ciphers already raw JSON)
|
|
let user_decryption_json = serde_json::to_string(&json!({
|
|
"masterPasswordUnlock": master_password_unlock
|
|
}))
|
|
.map_err(|_| AppError::Internal)?;
|
|
|
|
const DEFAULT_SYNC_RESPONSE_PREALLOC_BYTES: usize = 1024 * 1024;
|
|
|
|
let capacity = sync_response_prealloc_bytes(env.as_ref())
|
|
.filter(|v| *v > 0)
|
|
.unwrap_or(DEFAULT_SYNC_RESPONSE_PREALLOC_BYTES);
|
|
|
|
// `/api/sync` response schema (Bitwarden-compatible):
|
|
// {
|
|
// "profile": {...},
|
|
// "folders": [...],
|
|
// "collections": [],
|
|
// "policies": [],
|
|
// "ciphers": [...],
|
|
// "domains": {...}, // omitted when excludeDomains=true
|
|
// "sends": [],
|
|
// "userDecryption": {...},
|
|
// "object": "sync"
|
|
// }
|
|
//
|
|
// We build this as a JSON string to avoid parsing/re-serializing the (potentially huge) ciphers array.
|
|
let mut response = String::with_capacity(capacity);
|
|
response.push_str("{\"profile\":");
|
|
response.push_str(&profile_json);
|
|
response.push_str(",\"folders\":");
|
|
response.push_str(&folders_json);
|
|
response.push_str(",\"collections\":[],\"policies\":[],\"ciphers\":");
|
|
ciphers::append_cipher_json_array_raw(
|
|
&mut response,
|
|
&db,
|
|
include_attachments,
|
|
"WHERE c.user_id = ?1",
|
|
&[user_id.clone().into()],
|
|
"",
|
|
force_row_query,
|
|
)
|
|
.await?;
|
|
|
|
if !query.exclude_domains {
|
|
// Match vaultwarden sync semantics:
|
|
// - mark excluded in /api/settings/domains
|
|
// - filter excluded out of sync payload
|
|
let global_equivalent_domains =
|
|
domains::global_equivalent_domains_json(&db, &excluded_globals, false).await;
|
|
response.push_str(",\"domains\":{\"equivalentDomains\":");
|
|
response.push_str(&equivalent_domains);
|
|
response.push_str(",\"globalEquivalentDomains\":");
|
|
response.push_str(&global_equivalent_domains);
|
|
response.push_str(",\"object\":\"domains\"}");
|
|
}
|
|
|
|
response.push_str(",\"sends\":[],\"userDecryption\":");
|
|
response.push_str(&user_decryption_json);
|
|
response.push_str(",\"object\":\"sync\"}");
|
|
|
|
Ok(RawJson(response))
|
|
}
|