use std::{ io::{self, Read}, time::{Duration, SystemTime, UNIX_EPOCH}, }; use serde::de::DeserializeOwned; use ureq::{Agent, AgentBuilder}; use crate::{ SnapshotHeadRef, auth::BearerToken, device::{DeviceIdentity, DeviceListResponse, DeviceRegistration}, device_api::{ DeviceApprovalDocument, DeviceApprovalRequest, DeviceRebindChallengeDocument, DeviceRebindChallengeRequest, DeviceRebindDocument, }, device_revocation::{DeviceRevocationDocument, DeviceRevocationRequest}, error::SyncClientError, snapshot::{MAX_SNAPSHOT_BYTES, SnapshotDownload, SnapshotUploadRequest}, vault::SyncVaultDocument, vault_bootstrap::SyncVaultBootstrapRequest, }; mod session; const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const USER_AGENT: &str = concat!("ELY Browser/", env!("CARGO_PKG_VERSION")); /// Resolved configuration for talking to a worker deployment. #[derive(Clone, Debug)] pub struct ApiClientConfig { base_url: String, region: String, } impl ApiClientConfig { /// Build the config used by the production deployment. The base URL /// matches `wrangler.toml`'s `ELY_AUTH_BASE_URL`. pub fn production() -> Self { Self { base_url: "https://ely-browser-cloud.zhangyanghaha0407.workers.dev".to_string(), region: "auto".to_string(), } } pub fn custom(base_url: impl Into, region: impl Into) -> Self { Self { base_url: base_url.into(), region: region.into() } } pub fn base_url(&self) -> &str { &self.base_url } pub fn region(&self) -> &str { &self.region } } /// Bearer-token authenticated HTTP client targeting `ely-browser-cloud`. pub struct SyncApiClient { agent: Agent, config: ApiClientConfig, bearer: BearerToken, } impl SyncApiClient { pub fn new(config: ApiClientConfig, bearer: BearerToken) -> Result { if !config.base_url.starts_with("https://") && !config.base_url.starts_with("http://") { return Err(SyncClientError::InvalidBaseUrl { url: config.base_url.clone() }); } let agent = AgentBuilder::new().timeout(REQUEST_TIMEOUT).user_agent(USER_AGENT).build(); Ok(Self { agent, config, bearer }) } pub fn config(&self) -> &ApiClientConfig { &self.config } /// `POST /api/devices/register` — bind a freshly-generated device /// identity to the current session. The response carries the /// canonical device record from the worker's `user_devices` table, /// including the `approval_status` that gates `/api/sync/*`. pub fn register_device( &self, identity: &DeviceIdentity, idempotency_key: &str, ) -> Result { let registration_proof = identity.registration_proof(idempotency_key)?; let registration = DeviceRegistration { version: 2, device_id: &identity.device_id, public_key: &identity.public_key, wrapping_public_key: &identity.wrapping_public_key, device_name: &identity.device_name, platform: &identity.platform, idempotency_key, registration_proof: ®istration_proof, }; let endpoint = self.endpoint("/api/devices/register"); let response = self .agent .post(&endpoint) .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) .set("Content-Type", "application/json") .send_json(serde_json::to_value(®istration).map_err(|error| { SyncClientError::Json { endpoint: endpoint.clone(), source: error } })?); let body = read_json_response::(&endpoint, response)?; Ok(body) } /// `GET /api/devices` — return every device bound to the user, /// approved or otherwise. Used by the UI to render the pending /// device-approval list. pub fn list_devices(&self) -> Result { let endpoint = self.endpoint("/api/devices"); let response = self .agent .get(&endpoint) .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) .call(); read_json_response::(&endpoint, response) } /// Rebind an existing v2 device to a fresh authenticated session. pub fn rebind_device( &self, identity: &DeviceIdentity, ) -> Result { let challenge_endpoint = self.endpoint("/api/devices/rebind/challenge"); let challenge_request = DeviceRebindChallengeRequest { version: 1, device_id: &identity.device_id }; let challenge_response = self .agent .post(&challenge_endpoint) .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) .set("Content-Type", "application/json") .send_json(serde_json::to_value(&challenge_request).map_err(|source| { SyncClientError::Json { endpoint: challenge_endpoint.clone(), source } })?); let challenge = read_json_response::( &challenge_endpoint, challenge_response, )?; let now_seconds = current_time_seconds()?; let rebind_request = challenge.signed_request(identity, now_seconds)?; let rebind_endpoint = self.endpoint("/api/devices/rebind"); let rebind_response = self .agent .post(&rebind_endpoint) .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) .set("Content-Type", "application/json") .send_json(serde_json::to_value(&rebind_request).map_err(|source| { SyncClientError::Json { endpoint: rebind_endpoint.clone(), source } })?); let document = read_json_response::(&rebind_endpoint, rebind_response)?; document.validate(identity, &challenge, current_time_seconds()?)?; Ok(document) } pub fn approve_device( &self, request: &DeviceApprovalRequest<'_>, ) -> Result { let endpoint = self.endpoint("/api/devices/approve"); let response = self.agent .post(&endpoint) .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) .set("Content-Type", "application/json") .send_json(serde_json::to_value(request).map_err(|source| { SyncClientError::Json { endpoint: endpoint.clone(), source } })?); read_json_response::(&endpoint, response) } pub fn revoke_device( &self, request: &DeviceRevocationRequest, ) -> Result { let endpoint = self.endpoint("/api/devices/revoke"); let response = self.agent .post(&endpoint) .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) .set("Content-Type", "application/json") .send_json(serde_json::to_value(request).map_err(|source| { SyncClientError::Json { endpoint: endpoint.clone(), source } })?); read_json_response::(&endpoint, response) } /// `GET /api/sync/status` — return the worker-side cursor, /// object, snapshot, and device summary for the authenticated /// approved device. pub fn sync_status(&self) -> Result { let endpoint = self.endpoint("/api/sync/status"); let response = self .agent .get(&endpoint) .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) .call(); read_json_response::(&endpoint, response) } pub fn current_sync_vault(&self) -> Result { let endpoint = self.endpoint("/api/sync/vault"); let response = self .agent .get(&endpoint) .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) .call(); read_json_response::(&endpoint, response) } pub fn sync_vault_generation( &self, generation: u64, key_id: &str, ) -> Result { let endpoint = self.endpoint(&format!("/api/sync/vault?generation={generation}&key_id={key_id}")); let response = self .agent .get(&endpoint) .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) .call(); read_json_response::(&endpoint, response) } pub fn bootstrap_sync_vault( &self, request: &SyncVaultBootstrapRequest<'_>, ) -> Result { let endpoint = self.endpoint("/api/sync/vault/bootstrap"); let response = self.agent .post(&endpoint) .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) .set("Content-Type", "application/json") .send_json(serde_json::to_value(request).map_err(|error| { SyncClientError::Json { endpoint: endpoint.clone(), source: error } })?); read_json_response::(&endpoint, response) } /// `POST /api/sync/snapshot` — push the full per-user state. The /// worker enforces logical-clock monotonicity, so callers must /// pass a value strictly greater than the last accepted snapshot. pub fn upload_snapshot( &self, request: &SnapshotUploadRequest<'_>, ) -> Result { let endpoint = self.endpoint("/api/sync/snapshot"); let response = self.agent .post(&endpoint) .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) .set("Content-Type", "application/json") .send_json(serde_json::to_value(request).map_err(|error| { SyncClientError::Json { endpoint: endpoint.clone(), source: error } })?); match response { Ok(response) => read_json_from_response::(&endpoint, response) .map(SnapshotUploadResult::Committed), Err(ureq::Error::Status(409, response)) => { let conflict = read_json_from_response::( &endpoint, response, )?; if conflict.version != 1 || conflict.error != "sync_snapshot_head_conflict" { return Err(SyncClientError::DeviceTrust { reason: "snapshot conflict response is invalid", }); } Ok(SnapshotUploadResult::Conflict(conflict)) } Err(error) => read_json_response::(&endpoint, Err(error)) .map(SnapshotUploadResult::Committed), } } /// `GET /api/sync/snapshot?snapshot_id=…` — fetch the snapshot for /// the named id. Returns the encoded payload plus the snapshot /// metadata; callers should verify the payload hash before trusting /// the bytes. pub fn download_snapshot( &self, head: &SnapshotHeadRef, ) -> Result { let endpoint = self.endpoint(&format!( "/api/sync/snapshot?snapshot_id={}&head_revision={}&payload_hash={}", head.snapshot_id(), head.revision(), head.payload_hash(), )); let response = self .agent .get(&endpoint) .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) .call(); match response { Ok(response) => read_json_from_response::(&endpoint, response) .map(SnapshotDownloadResult::Downloaded), Err(ureq::Error::Status(409, response)) => { let conflict = read_json_from_response::( &endpoint, response, )?; if conflict.version != 1 || conflict.error != "sync_snapshot_head_conflict" { return Err(SyncClientError::DeviceTrust { reason: "snapshot conflict response is invalid", }); } Ok(SnapshotDownloadResult::Conflict(conflict)) } Err(error) => read_json_response::(&endpoint, Err(error)) .map(SnapshotDownloadResult::Downloaded), } } fn endpoint(&self, path: &str) -> String { format!("{}{}", self.config.base_url.trim_end_matches('/'), path) } } #[derive(Clone, Debug, serde::Deserialize)] pub struct DeviceRecordDocument { pub version: u32, pub user_id: String, pub device: crate::device::DeviceRecord, } #[derive(Clone, Debug, serde::Deserialize)] pub struct SnapshotUploadDocument { pub version: u32, pub user_id: String, pub device_id: String, pub snapshot: crate::snapshot::SnapshotDocument, } #[derive(Clone, Debug, serde::Deserialize)] pub struct SyncStatusDocument { pub version: u32, pub user_id: String, pub device_id: String, pub cursor: SyncCursorStatusDocument, pub objects: Vec, pub snapshots: SyncSnapshotStatusDocument, pub devices: SyncDeviceStatusDocument, } #[derive(Clone, Debug, serde::Deserialize)] pub struct SyncCursorStatusDocument { pub latest_change_id: u64, pub total_changes: u64, } #[derive(Clone, Debug, serde::Deserialize)] pub struct SyncObjectStatusDocument { pub object_type: String, pub active_count: u64, pub deleted_count: u64, pub latest_logical_clock: u64, pub latest_updated_at: u64, } #[derive(Clone, Debug, serde::Deserialize)] pub struct SyncSnapshotStatusDocument { pub total_snapshots: u64, pub head: Option, } #[derive(Clone, Debug, serde::Deserialize)] pub struct SyncLatestSnapshotDocument { pub head_revision: u64, pub base_head: Option, pub snapshot_id: String, pub payload_hash: String, pub encryption_version: u32, pub vault_generation: u64, pub key_id: String, pub content_hash: String, pub logical_clock: u64, pub device_id: String, pub size_bytes: u64, pub created_at: u64, } impl SyncLatestSnapshotDocument { pub fn head_ref(&self) -> Result { SnapshotHeadRef::new( self.head_revision, self.snapshot_id.clone(), self.payload_hash.clone(), ) } } #[derive(Clone, Debug)] pub enum SnapshotUploadResult { Committed(SnapshotUploadDocument), Conflict(SyncSnapshotHeadConflictDocument), } #[derive(Clone, Debug)] pub enum SnapshotDownloadResult { Downloaded(SnapshotDownload), Conflict(SyncSnapshotHeadConflictDocument), } #[derive(Clone, Debug, serde::Deserialize)] #[serde(deny_unknown_fields)] pub struct SyncSnapshotHeadConflictDocument { pub version: u32, pub error: String, pub current_head: Option, } #[derive(Clone, Debug, serde::Deserialize)] pub struct SyncDeviceStatusDocument { pub approved_count: u64, pub current_device_id: String, pub current_device_approved: bool, } /// A snapshot download legally carries `MAX_SNAPSHOT_BYTES` of payload as /// base64 (4/3 expansion) plus its JSON envelope, which exceeds ureq's /// 10 MiB `into_string` cap. Anything above this bound fails closed. const MAX_RESPONSE_BODY_BYTES: usize = MAX_SNAPSHOT_BYTES.div_ceil(3) * 4 + 64 * 1024; fn read_response_body(response: ureq::Response) -> io::Result { let mut body = String::new(); response.into_reader().take(MAX_RESPONSE_BODY_BYTES as u64 + 1).read_to_string(&mut body)?; if body.len() > MAX_RESPONSE_BODY_BYTES { return Err(io::Error::other("response body exceeds the sync wire limit")); } Ok(body) } fn read_json_response( endpoint: &str, response: Result, ) -> Result { match response { Ok(ok) => read_json_from_response(endpoint, ok), Err(ureq::Error::Status(status, raw)) => { let body = read_response_body(raw).unwrap_or_default(); if session::response_ends_session(status, &body) { return Err(SyncClientError::SessionEnded); } Err(SyncClientError::HttpStatus { endpoint: endpoint.to_string(), status, body }) } Err(other) => { Err(SyncClientError::Http { endpoint: endpoint.to_string(), source: Box::new(other) }) } } } fn read_json_from_response( endpoint: &str, response: ureq::Response, ) -> Result { let status = response.status(); let body = read_response_body(response).map_err(|error| SyncClientError::HttpStatus { endpoint: endpoint.to_string(), status, body: error.to_string(), })?; serde_json::from_str::(&body) .map_err(|source| SyncClientError::Json { endpoint: endpoint.to_string(), source }) } fn current_time_seconds() -> Result { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_secs()) .map_err(|_| SyncClientError::DeviceTrust { reason: "system clock is invalid" }) } #[cfg(test)] #[path = "client_tests.rs"] mod tests;