1use std::collections::HashMap;
6use std::ffi::CString;
7use std::path::Path;
8use std::sync::Arc;
9
10use tracing::info;
11
12use crate::blob::BlobPlan;
13use crate::config::{CloudProvider, Config, ConfigError};
14use crate::encryption::{EncryptionError, EncryptionService};
15use crate::join_code::InviteCode;
16use crate::keys::{CloudHomeCredentials, KeyError, KeyService};
17use crate::library_dir::LibraryDir;
18use crate::oauth::OAuthError;
19use crate::storage::cloud::{CloudHome, CloudHomeError, CloudHomeJoinInfo};
20use crate::sync::encrypted_storage::EncryptedSyncStorage;
21use crate::sync::invite::{unwrap_library_key, InviteError};
22use crate::sync::pull::{pull_changes, PullError};
23use crate::sync::snapshot::{bootstrap_from_snapshot, SnapshotError};
24use crate::sync::storage::SyncStorage;
25
26#[derive(Debug, thiserror::Error)]
27pub enum JoinError {
28 #[error("cloud home: {0}")]
29 CloudHome(#[from] CloudHomeError),
30 #[error("encryption: {0}")]
31 Encryption(#[from] EncryptionError),
32 #[error("invite: {0}")]
33 Invite(#[from] InviteError),
34 #[error("snapshot: {0}")]
35 Snapshot(#[from] SnapshotError),
36 #[error("pull: {0}")]
37 Pull(#[from] PullError),
38 #[error("config: {0}")]
39 Config(#[from] ConfigError),
40 #[error("keyring: {0}")]
41 Key(#[from] KeyError),
42 #[error("I/O: {0}")]
43 Io(#[from] std::io::Error),
44 #[error("database: {0}")]
45 Database(String),
46 #[error("oauth: {0}")]
47 OAuth(#[from] OAuthError),
48}
49
50async fn build_cloud_home_for_join(
55 join_info: &CloudHomeJoinInfo,
56 keypair: &crate::keys::UserKeypair,
57 lib_ks: &KeyService,
58 cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
59 oauth_cancel: tokio::sync::watch::Receiver<bool>,
60 clock: crate::clock::ClockRef,
61) -> Result<Box<dyn CloudHome>, JoinError> {
62 use crate::storage::cloud::*;
63
64 match join_info {
65 CloudHomeJoinInfo::S3 {
66 bucket,
67 region,
68 endpoint,
69 access_key,
70 secret_key,
71 key_prefix,
72 } => {
73 let s3 = s3::S3CloudHome::new(
74 bucket.clone(),
75 region.clone(),
76 endpoint.clone(),
77 access_key.clone(),
78 secret_key.clone(),
79 key_prefix.clone(),
80 )
81 .await?;
82 Ok(Box::new(s3))
83 }
84
85 CloudHomeJoinInfo::HttpProxy { url } => Ok(Box::new(http::HttpCloudHome::new(
86 url.clone(),
87 keypair.clone(),
88 clock,
89 ))),
90
91 CloudHomeJoinInfo::GoogleDrive { folder_id } => {
92 info!("Authorizing with Google Drive...");
93 let tokens = crate::oauth::authorize_provider(
94 CloudProvider::GoogleDrive,
95 oauth_cancel.clone(),
96 clock.as_ref(),
97 )
98 .await?;
99 let token_json = serde_json::to_string(&tokens)
100 .map_err(|e| JoinError::Database(format!("Failed to serialize tokens: {e}")))?;
101 lib_ks.set_cloud_home_credentials(&CloudHomeCredentials::OAuth { token_json })?;
102 Ok(Box::new(google_drive::GoogleDriveCloudHome::new(
103 folder_id.clone(),
104 tokens,
105 lib_ks.clone(),
106 clock,
107 )))
108 }
109
110 CloudHomeJoinInfo::Dropbox { shared_folder_id } => {
111 info!("Authorizing with Dropbox...");
112 let tokens = crate::oauth::authorize_provider(
113 CloudProvider::Dropbox,
114 oauth_cancel.clone(),
115 clock.as_ref(),
116 )
117 .await?;
118 let token_json = serde_json::to_string(&tokens)
119 .map_err(|e| JoinError::Database(format!("Failed to serialize tokens: {e}")))?;
120 lib_ks.set_cloud_home_credentials(&CloudHomeCredentials::OAuth { token_json })?;
121 Ok(Box::new(dropbox::DropboxCloudHome::new(
122 shared_folder_id.clone(),
123 tokens,
124 lib_ks.clone(),
125 clock,
126 )))
127 }
128
129 CloudHomeJoinInfo::OneDrive {
130 drive_id,
131 folder_id,
132 } => {
133 info!("Authorizing with OneDrive...");
134 let tokens = crate::oauth::authorize_provider(
135 CloudProvider::OneDrive,
136 oauth_cancel.clone(),
137 clock.as_ref(),
138 )
139 .await?;
140 let token_json = serde_json::to_string(&tokens)
141 .map_err(|e| JoinError::Database(format!("Failed to serialize tokens: {e}")))?;
142 lib_ks.set_cloud_home_credentials(&CloudHomeCredentials::OAuth { token_json })?;
143 Ok(Box::new(onedrive::OneDriveCloudHome::new(
144 drive_id.clone(),
145 folder_id.clone(),
146 tokens,
147 lib_ks.clone(),
148 clock,
149 )))
150 }
151
152 CloudHomeJoinInfo::CloudKit { .. } => {
153 let ops = cloudkit_ops
154 .ok_or_else(|| JoinError::Database("CloudKit driver not provided".to_string()))?;
155 Ok(Box::new(cloudkit::CloudKitCloudHome::new(ops)))
156 }
157 }
158}
159
160pub async fn join_from_invite_code(
165 invite_code_str: &str,
166 app_dir: &Path,
167 cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
168 oauth_cancel: tokio::sync::watch::Receiver<bool>,
169 clock: crate::clock::ClockRef,
170 ids: crate::id_provider::IdRef,
171 make_blob_plan: impl Fn(&LibraryDir) -> Box<dyn BlobPlan>,
172 on_status: impl Fn(&str),
173) -> Result<Config, JoinError> {
174 let code = crate::join_code::decode(invite_code_str)
175 .map_err(|e| JoinError::Database(format!("Invalid invite code: {e}")))?;
176
177 let dev_mode = Config::is_dev_mode();
178 let global_ks = KeyService::new(dev_mode, "global".to_string());
179 let keypair = global_ks.get_or_create_user_keypair()?;
180 let lib_ks = KeyService::new(dev_mode, code.library_id.clone());
181
182 let cloud_home = build_cloud_home_for_join(
183 &code.join_info,
184 &keypair,
185 &lib_ks,
186 cloudkit_ops,
187 oauth_cancel,
188 clock,
189 )
190 .await?;
191
192 let config = join_library(
193 app_dir,
194 code,
195 &global_ks,
196 cloud_home,
197 ids.as_ref(),
198 make_blob_plan,
199 &on_status,
200 )
201 .await?;
202
203 Ok(config)
205}
206
207pub async fn join_library(
214 bae_dir: &Path,
215 code: InviteCode,
216 key_service: &KeyService,
217 cloud_home: Box<dyn CloudHome>,
218 ids: &dyn crate::id_provider::IdProvider,
219 make_blob_plan: impl Fn(&LibraryDir) -> Box<dyn BlobPlan>,
220 on_status: impl Fn(&str),
221) -> Result<Config, JoinError> {
222 on_status("Loading keypair...");
225 let user_keypair = key_service.get_user_keypair()?;
226
227 on_status("Accepting invitation...");
231 let encryption_key = unwrap_library_key(cloud_home.as_ref(), &user_keypair).await?;
232 let encryption_key_hex = hex::encode(encryption_key);
233
234 on_status("Downloading library snapshot...");
236 let encryption = EncryptionService::new(&encryption_key_hex)?;
237 let storage = EncryptedSyncStorage::new(cloud_home, encryption.clone());
238
239 let library_id = code.library_id;
241 let device_id = ids.new_id();
242 let library_dir = LibraryDir::new(bae_dir.join("libraries").join(&library_id));
243 std::fs::create_dir_all(&*library_dir)?;
244 let blob_plan = make_blob_plan(&library_dir);
246
247 let new_key_service = KeyService::new(key_service.is_dev_mode(), library_id.clone());
249
250 let result = bootstrap_and_save(
251 &storage,
252 &encryption,
253 &encryption_key_hex,
254 &library_dir,
255 &library_id,
256 &device_id,
257 &code.join_info,
258 &code.library_name,
259 &new_key_service,
260 blob_plan.as_ref(),
261 &on_status,
262 )
263 .await;
264
265 if result.is_err() {
266 let _ = std::fs::remove_dir_all(&*library_dir);
267 }
268
269 result
270}
271
272async fn bootstrap_and_save(
274 storage: &EncryptedSyncStorage,
275 encryption: &EncryptionService,
276 encryption_key_hex: &str,
277 library_dir: &LibraryDir,
278 library_id: &str,
279 device_id: &str,
280 join_info: &CloudHomeJoinInfo,
281 library_name: &str,
282 key_service: &KeyService,
283 blob_plan: &dyn BlobPlan,
284 on_status: &impl Fn(&str),
285) -> Result<Config, JoinError> {
286 let db_path = library_dir.db_path();
288 let bucket_dyn: &dyn SyncStorage = storage;
289 let bootstrap_result = bootstrap_from_snapshot(bucket_dyn, encryption, &db_path).await?;
290
291 info!(
292 "Bootstrapped from snapshot ({} device cursors)",
293 bootstrap_result.cursors.len()
294 );
295
296 on_status("Applying recent changes...");
298 let cursors = bootstrap_result.cursors;
299
300 let changesets_applied = open_db_and_pull(
301 &db_path,
302 bucket_dyn,
303 device_id,
304 &cursors,
305 library_dir,
306 blob_plan,
307 )
308 .await?;
309
310 if changesets_applied > 0 {
311 info!("Applied {changesets_applied} changesets since snapshot");
312 }
313
314 on_status("Saving configuration...");
316 key_service.set_encryption_key(encryption_key_hex)?;
317
318 let credentials = derive_credentials(join_info);
320 key_service.set_cloud_home_credentials(&credentials)?;
321
322 let config = build_config(
324 library_id,
325 device_id,
326 library_dir,
327 library_name,
328 join_info,
329 encryption,
330 );
331
332 config.save_to_config_yaml()?;
333
334 info!("Joined library {} at {}", library_id, library_dir.display());
335 Ok(config)
336}
337
338pub(crate) async fn open_db_and_pull(
340 db_path: &Path,
341 storage: &dyn SyncStorage,
342 device_id: &str,
343 cursors: &HashMap<String, u64>,
344 library_dir: &LibraryDir,
345 blob_plan: &dyn BlobPlan,
346) -> Result<u64, JoinError> {
347 unsafe {
348 let c_path = CString::new(db_path.to_str().unwrap()).unwrap();
349 let mut db: *mut libsqlite3_sys::sqlite3 = std::ptr::null_mut();
350 let rc = libsqlite3_sys::sqlite3_open(c_path.as_ptr(), &mut db);
351 if rc != libsqlite3_sys::SQLITE_OK {
352 return Err(JoinError::Database(
353 "Failed to open database for changeset application".to_string(),
354 ));
355 }
356
357 let result =
358 match pull_changes(db, storage, device_id, cursors, library_dir, blob_plan).await {
359 Ok((_updated_cursors, pull_result)) => Ok(pull_result.changesets_applied),
360 Err(e) => {
361 libsqlite3_sys::sqlite3_close(db);
362 Err(JoinError::Pull(e))
363 }
364 };
365
366 if result.is_ok() {
367 libsqlite3_sys::sqlite3_close(db);
368 }
369
370 result
371 }
372}
373
374pub(crate) fn derive_credentials(join_info: &CloudHomeJoinInfo) -> CloudHomeCredentials {
380 match join_info {
381 CloudHomeJoinInfo::S3 {
382 access_key,
383 secret_key,
384 ..
385 } => CloudHomeCredentials::S3 {
386 access_key: access_key.clone(),
387 secret_key: secret_key.clone(),
388 },
389 CloudHomeJoinInfo::HttpProxy { .. } => CloudHomeCredentials::None,
390 CloudHomeJoinInfo::GoogleDrive { .. }
394 | CloudHomeJoinInfo::Dropbox { .. }
395 | CloudHomeJoinInfo::OneDrive { .. }
396 | CloudHomeJoinInfo::CloudKit { .. } => CloudHomeCredentials::None,
397 }
398}
399
400pub(crate) fn build_config(
402 library_id: &str,
403 device_id: &str,
404 library_dir: &LibraryDir,
405 library_name: &str,
406 join_info: &CloudHomeJoinInfo,
407 encryption: &EncryptionService,
408) -> Config {
409 let mut config = Config::with_defaults(
410 library_id.to_string(),
411 device_id.to_string(),
412 library_dir.clone(),
413 library_name.to_string(),
414 );
415
416 config.encryption_key_stored = true;
417 config.encryption_key_fingerprint = Some(encryption.fingerprint());
418
419 config.cloud_home.provider = Some(match join_info {
420 CloudHomeJoinInfo::S3 { .. } => CloudProvider::S3,
421 CloudHomeJoinInfo::HttpProxy { .. } => CloudProvider::HttpProxy,
422 CloudHomeJoinInfo::GoogleDrive { .. } => CloudProvider::GoogleDrive,
423 CloudHomeJoinInfo::Dropbox { .. } => CloudProvider::Dropbox,
424 CloudHomeJoinInfo::OneDrive { .. } => CloudProvider::OneDrive,
425 CloudHomeJoinInfo::CloudKit { .. } => CloudProvider::CloudKit,
426 });
427
428 match join_info {
429 CloudHomeJoinInfo::S3 {
430 bucket,
431 region,
432 endpoint,
433 key_prefix,
434 ..
435 } => {
436 config.cloud_home.s3_bucket = Some(bucket.clone());
437 config.cloud_home.s3_region = Some(region.clone());
438 config.cloud_home.s3_endpoint = endpoint.clone();
439 config.cloud_home.s3_key_prefix = key_prefix.clone();
440 }
441 CloudHomeJoinInfo::HttpProxy { url } => {
442 config.cloud_home.http_url = Some(url.clone());
443 }
444 CloudHomeJoinInfo::GoogleDrive { folder_id } => {
445 config.cloud_home.google_drive_folder_id = Some(folder_id.clone());
446 }
447 CloudHomeJoinInfo::Dropbox { shared_folder_id } => {
448 config.cloud_home.dropbox_folder_path = Some(shared_folder_id.clone());
449 }
450 CloudHomeJoinInfo::OneDrive {
451 drive_id,
452 folder_id,
453 } => {
454 config.cloud_home.onedrive_drive_id = Some(drive_id.clone());
455 config.cloud_home.onedrive_folder_id = Some(folder_id.clone());
456 }
457 CloudHomeJoinInfo::CloudKit { .. } => {
458 config.cloud_home.cloudkit_is_shared = true;
459 }
460 }
461
462 config
463}