coven/storage/cloud/
dropbox.rs

1//! Dropbox `CloudHome` implementation.
2//!
3//! Uses the Dropbox HTTP API v2 with OAuth 2.0 (PKCE) tokens.
4//! Files are stored in a folder (e.g. `/Apps/bae/{library_name}`) using native
5//! path-based access -- no filename encoding needed unlike Google Drive.
6
7use async_trait::async_trait;
8
9use super::oauth_session::OAuthSession;
10use super::{CloudHome, CloudHomeError, CloudHomeJoinInfo};
11use crate::clock::ClockRef;
12use crate::keys::KeyService;
13use crate::oauth::{OAuthConfig, OAuthTokens};
14
15const API_BASE: &str = "https://api.dropboxapi.com/2";
16const CONTENT_BASE: &str = "https://content.dropboxapi.com/2";
17
18/// Dropbox cloud home backend.
19pub struct DropboxCloudHome {
20    client: reqwest::Client,
21    /// Folder path in Dropbox, e.g. "/Apps/bae/my-library"
22    folder_path: String,
23    session: OAuthSession,
24}
25
26impl DropboxCloudHome {
27    pub fn new(
28        folder_path: String,
29        tokens: OAuthTokens,
30        key_service: KeyService,
31        clock: ClockRef,
32    ) -> Self {
33        Self {
34            client: reqwest::Client::new(),
35            folder_path,
36            session: OAuthSession::new(tokens, key_service, clock, Self::oauth_config(), "Dropbox"),
37        }
38    }
39
40    pub fn oauth_config() -> OAuthConfig {
41        let creds = crate::oauth::oauth_client_creds("dropbox");
42        OAuthConfig {
43            client_id: creds.client_id,
44            client_secret: creds.client_secret,
45            auth_url: "https://www.dropbox.com/oauth2/authorize".to_string(),
46            token_url: "https://api.dropboxapi.com/oauth2/token".to_string(),
47            scopes: vec![],
48            redirect_port: 19284,
49            extra_auth_params: vec![("token_access_type".to_string(), "offline".to_string())],
50        }
51    }
52
53    /// Build the full Dropbox path for a key.
54    /// `changes/dev1/42.enc` -> `/Apps/bae/my-library/changes/dev1/42.enc`
55    fn full_path(&self, key: &str) -> String {
56        format!("{}/{}", self.folder_path, key)
57    }
58
59    /// Make an API call with automatic token refresh on 401.
60    async fn api_call(
61        &self,
62        build_request: impl Fn(&str) -> reqwest::RequestBuilder,
63    ) -> Result<reqwest::Response, CloudHomeError> {
64        self.session.api_call(build_request).await
65    }
66
67    /// Call `share_folder` and resolve the shared_folder_id, handling both
68    /// immediate and async_job_id responses.
69    async fn get_or_create_shared_folder_id(&self) -> Result<String, CloudHomeError> {
70        let share_body = serde_json::json!({ "path": self.folder_path });
71
72        let resp = self
73            .api_call(|token| {
74                self.client
75                    .post(format!("{}/sharing/share_folder", API_BASE))
76                    .bearer_auth(token)
77                    .json(&share_body)
78            })
79            .await?;
80
81        let status = resp.status();
82        let resp_body = resp
83            .text()
84            .await
85            .unwrap_or_else(|e| format!("<body read failed: {e}>"));
86        let json: serde_json::Value = serde_json::from_str(&resp_body).unwrap_or_default();
87
88        // Immediate: {".tag": "complete", "shared_folder_id": "..."}
89        if let Some(id) = json["shared_folder_id"].as_str() {
90            return Ok(id.to_string());
91        }
92
93        // Already shared: error payload contains the shared_folder_metadata
94        if let Some(id) = json["error"]["shared_folder_metadata"]["shared_folder_id"].as_str() {
95            return Ok(id.to_string());
96        }
97
98        // Async: {".tag": "async_job_id", "async_job_id": "..."}
99        if let Some(job_id) = json["async_job_id"].as_str() {
100            return self.poll_share_job(job_id).await;
101        }
102
103        if !status.is_success() {
104            return Err(CloudHomeError::Storage(format!(
105                "share folder (HTTP {status}): {resp_body}"
106            )));
107        }
108
109        Err(CloudHomeError::Storage(
110            "could not determine shared_folder_id".to_string(),
111        ))
112    }
113
114    /// Poll `check_share_job_status` until the share operation completes.
115    async fn poll_share_job(&self, job_id: &str) -> Result<String, CloudHomeError> {
116        let body = serde_json::json!({ "async_job_id": job_id });
117
118        for _ in 0..30 {
119            tokio::time::sleep(std::time::Duration::from_secs(1)).await;
120
121            let resp = self
122                .api_call(|token| {
123                    self.client
124                        .post(format!("{}/sharing/check_share_job_status", API_BASE))
125                        .bearer_auth(token)
126                        .json(&body)
127                })
128                .await?;
129
130            let resp_body = resp
131                .text()
132                .await
133                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
134            let json: serde_json::Value = serde_json::from_str(&resp_body).unwrap_or_default();
135
136            match json[".tag"].as_str() {
137                Some("complete") => {
138                    if let Some(id) = json["shared_folder_id"].as_str() {
139                        return Ok(id.to_string());
140                    }
141                    return Err(CloudHomeError::Storage(
142                        "share job completed but no shared_folder_id".to_string(),
143                    ));
144                }
145                Some("failed") => {
146                    return Err(CloudHomeError::Storage(format!(
147                        "share folder job failed: {resp_body}"
148                    )));
149                }
150                _ => continue, // "in_progress" — keep polling
151            }
152        }
153
154        Err(CloudHomeError::Storage(
155            "share folder timed out after 30 seconds".to_string(),
156        ))
157    }
158}
159
160#[async_trait]
161impl CloudHome for DropboxCloudHome {
162    async fn write(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError> {
163        let path = self.full_path(key);
164        let api_arg = serde_json::json!({
165            "path": path,
166            "mode": { ".tag": "overwrite" },
167            "autorename": false,
168            "mute": true,
169        });
170        let api_arg_str = api_arg.to_string();
171
172        let resp = self
173            .api_call(|token| {
174                self.client
175                    .post(format!("{}/files/upload", CONTENT_BASE))
176                    .bearer_auth(token)
177                    .header("Dropbox-API-Arg", &api_arg_str)
178                    .header("Content-Type", "application/octet-stream")
179                    .body(data.clone())
180            })
181            .await?;
182
183        let status = resp.status();
184        if !status.is_success() {
185            let body = resp
186                .text()
187                .await
188                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
189            return Err(CloudHomeError::Storage(format!(
190                "write {key} (HTTP {status}): {body}"
191            )));
192        }
193
194        Ok(())
195    }
196
197    async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError> {
198        let path = self.full_path(key);
199        let api_arg = serde_json::json!({ "path": path });
200        let api_arg_str = api_arg.to_string();
201
202        let resp = self
203            .api_call(|token| {
204                self.client
205                    .post(format!("{}/files/download", CONTENT_BASE))
206                    .bearer_auth(token)
207                    .header("Dropbox-API-Arg", &api_arg_str)
208            })
209            .await?;
210
211        let status = resp.status();
212        if status == reqwest::StatusCode::CONFLICT {
213            let body = resp
214                .text()
215                .await
216                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
217            if body.contains("not_found") {
218                return Err(CloudHomeError::NotFound(key.to_string()));
219            }
220            return Err(CloudHomeError::Storage(format!(
221                "read {key} (HTTP {status}): {body}"
222            )));
223        }
224        if !status.is_success() {
225            let body = resp
226                .text()
227                .await
228                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
229            return Err(CloudHomeError::Storage(format!(
230                "read {key} (HTTP {status}): {body}"
231            )));
232        }
233
234        let bytes = resp
235            .bytes()
236            .await
237            .map_err(|e| CloudHomeError::Storage(format!("read body for {key}: {e}")))?;
238
239        Ok(bytes.to_vec())
240    }
241
242    async fn read_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, CloudHomeError> {
243        let path = self.full_path(key);
244        let api_arg = serde_json::json!({ "path": path });
245        let api_arg_str = api_arg.to_string();
246        let range = format!("bytes={}-{}", start, end.saturating_sub(1));
247
248        let resp = self
249            .api_call(|token| {
250                self.client
251                    .post(format!("{}/files/download", CONTENT_BASE))
252                    .bearer_auth(token)
253                    .header("Dropbox-API-Arg", &api_arg_str)
254                    .header("Range", &range)
255            })
256            .await?;
257
258        let status = resp.status();
259        if status == reqwest::StatusCode::CONFLICT {
260            let body = resp
261                .text()
262                .await
263                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
264            if body.contains("not_found") {
265                return Err(CloudHomeError::NotFound(key.to_string()));
266            }
267            return Err(CloudHomeError::Storage(format!(
268                "read range {key} (HTTP {status}): {body}"
269            )));
270        }
271        if !status.is_success() && status != reqwest::StatusCode::PARTIAL_CONTENT {
272            let body = resp
273                .text()
274                .await
275                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
276            return Err(CloudHomeError::Storage(format!(
277                "read range {key} (HTTP {status}): {body}"
278            )));
279        }
280
281        let bytes = resp
282            .bytes()
283            .await
284            .map_err(|e| CloudHomeError::Storage(format!("read range body for {key}: {e}")))?;
285
286        Ok(bytes.to_vec())
287    }
288
289    async fn list(&self, prefix: &str) -> Result<Vec<String>, CloudHomeError> {
290        // List from the root folder_path with recursive=true, then filter by prefix.
291        // Dropbox list_folder needs a folder path, not a prefix, so we always
292        // start from the root and filter results.
293        let search_path = self.folder_path.clone();
294
295        let mut all_keys = Vec::new();
296        let mut cursor: Option<String> = None;
297
298        loop {
299            let resp = if let Some(ref cur) = cursor {
300                let body = serde_json::json!({ "cursor": cur });
301                self.api_call(|token| {
302                    self.client
303                        .post(format!("{}/files/list_folder/continue", API_BASE))
304                        .bearer_auth(token)
305                        .json(&body)
306                })
307                .await?
308            } else {
309                let body = serde_json::json!({
310                    "path": search_path,
311                    "recursive": true,
312                    "limit": 2000,
313                });
314                self.api_call(|token| {
315                    self.client
316                        .post(format!("{}/files/list_folder", API_BASE))
317                        .bearer_auth(token)
318                        .json(&body)
319                })
320                .await?
321            };
322
323            let status = resp.status();
324
325            // If the folder doesn't exist, return empty list
326            if status == reqwest::StatusCode::CONFLICT {
327                let body = resp
328                    .text()
329                    .await
330                    .unwrap_or_else(|e| format!("<body read failed: {e}>"));
331                if body.contains("not_found") {
332                    return Ok(Vec::new());
333                }
334                return Err(CloudHomeError::Storage(format!(
335                    "list {prefix} (HTTP {status}): {body}"
336                )));
337            }
338
339            if !status.is_success() {
340                let body = resp
341                    .text()
342                    .await
343                    .unwrap_or_else(|e| format!("<body read failed: {e}>"));
344                return Err(CloudHomeError::Storage(format!(
345                    "list {prefix} (HTTP {status}): {body}"
346                )));
347            }
348
349            let body = resp
350                .text()
351                .await
352                .map_err(|e| CloudHomeError::Storage(format!("read body: {e}")))?;
353            let json: serde_json::Value = serde_json::from_str(&body)
354                .map_err(|e| CloudHomeError::Storage(format!("parse list: {e}")))?;
355
356            let folder_lower = self.folder_path.to_lowercase();
357
358            if let Some(entries) = json["entries"].as_array() {
359                for entry in entries {
360                    // Only include files, not folders
361                    if entry[".tag"].as_str() != Some("file") {
362                        continue;
363                    }
364                    // Use path_lower for reliable prefix stripping (path_display
365                    // has inconsistent casing), then use path_display for the
366                    // actual key value to preserve original casing.
367                    if let (Some(path_lower), Some(path_display)) =
368                        (entry["path_lower"].as_str(), entry["path_display"].as_str())
369                    {
370                        let lower_prefix = format!("{}/", folder_lower);
371                        if path_lower.starts_with(&lower_prefix) {
372                            // Extract key from path_display at the same offset
373                            let key = &path_display[lower_prefix.len()..];
374                            if key.starts_with(prefix) {
375                                all_keys.push(key.to_string());
376                            }
377                        }
378                    }
379                }
380            }
381
382            let has_more = json["has_more"].as_bool().unwrap_or(false);
383            if has_more {
384                cursor = json["cursor"].as_str().map(|s| s.to_string());
385            } else {
386                break;
387            }
388        }
389
390        Ok(all_keys)
391    }
392
393    async fn delete(&self, key: &str) -> Result<(), CloudHomeError> {
394        let path = self.full_path(key);
395        let body = serde_json::json!({ "path": path });
396
397        let resp = self
398            .api_call(|token| {
399                self.client
400                    .post(format!("{}/files/delete_v2", API_BASE))
401                    .bearer_auth(token)
402                    .json(&body)
403            })
404            .await?;
405
406        let status = resp.status();
407
408        // 409 with path_lookup/not_found means already deleted -- treat as success
409        if status == reqwest::StatusCode::CONFLICT {
410            let body = resp
411                .text()
412                .await
413                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
414            if body.contains("not_found") {
415                return Ok(());
416            }
417            return Err(CloudHomeError::Storage(format!(
418                "delete {key} (HTTP {status}): {body}"
419            )));
420        }
421
422        if !status.is_success() {
423            let body = resp
424                .text()
425                .await
426                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
427            return Err(CloudHomeError::Storage(format!(
428                "delete {key} (HTTP {status}): {body}"
429            )));
430        }
431
432        Ok(())
433    }
434
435    async fn exists(&self, key: &str) -> Result<bool, CloudHomeError> {
436        let path = self.full_path(key);
437        let body = serde_json::json!({ "path": path });
438
439        let resp = self
440            .api_call(|token| {
441                self.client
442                    .post(format!("{}/files/get_metadata", API_BASE))
443                    .bearer_auth(token)
444                    .json(&body)
445            })
446            .await?;
447
448        let status = resp.status();
449        if status == reqwest::StatusCode::CONFLICT {
450            let body = resp
451                .text()
452                .await
453                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
454            if body.contains("not_found") {
455                return Ok(false);
456            }
457            return Err(CloudHomeError::Storage(format!(
458                "exists {key} (HTTP {status}): {body}"
459            )));
460        }
461
462        if !status.is_success() {
463            let body = resp
464                .text()
465                .await
466                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
467            return Err(CloudHomeError::Storage(format!(
468                "exists {key} (HTTP {status}): {body}"
469            )));
470        }
471
472        Ok(true)
473    }
474
475    async fn grant_access(&self, member_id: &str) -> Result<CloudHomeJoinInfo, CloudHomeError> {
476        let shared_folder_id = self.get_or_create_shared_folder_id().await?;
477
478        // Now add the member
479        let add_body = serde_json::json!({
480            "shared_folder_id": shared_folder_id,
481            "members": [{
482                "member": {
483                    ".tag": "email",
484                    "email": member_id,
485                },
486                "access_level": { ".tag": "editor" },
487            }],
488            "quiet": false,
489        });
490
491        let resp = self
492            .api_call(|token| {
493                self.client
494                    .post(format!("{}/sharing/add_folder_member", API_BASE))
495                    .bearer_auth(token)
496                    .json(&add_body)
497            })
498            .await?;
499
500        let status = resp.status();
501        if !status.is_success() {
502            let body = resp
503                .text()
504                .await
505                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
506            return Err(CloudHomeError::Storage(format!(
507                "grant access to {member_id} (HTTP {status}): {body}"
508            )));
509        }
510
511        Ok(CloudHomeJoinInfo::Dropbox { shared_folder_id })
512    }
513
514    async fn revoke_access(&self, member_id: &str) -> Result<(), CloudHomeError> {
515        let shared_folder_id = self.get_or_create_shared_folder_id().await?;
516
517        // Remove the member
518        let remove_body = serde_json::json!({
519            "shared_folder_id": shared_folder_id,
520            "member": {
521                ".tag": "email",
522                "email": member_id,
523            },
524            "leave_a_copy": false,
525        });
526
527        let resp = self
528            .api_call(|token| {
529                self.client
530                    .post(format!("{}/sharing/remove_folder_member", API_BASE))
531                    .bearer_auth(token)
532                    .json(&remove_body)
533            })
534            .await?;
535
536        let status = resp.status();
537        if !status.is_success() {
538            let body = resp
539                .text()
540                .await
541                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
542
543            // If the member is not found, treat as success
544            if body.contains("not_found") || body.contains("member_error") {
545                return Ok(());
546            }
547
548            return Err(CloudHomeError::Storage(format!(
549                "revoke access for {member_id} (HTTP {status}): {body}"
550            )));
551        }
552
553        Ok(())
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use std::sync::Arc;
561
562    #[test]
563    fn full_path_joins_correctly() {
564        let home = DropboxCloudHome::new(
565            "/Apps/bae/my-library".to_string(),
566            OAuthTokens {
567                access_token: String::new(),
568                refresh_token: None,
569                expires_at: None,
570            },
571            KeyService::new(true, "test".to_string()),
572            Arc::new(crate::clock::SystemClock),
573        );
574
575        assert_eq!(
576            home.full_path("changes/dev1/42.enc"),
577            "/Apps/bae/my-library/changes/dev1/42.enc"
578        );
579        assert_eq!(
580            home.full_path("snapshot.db.enc"),
581            "/Apps/bae/my-library/snapshot.db.enc"
582        );
583    }
584
585    #[test]
586    fn oauth_config_uses_dropbox_urls() {
587        let config = DropboxCloudHome::oauth_config();
588        assert_eq!(config.auth_url, "https://www.dropbox.com/oauth2/authorize");
589        assert_eq!(config.token_url, "https://api.dropboxapi.com/oauth2/token");
590        assert!(config.client_secret.is_none());
591        assert!(config.scopes.is_empty());
592    }
593}