coven/storage/cloud/
setup.rs

1//! Cloud provider setup and management.
2//!
3//! Contains the OAuth sign-in flows for Google Drive, Dropbox, and OneDrive,
4//! as well as managed service signup/login, disconnect, and account display logic.
5
6use tracing::{info, warn};
7
8use crate::config::{CloudProvider, Config};
9use crate::keys::{CloudHomeCredentials, KeyService};
10
11/// Google Drive OAuth sign-in: authorize, find/create the library folder, save
12/// tokens to the keyring. Returns the folder id for the host to persist in its
13/// own config (coven never writes the host's config).
14pub async fn sign_in_google_drive(
15    key_service: &KeyService,
16    library_name: &str,
17    oauth_cancel: tokio::sync::watch::Receiver<bool>,
18    clock: &dyn crate::clock::Clock,
19) -> Result<String, SetupError> {
20    let oauth_config = super::google_drive::GoogleDriveCloudHome::oauth_config();
21    let tokens = crate::oauth::authorize(&oauth_config, oauth_cancel, clock)
22        .await
23        .map_err(|e| SetupError(format!("Google Drive authorization failed: {e}")))?;
24
25    let client = reqwest::Client::new();
26
27    // Create or find the folder
28    let folder_name = format!("bae - {library_name}");
29
30    let search_query = format!(
31        "name = '{}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false",
32        folder_name.replace('\'', "\\'")
33    );
34    let search_resp = client
35        .get("https://www.googleapis.com/drive/v3/files")
36        .bearer_auth(&tokens.access_token)
37        .query(&[("q", &search_query), ("fields", &"files(id)".to_string())])
38        .send()
39        .await
40        .map_err(|e| {
41            SetupError(format!(
42                "Failed to search for existing Google Drive folder: {e}"
43            ))
44        })?;
45
46    if !search_resp.status().is_success() {
47        let body = search_resp.text().await.unwrap_or_default();
48        return Err(SetupError(format!(
49            "Failed to search for existing Google Drive folder: {body}"
50        )));
51    }
52
53    let search_json: serde_json::Value = search_resp
54        .json()
55        .await
56        .map_err(|e| SetupError(format!("Failed to parse Google Drive search response: {e}")))?;
57
58    let existing_folder_id = search_json["files"][0]["id"]
59        .as_str()
60        .map(|s| s.to_string());
61
62    let folder_id = if let Some(id) = existing_folder_id {
63        id
64    } else {
65        let create_body = serde_json::json!({
66            "name": folder_name,
67            "mimeType": "application/vnd.google-apps.folder",
68        });
69        let resp = client
70            .post("https://www.googleapis.com/drive/v3/files")
71            .bearer_auth(&tokens.access_token)
72            .json(&create_body)
73            .send()
74            .await
75            .map_err(|e| SetupError(format!("Failed to create Google Drive folder: {e}")))?;
76
77        if !resp.status().is_success() {
78            let body = resp
79                .text()
80                .await
81                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
82            return Err(SetupError(format!(
83                "Failed to create Google Drive folder: {body}"
84            )));
85        }
86
87        let folder_resp: serde_json::Value = resp
88            .json()
89            .await
90            .map_err(|e| SetupError(format!("Failed to parse folder response: {e}")))?;
91        folder_resp["id"]
92            .as_str()
93            .ok_or_else(|| SetupError("Google Drive folder response missing 'id'".to_string()))?
94            .to_string()
95    };
96
97    // Save tokens to keyring
98    let token_json = serde_json::to_string(&tokens)
99        .map_err(|e| SetupError(format!("Failed to serialize tokens: {e}")))?;
100    key_service
101        .set_cloud_home_credentials(&CloudHomeCredentials::OAuth { token_json })
102        .map_err(|e| SetupError(format!("Failed to save OAuth token: {e}")))?;
103
104    info!("Authorized Google Drive; folder ready");
105    Ok(folder_id)
106}
107
108/// Dropbox OAuth sign-in: authorize, create the library folder, save tokens to
109/// the keyring. Returns the folder path for the host to persist in its config.
110pub async fn sign_in_dropbox(
111    key_service: &KeyService,
112    library_name: &str,
113    oauth_cancel: tokio::sync::watch::Receiver<bool>,
114    clock: &dyn crate::clock::Clock,
115) -> Result<String, SetupError> {
116    let oauth_config = super::dropbox::DropboxCloudHome::oauth_config();
117    let tokens = crate::oauth::authorize(&oauth_config, oauth_cancel, clock)
118        .await
119        .map_err(|e| SetupError(format!("Dropbox authorization failed: {e}")))?;
120
121    let client = reqwest::Client::new();
122
123    let folder_path = format!("/Apps/bae/{library_name}");
124
125    // Create the folder (ignore error if it already exists)
126    let create_body = serde_json::json!({
127        "path": folder_path,
128        "autorename": false,
129    });
130    let resp = client
131        .post("https://api.dropboxapi.com/2/files/create_folder_v2")
132        .bearer_auth(&tokens.access_token)
133        .json(&create_body)
134        .send()
135        .await
136        .map_err(|e| SetupError(format!("Failed to create Dropbox folder: {e}")))?;
137
138    let status = resp.status();
139    if !status.is_success() {
140        let body = resp
141            .text()
142            .await
143            .unwrap_or_else(|e| format!("<body read failed: {e}>"));
144        // 409 with "path/conflict" means the folder already exists -- fine
145        if !(status == reqwest::StatusCode::CONFLICT && body.contains("conflict")) {
146            return Err(SetupError(format!(
147                "Failed to create Dropbox folder (HTTP {status}): {body}"
148            )));
149        }
150    }
151
152    // Save tokens to keyring
153    let token_json = serde_json::to_string(&tokens)
154        .map_err(|e| SetupError(format!("Failed to serialize tokens: {e}")))?;
155    key_service
156        .set_cloud_home_credentials(&CloudHomeCredentials::OAuth { token_json })
157        .map_err(|e| SetupError(format!("Failed to save OAuth token: {e}")))?;
158
159    info!("Authorized Dropbox; folder ready");
160    Ok(folder_path)
161}
162
163/// OneDrive OAuth sign-in: authorize, resolve the default drive, create the app
164/// folder, save tokens to the keyring. Returns `(drive_id, folder_id)` for the
165/// host to persist in its config.
166pub async fn sign_in_onedrive(
167    key_service: &KeyService,
168    oauth_cancel: tokio::sync::watch::Receiver<bool>,
169    clock: &dyn crate::clock::Clock,
170) -> Result<(String, String), SetupError> {
171    let oauth_config = super::onedrive::OneDriveCloudHome::oauth_config();
172    let tokens = crate::oauth::authorize(&oauth_config, oauth_cancel, clock)
173        .await
174        .map_err(|e| SetupError(format!("OneDrive authorization failed: {e}")))?;
175
176    let client = reqwest::Client::new();
177
178    // Get the user's default drive
179    let drive_resp = client
180        .get("https://graph.microsoft.com/v1.0/me/drive")
181        .bearer_auth(&tokens.access_token)
182        .send()
183        .await
184        .map_err(|e| SetupError(format!("Failed to get drive info: {e}")))?;
185
186    if !drive_resp.status().is_success() {
187        let body = drive_resp
188            .text()
189            .await
190            .unwrap_or_else(|e| format!("<body read failed: {e}>"));
191        return Err(SetupError(format!("Failed to get OneDrive info: {body}")));
192    }
193
194    let drive_json: serde_json::Value = drive_resp
195        .json()
196        .await
197        .map_err(|e| SetupError(format!("Failed to parse drive response: {e}")))?;
198
199    let drive_id = drive_json["id"]
200        .as_str()
201        .ok_or_else(|| SetupError("Drive response missing 'id' field".to_string()))?
202        .to_string();
203
204    // Create the app folder
205    let create_resp = client
206        .post(format!(
207            "https://graph.microsoft.com/v1.0/drives/{}/root/children",
208            drive_id
209        ))
210        .bearer_auth(&tokens.access_token)
211        .json(&serde_json::json!({
212            "name": "bae",
213            "folder": {},
214            "@microsoft.graph.conflictBehavior": "useExisting",
215        }))
216        .send()
217        .await
218        .map_err(|e| SetupError(format!("Failed to create OneDrive folder: {e}")))?;
219
220    if !create_resp.status().is_success() {
221        let body = create_resp
222            .text()
223            .await
224            .unwrap_or_else(|e| format!("<body read failed: {e}>"));
225        return Err(SetupError(format!(
226            "Failed to create OneDrive folder: {body}"
227        )));
228    }
229
230    let folder_json: serde_json::Value = create_resp
231        .json()
232        .await
233        .map_err(|e| SetupError(format!("Failed to parse folder response: {e}")))?;
234
235    let folder_id = folder_json["id"]
236        .as_str()
237        .ok_or_else(|| SetupError("Folder response missing 'id' field".to_string()))?
238        .to_string();
239
240    // Save tokens to keyring
241    let token_json = serde_json::to_string(&tokens)
242        .map_err(|e| SetupError(format!("Failed to serialize tokens: {e}")))?;
243    key_service
244        .set_cloud_home_credentials(&CloudHomeCredentials::OAuth { token_json })
245        .map_err(|e| SetupError(format!("Failed to save OAuth token: {e}")))?;
246
247    info!("Authorized OneDrive; folder ready");
248    Ok((drive_id, folder_id))
249}
250
251/// Get a display string for the current cloud account (bucket name, username, etc.)
252pub fn cloud_account_display_for(config: &Config, key_service: &KeyService) -> Option<String> {
253    match config.cloud_home.provider.as_ref()? {
254        CloudProvider::HttpProxy => config.cloud_home.http_url.clone(),
255        CloudProvider::S3 => config
256            .cloud_home
257            .s3_bucket
258            .as_ref()
259            .map(|b| format!("s3://{b}")),
260        CloudProvider::CloudKit => Some("iCloud".to_string()),
261        CloudProvider::GoogleDrive | CloudProvider::Dropbox | CloudProvider::OneDrive => {
262            match key_service.get_cloud_home_credentials() {
263                Ok(Some(CloudHomeCredentials::OAuth { .. })) => Some("Connected".to_string()),
264                Ok(_) => None,
265                Err(e) => {
266                    warn!("reading cloud home credentials for account display: {e}");
267                    None
268                }
269            }
270        }
271    }
272}
273
274/// Build a RestoreCode from config and keyring, then encode it.
275pub fn generate_restore_code(
276    config: &Config,
277    key_service: &KeyService,
278) -> Result<String, SetupError> {
279    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
280    use base64::Engine;
281
282    use crate::sync::restore_code::{encode_restore_code, RestoreCode, RestoreProvider};
283
284    let cloud_provider = config.cloud_home.provider.as_ref().ok_or_else(|| {
285        SetupError("No cloud provider configured. Set up sync first.".to_string())
286    })?;
287
288    let encryption_key_hex = key_service
289        .get_encryption_key()
290        .map_err(|e| SetupError(format!("Failed to read encryption key: {e}")))?
291        .ok_or_else(|| SetupError("No encryption key found".to_string()))?;
292
293    let keypair = key_service
294        .get_or_create_user_keypair()
295        .map_err(|e| SetupError(format!("Failed to get signing key: {e}")))?;
296
297    let provider = match cloud_provider {
298        CloudProvider::S3 => {
299            let creds = key_service
300                .get_cloud_home_credentials()
301                .map_err(|e| SetupError(format!("Failed to read cloud credentials: {e}")))?
302                .ok_or_else(|| SetupError("No S3 credentials found in keyring".to_string()))?;
303            let (access_key, secret_key) = match creds {
304                CloudHomeCredentials::S3 {
305                    access_key,
306                    secret_key,
307                } => (access_key, secret_key),
308                _ => {
309                    return Err(SetupError(
310                        "Expected S3 credentials but found different type".to_string(),
311                    ))
312                }
313            };
314            let bucket = config
315                .cloud_home
316                .s3_bucket
317                .clone()
318                .ok_or_else(|| SetupError("S3 bucket not configured".to_string()))?;
319            let region = config
320                .cloud_home
321                .s3_region
322                .clone()
323                .ok_or_else(|| SetupError("S3 region not configured".to_string()))?;
324            RestoreProvider::S3 {
325                bucket,
326                region,
327                endpoint: config.cloud_home.s3_endpoint.clone(),
328                key_prefix: config.cloud_home.s3_key_prefix.clone(),
329                access_key,
330                secret_key,
331            }
332        }
333        CloudProvider::CloudKit => RestoreProvider::CloudKit,
334        CloudProvider::GoogleDrive => RestoreProvider::GoogleDrive {
335            folder_id: config
336                .cloud_home
337                .google_drive_folder_id
338                .clone()
339                .ok_or_else(|| SetupError("Google Drive folder ID not configured".to_string()))?,
340        },
341        CloudProvider::Dropbox => RestoreProvider::Dropbox {
342            folder_path: config
343                .cloud_home
344                .dropbox_folder_path
345                .clone()
346                .ok_or_else(|| SetupError("Dropbox folder path not configured".to_string()))?,
347        },
348        CloudProvider::OneDrive => {
349            let drive_id = config
350                .cloud_home
351                .onedrive_drive_id
352                .clone()
353                .ok_or_else(|| SetupError("OneDrive drive ID not configured".to_string()))?;
354            let folder_id = config
355                .cloud_home
356                .onedrive_folder_id
357                .clone()
358                .ok_or_else(|| SetupError("OneDrive folder ID not configured".to_string()))?;
359            RestoreProvider::OneDrive {
360                drive_id,
361                folder_id,
362            }
363        }
364        CloudProvider::HttpProxy => RestoreProvider::HttpProxy {
365            url: config
366                .cloud_home
367                .http_url
368                .clone()
369                .ok_or_else(|| SetupError("HTTP proxy URL not configured".to_string()))?,
370        },
371    };
372
373    let code = RestoreCode {
374        v: 1,
375        lid: config.library_id.clone(),
376        ek: encryption_key_hex,
377        name: config.library_name.clone(),
378        provider,
379        sk: URL_SAFE_NO_PAD.encode(keypair.signing_key),
380    };
381
382    Ok(encode_restore_code(&code))
383}
384
385/// Create sync storage from config and credentials.
386///
387/// This is a lighter version of `sync::cycle::init_sync` that only creates the
388/// storage client without starting a sync session or extracting raw DB handles.
389/// Used by membership management which only needs storage access.
390pub async fn create_sync_storage(
391    config: &Config,
392    key_service: &KeyService,
393    encryption_service: &Option<crate::encryption::EncryptionService>,
394    clock: crate::clock::ClockRef,
395) -> Result<crate::sync::encrypted_storage::EncryptedSyncStorage, String> {
396    let cloud_home = super::create_cloud_home(config, key_service, clock)
397        .await
398        .map_err(|e| format!("{e}"))?;
399
400    let encryption = match encryption_service {
401        Some(enc) => enc.clone(),
402        None => {
403            let key = key_service
404                .get_encryption_key()
405                .map_err(|e| format!("Failed to read encryption key: {e}"))?
406                .ok_or("No encryption key found")?;
407            crate::encryption::EncryptionService::new(&key)
408                .map_err(|e| format!("Failed to create encryption service: {e}"))?
409        }
410    };
411
412    Ok(crate::sync::encrypted_storage::EncryptedSyncStorage::new(
413        cloud_home, encryption,
414    ))
415}
416
417/// Cloud provider setup error.
418#[derive(Debug, thiserror::Error)]
419#[error("{0}")]
420pub struct SetupError(pub String);