From 2cb200484083b8b51853343a09b7d32b7fcdab2a Mon Sep 17 00:00:00 2001 From: Deep Date: Sun, 21 Sep 2025 15:39:41 +0530 Subject: [PATCH] Vault + registration --- src/handlers/accounts.rs | 10 +- src/handlers/ciphers.rs | 85 ++++++++---- src/handlers/sync.rs | 23 +++- src/models/cipher.rs | 271 ++++++++++++++++++++++++++++++++++++++- src/models/sync.rs | 2 +- src/router.rs | 9 +- 6 files changed, 366 insertions(+), 34 deletions(-) diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs index 444250a..09c5e77 100644 --- a/src/handlers/accounts.rs +++ b/src/handlers/accounts.rs @@ -1,5 +1,6 @@ use axum::{extract::State, Json}; use chrono::Utc; +use serde_json::{json, Value}; use std::sync::Arc; use uuid::Uuid; use worker::{query, D1Database, Env}; @@ -39,7 +40,7 @@ pub async fn prelogin( pub async fn register( State(env): State>, Json(payload): Json, -) -> Result, AppError> { +) -> Result, AppError> { log::info!("Get db"); let db = db::get_db(&env)?; log::info!("db got"); @@ -88,5 +89,10 @@ pub async fn register( AppError::Database })?; - Ok(Json(())) + Ok(Json(json!({}))) +} + +#[worker::send] +pub async fn send_verification_email() -> String { + "fixed-token-to-mock".to_string() } diff --git a/src/handlers/ciphers.rs b/src/handlers/ciphers.rs index e0e5677..0d7e93f 100644 --- a/src/handlers/ciphers.rs +++ b/src/handlers/ciphers.rs @@ -2,40 +2,79 @@ use axum::{extract::State, Json}; use chrono::Utc; use std::sync::Arc; use uuid::Uuid; -use worker::Env; +use worker::{query, Env}; -use crate::{auth::Claims, db, error::AppError, models::cipher::Cipher}; +use crate::auth::Claims; +use crate::db; +use crate::error::AppError; +use crate::models::cipher::{Cipher, CipherData, CreateCipherRequest}; #[worker::send] pub async fn create_cipher( claims: Claims, State(env): State>, - Json(mut payload): Json, + Json(payload): Json, ) -> Result, AppError> { let db = db::get_db(&env)?; - let now = Utc::now().to_rfc3339(); + let now = Utc::now(); + let now = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + let cipher_data_req = payload.cipher; - payload.id = Uuid::new_v4().to_string(); - payload.user_id = Some(claims.sub); - payload.created_at = now.clone(); - payload.updated_at = now; + let cipher_data = CipherData { + name: cipher_data_req.name, + notes: cipher_data_req.notes, + login: cipher_data_req.login, + card: cipher_data_req.card, + identity: cipher_data_req.identity, + secure_note: cipher_data_req.secure_note, + fields: cipher_data_req.fields, + password_history: cipher_data_req.password_history, + reprompt: cipher_data_req.reprompt, + }; - db.prepare( - "INSERT INTO ciphers (id, user_id, type, data, favorite, folder_id, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - ) - .bind(&[ - payload.id.clone().into(), - payload.user_id.clone().into(), - payload.r#type.into(), - serde_json::to_string(&payload.data).unwrap().into(), - payload.favorite.into(), - payload.folder_id.clone().into(), - payload.created_at.clone().into(), - payload.updated_at.clone().into(), - ])? + let data_value = serde_json::to_value(&cipher_data).map_err(|_| AppError::Internal)?; + + let cipher = Cipher { + id: Uuid::new_v4().to_string(), + user_id: Some(claims.sub.clone()), + organization_id: cipher_data_req.organization_id.clone(), + r#type: cipher_data_req.r#type, + data: data_value, + favorite: cipher_data_req.favorite, + folder_id: cipher_data_req.folder_id.clone(), + deleted_at: None, + created_at: now.clone(), + updated_at: now.clone(), + object: "cipher".to_string(), + organization_use_totp: false, + edit: true, + view_password: true, + collection_ids: if payload.collection_ids.is_empty() { + None + } else { + Some(payload.collection_ids) + }, + }; + + let data = serde_json::to_string(&cipher.data).map_err(|_| AppError::Internal)?; + + query!( + &db, + "INSERT INTO ciphers (id, user_id, organization_id, type, data, favorite, folder_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + cipher.id, + cipher.user_id, + cipher.organization_id, + cipher.r#type, + data, + cipher.favorite, + + cipher.folder_id, + cipher.created_at, + cipher.updated_at, + ).map_err(|_|AppError::Database)? .run() .await?; - Ok(Json(payload)) + Ok(Json(cipher)) } diff --git a/src/handlers/sync.rs b/src/handlers/sync.rs index 3d1861e..e395aa4 100644 --- a/src/handlers/sync.rs +++ b/src/handlers/sync.rs @@ -1,4 +1,5 @@ use axum::{extract::State, Json}; +use serde_json::Value; use std::sync::Arc; use worker::Env; @@ -7,7 +8,7 @@ use crate::{ db, error::AppError, models::{ - cipher::Cipher, + cipher::{Cipher, CipherDBModel}, folder::Folder, sync::{Profile, SyncResponse}, user::User, @@ -22,6 +23,7 @@ pub async fn get_sync_data( let user_id = claims.sub; let db = db::get_db(&env)?; + log::info!("Fetch user"); // Fetch profile let user: User = db .prepare("SELECT * FROM users WHERE id = ?1") @@ -30,6 +32,7 @@ pub async fn get_sync_data( .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + log::info!("Fetch folders"); // Fetch folders let folders: Vec = db .prepare("SELECT * FROM folders WHERE user_id = ?1") @@ -38,14 +41,30 @@ pub async fn get_sync_data( .await? .results()?; + log::info!("Fetch ciphers"); // Fetch ciphers - let ciphers: Vec = db + 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::>(); + + log::info!("Fetch time"); let time = chrono::DateTime::parse_from_rfc3339(&user.created_at) .map_err(|_| AppError::Internal)? .to_rfc3339_opts(chrono::SecondsFormat::Micros, true); diff --git a/src/models/cipher.rs b/src/models/cipher.rs index a766697..c9efc49 100644 --- a/src/models/cipher.rs +++ b/src/models/cipher.rs @@ -1,7 +1,74 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::{json, Map, Value}; -#[derive(Debug, Serialize, Deserialize)] +// This struct represents the data stored in the `data` column of the `ciphers` table. +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CipherData { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub login: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub card: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub identity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub secure_note: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fields: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub password_history: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reprompt: Option, +} + +// Custom deserialization function for booleans +fn deserialize_bool_from_int<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + // A visitor is used to handle different data types + struct BoolOrIntVisitor; + + impl<'de> de::Visitor<'de> for BoolOrIntVisitor { + type Value = bool; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a boolean or an integer 0 or 1") + } + + // Handles boolean values + fn visit_bool(self, value: bool) -> Result + where + E: de::Error, + { + Ok(value) + } + + // Handles integer values (0 or 1) + fn visit_u64(self, value: u64) -> Result + where + E: de::Error, + { + match value { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(de::Error::invalid_value( + de::Unexpected::Unsigned(value), + &"0 or 1", + )), + } + } + } + + deserializer.deserialize_any(BoolOrIntVisitor) +} + +// The struct that is stored in the database and used in handlers. +// For serialization to JSON for the client, we implement a custom `Serialize`. +#[derive(Debug, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct Cipher { pub id: String, @@ -11,9 +78,8 @@ pub struct Cipher { pub organization_id: Option, #[serde(rename = "type")] pub r#type: i32, - // Store all other fields as a raw JSON value. - // The server doesn't need to understand the encrypted contents. pub data: Value, + #[serde(deserialize_with = "deserialize_bool_from_int")] pub favorite: bool, #[serde(skip_serializing_if = "Option::is_none")] pub folder_id: Option, @@ -21,4 +87,199 @@ pub struct Cipher { pub deleted_at: Option, pub created_at: String, pub updated_at: String, + + // Bitwarden specific field for API responses + #[serde(default = "default_object")] + pub object: String, + #[serde(default)] + #[serde(deserialize_with = "deserialize_bool_from_int")] + pub organization_use_totp: bool, + #[serde(default = "default_true")] + #[serde(deserialize_with = "deserialize_bool_from_int")] + pub edit: bool, + #[serde(default = "default_true")] + #[serde(deserialize_with = "deserialize_bool_from_int")] + pub view_password: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub collection_ids: Option>, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct CipherDBModel { + pub id: String, + pub user_id: String, + pub organization_id: Option, + pub r#type: i32, + pub data: String, + pub favorite: i32, + pub folder_id: Option, + pub deleted_at: Option, + pub created_at: String, + pub updated_at: String, +} + +impl Into for CipherDBModel { + fn into(self) -> Cipher { + Cipher { + id: self.id, + user_id: Some(self.user_id), + organization_id: self.organization_id, + r#type: self.r#type, + data: serde_json::from_str(&self.data).unwrap_or_default(), + favorite: match self.favorite { + 0 => false, + _ => true, + }, + folder_id: self.folder_id, + deleted_at: self.deleted_at, + created_at: self.created_at, + updated_at: self.updated_at, + object: "default_object".to_string(), + organization_use_totp: false, + edit: true, + view_password: true, + collection_ids: None, + } + } +} + +impl Serialize for Cipher { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut response_map = Map::new(); + + response_map.insert("object".to_string(), json!(self.object)); + response_map.insert("id".to_string(), json!(self.id)); + if self.user_id.is_some() { + response_map.insert("userId".to_string(), json!(self.user_id)); + } + response_map.insert("organizationId".to_string(), json!(self.organization_id)); + response_map.insert("folderId".to_string(), json!(self.folder_id)); + response_map.insert("type".to_string(), json!(self.r#type)); + response_map.insert("favorite".to_string(), json!(self.favorite)); + response_map.insert("edit".to_string(), json!(self.edit)); + response_map.insert("viewPassword".to_string(), json!(self.view_password)); + response_map.insert( + "organizationUseTotp".to_string(), + json!(self.organization_use_totp), + ); + response_map.insert("collectionIds".to_string(), json!(self.collection_ids)); + response_map.insert("revisionDate".to_string(), json!(self.updated_at)); + response_map.insert("creationDate".to_string(), json!(self.created_at)); + response_map.insert("deletedDate".to_string(), json!(self.deleted_at)); + + log::debug!("Response data is {:?}", self.data); + if let Some(data_obj) = self.data.as_object() { + let data_clone = data_obj.clone(); + + response_map.insert( + "name".to_string(), + data_clone.get("name").cloned().unwrap_or(Value::Null), + ); + response_map.insert( + "notes".to_string(), + data_clone.get("notes").cloned().unwrap_or(Value::Null), + ); + response_map.insert( + "fields".to_string(), + data_clone.get("fields").cloned().unwrap_or(Value::Null), + ); + response_map.insert( + "passwordHistory".to_string(), + data_clone + .get("passwordHistory") + .cloned() + .unwrap_or(Value::Null), + ); + response_map.insert( + "reprompt".to_string(), + data_clone + .get("reprompt") + .cloned() + .unwrap_or(Value::Number(serde_json::Number::from_f64(0.0).unwrap())), + ); + + let mut login = Value::Null; + let mut secure_note = Value::Null; + let mut card = Value::Null; + let mut identity = Value::Null; + + match self.r#type { + 1 => login = data_clone.get("login").cloned().unwrap_or(Value::Null), + 2 => secure_note = data_clone.get("secureNote").cloned().unwrap_or(Value::Null), + 3 => card = data_clone.get("card").cloned().unwrap_or(Value::Null), + 4 => identity = data_clone.get("identity").cloned().unwrap_or(Value::Null), + _ => {} + } + + response_map.insert("login".to_string(), login); + response_map.insert("secureNote".to_string(), secure_note); + response_map.insert("card".to_string(), card); + response_map.insert("identity".to_string(), identity); + } else { + response_map.insert("name".to_string(), Value::Null); + response_map.insert("notes".to_string(), Value::Null); + response_map.insert("fields".to_string(), Value::Null); + response_map.insert("passwordHistory".to_string(), Value::Null); + response_map.insert("reprompt".to_string(), Value::Null); + response_map.insert("login".to_string(), Value::Null); + response_map.insert("secureNote".to_string(), Value::Null); + response_map.insert("card".to_string(), Value::Null); + response_map.insert("identity".to_string(), Value::Null); + } + + Value::Object(response_map).serialize(serializer) + } +} + +fn default_object() -> String { + "cipher".to_string() +} + +fn default_true() -> bool { + true +} + +// Represents the "Cipher" object within the incoming request payload. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CipherRequestData { + #[serde(rename = "type")] + pub r#type: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub folder_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub organization_id: Option, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + #[serde(default)] + pub favorite: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub login: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub card: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub identity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub secure_note: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fields: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub password_history: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reprompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_known_revision_date: Option, +} + +// Represents the full request payload for creating a cipher. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct CreateCipherRequest { + pub cipher: CipherRequestData, + #[serde(default)] + pub collection_ids: Vec, } diff --git a/src/models/sync.rs b/src/models/sync.rs index 9f4ac94..42f0798 100644 --- a/src/models/sync.rs +++ b/src/models/sync.rs @@ -37,7 +37,7 @@ pub struct SyncResponse { pub profile: Profile, #[serde(rename = "Folders")] pub folders: Vec, - #[serde(rename = "Ciphers")] + #[serde(rename = "ciphers")] pub ciphers: Vec, #[serde(rename = "Domains")] pub domains: Value, diff --git a/src/router.rs b/src/router.rs index 4549314..896b3c5 100644 --- a/src/router.rs +++ b/src/router.rs @@ -13,8 +13,15 @@ pub fn api_router(env: Env) -> Router { Router::new() // Identity/Auth routes .route("/identity/accounts/prelogin", post(accounts::prelogin)) - .route("/identity/accounts/register", post(accounts::register)) + .route( + "/identity/accounts/register/finish", + post(accounts::register), + ) .route("/identity/connect/token", post(identity::token)) + .route( + "/identity/accounts/register/send-verification-email", + post(accounts::send_verification_email), + ) // Main data sync route .route("/api/sync", get(sync::get_sync_data)) // Ciphers CRUD