1use 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
18pub struct DropboxCloudHome {
20 client: reqwest::Client,
21 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 fn full_path(&self, key: &str) -> String {
56 format!("{}/{}", self.folder_path, key)
57 }
58
59 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 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 if let Some(id) = json["shared_folder_id"].as_str() {
90 return Ok(id.to_string());
91 }
92
93 if let Some(id) = json["error"]["shared_folder_metadata"]["shared_folder_id"].as_str() {
95 return Ok(id.to_string());
96 }
97
98 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 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, }
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 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 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 if entry[".tag"].as_str() != Some("file") {
362 continue;
363 }
364 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 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 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 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 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 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}