coven/storage/cloud/
onedrive.rs

1//! OneDrive `CloudHome` implementation.
2//!
3//! Uses the Microsoft Graph API. Files are stored flat in a single folder --
4//! path separators are encoded as `__` (same as Google Drive) to avoid
5//! sub-folder creation.
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 GRAPH_API: &str = "https://graph.microsoft.com/v1.0";
16
17/// OneDrive cloud home backend.
18pub struct OneDriveCloudHome {
19    client: reqwest::Client,
20    drive_id: String,
21    folder_id: String,
22    session: OAuthSession,
23}
24
25impl OneDriveCloudHome {
26    pub fn new(
27        drive_id: String,
28        folder_id: String,
29        tokens: OAuthTokens,
30        key_service: KeyService,
31        clock: ClockRef,
32    ) -> Self {
33        Self {
34            client: reqwest::Client::new(),
35            drive_id,
36            folder_id,
37            session: OAuthSession::new(
38                tokens,
39                key_service,
40                clock,
41                Self::oauth_config(),
42                "OneDrive",
43            ),
44        }
45    }
46
47    pub fn oauth_config() -> OAuthConfig {
48        let creds = crate::oauth::oauth_client_creds("onedrive");
49        OAuthConfig {
50            client_id: creds.client_id,
51            client_secret: creds.client_secret,
52            auth_url: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"
53                .to_string(),
54            token_url: "https://login.microsoftonline.com/consumers/oauth2/v2.0/token".to_string(),
55            scopes: vec!["Files.ReadWrite".to_string(), "offline_access".to_string()],
56            redirect_port: 19284,
57            extra_auth_params: vec![],
58        }
59    }
60
61    /// Encode a CloudHome key to a flat OneDrive filename.
62    /// `changes/dev1/42.enc` -> `changes__dev1__42.enc`
63    fn encode_key(key: &str) -> String {
64        key.replace('/', "__")
65    }
66
67    /// Decode a flat filename back to a CloudHome key.
68    /// `changes__dev1__42.enc` -> `changes/dev1/42.enc`
69    fn decode_key(filename: &str) -> String {
70        filename.replace("__", "/")
71    }
72
73    /// Build the Graph API URL for a file by encoded name within the app folder.
74    fn item_path_url(&self, key: &str) -> String {
75        let encoded = Self::encode_key(key);
76        format!(
77            "{}/drives/{}/items/{}:/{}:",
78            GRAPH_API, self.drive_id, self.folder_id, encoded
79        )
80    }
81
82    /// Build the Graph API URL for the folder's children endpoint.
83    fn children_url(&self) -> String {
84        format!(
85            "{}/drives/{}/items/{}/children",
86            GRAPH_API, self.drive_id, self.folder_id
87        )
88    }
89
90    /// Make an API call with automatic token refresh on 401.
91    async fn api_call(
92        &self,
93        build_request: impl Fn(&str) -> reqwest::RequestBuilder,
94    ) -> Result<reqwest::Response, CloudHomeError> {
95        self.session.api_call(build_request).await
96    }
97}
98
99#[async_trait]
100impl CloudHome for OneDriveCloudHome {
101    async fn write(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError> {
102        let url = format!("{}/content", self.item_path_url(key));
103
104        let resp = self
105            .api_call(|token| {
106                self.client
107                    .put(&url)
108                    .bearer_auth(token)
109                    .header("Content-Type", "application/octet-stream")
110                    .body(data.clone())
111            })
112            .await?;
113
114        let status = resp.status();
115        if !status.is_success() {
116            let body = resp
117                .text()
118                .await
119                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
120            return Err(CloudHomeError::Storage(format!(
121                "write {key} (HTTP {status}): {body}"
122            )));
123        }
124
125        Ok(())
126    }
127
128    async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError> {
129        let url = format!("{}/content", self.item_path_url(key));
130
131        let resp = self
132            .api_call(|token| self.client.get(&url).bearer_auth(token))
133            .await?;
134
135        let status = resp.status();
136        if status == reqwest::StatusCode::NOT_FOUND {
137            return Err(CloudHomeError::NotFound(key.to_string()));
138        }
139        if !status.is_success() {
140            let body = resp
141                .text()
142                .await
143                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
144            return Err(CloudHomeError::Storage(format!(
145                "read {key} (HTTP {status}): {body}"
146            )));
147        }
148
149        let bytes = resp
150            .bytes()
151            .await
152            .map_err(|e| CloudHomeError::Storage(format!("read body for {key}: {e}")))?;
153
154        Ok(bytes.to_vec())
155    }
156
157    async fn read_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, CloudHomeError> {
158        let url = format!("{}/content", self.item_path_url(key));
159        let range = format!("bytes={}-{}", start, end.saturating_sub(1));
160
161        let resp = self
162            .api_call(|token| {
163                self.client
164                    .get(&url)
165                    .bearer_auth(token)
166                    .header("Range", &range)
167            })
168            .await?;
169
170        let status = resp.status();
171        if status == reqwest::StatusCode::NOT_FOUND {
172            return Err(CloudHomeError::NotFound(key.to_string()));
173        }
174        if !status.is_success() && status != reqwest::StatusCode::PARTIAL_CONTENT {
175            let body = resp
176                .text()
177                .await
178                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
179            return Err(CloudHomeError::Storage(format!(
180                "read range {key} (HTTP {status}): {body}"
181            )));
182        }
183
184        let bytes = resp
185            .bytes()
186            .await
187            .map_err(|e| CloudHomeError::Storage(format!("read range body for {key}: {e}")))?;
188
189        Ok(bytes.to_vec())
190    }
191
192    async fn list(&self, prefix: &str) -> Result<Vec<String>, CloudHomeError> {
193        // All files are stored flat with encoded names. Fetch all children
194        // and filter client-side after decoding.
195        let mut all_keys = Vec::new();
196        let initial_url = format!("{}?$select=name", self.children_url());
197        let mut next_url: Option<String> = Some(initial_url);
198        let encoded_prefix = Self::encode_key(prefix);
199
200        while let Some(url) = next_url.take() {
201            let resp = self
202                .api_call(|token| self.client.get(&url).bearer_auth(token))
203                .await?;
204
205            let status = resp.status();
206            let body = resp
207                .text()
208                .await
209                .map_err(|e| CloudHomeError::Storage(format!("read body: {e}")))?;
210
211            if !status.is_success() {
212                return Err(CloudHomeError::Storage(format!(
213                    "list {prefix} (HTTP {status}): {body}"
214                )));
215            }
216
217            let json: serde_json::Value = serde_json::from_str(&body)
218                .map_err(|e| CloudHomeError::Storage(format!("parse list: {e}")))?;
219
220            if let Some(items) = json["value"].as_array() {
221                for item in items {
222                    if let Some(name) = item["name"].as_str() {
223                        if name.starts_with(&encoded_prefix) {
224                            all_keys.push(Self::decode_key(name));
225                        }
226                    }
227                }
228            }
229
230            // @odata.nextLink is a full URL with all params included
231            next_url = json["@odata.nextLink"].as_str().map(|s| s.to_string());
232        }
233
234        Ok(all_keys)
235    }
236
237    async fn delete(&self, key: &str) -> Result<(), CloudHomeError> {
238        let url = self.item_path_url(key);
239
240        let resp = self
241            .api_call(|token| self.client.delete(&url).bearer_auth(token))
242            .await?;
243
244        let status = resp.status();
245        // 204 No Content is success, 404 is OK (already deleted)
246        if !status.is_success() && status != reqwest::StatusCode::NOT_FOUND {
247            let body = resp
248                .text()
249                .await
250                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
251            return Err(CloudHomeError::Storage(format!(
252                "delete {key} (HTTP {status}): {body}"
253            )));
254        }
255
256        Ok(())
257    }
258
259    async fn exists(&self, key: &str) -> Result<bool, CloudHomeError> {
260        let url = self.item_path_url(key);
261
262        let resp = self
263            .api_call(|token| self.client.get(&url).bearer_auth(token))
264            .await?;
265
266        match resp.status() {
267            s if s.is_success() => Ok(true),
268            reqwest::StatusCode::NOT_FOUND => Ok(false),
269            status => {
270                let body = resp
271                    .text()
272                    .await
273                    .unwrap_or_else(|e| format!("<body read failed: {e}>"));
274                Err(CloudHomeError::Storage(format!(
275                    "exists {key} (HTTP {status}): {body}"
276                )))
277            }
278        }
279    }
280
281    async fn grant_access(&self, member_id: &str) -> Result<CloudHomeJoinInfo, CloudHomeError> {
282        let url = format!(
283            "{}/drives/{}/items/{}/invite",
284            GRAPH_API, self.drive_id, self.folder_id
285        );
286
287        let invite = serde_json::json!({
288            "recipients": [{"email": member_id}],
289            "roles": ["write"],
290            "requireSignIn": true,
291        });
292
293        let resp = self
294            .api_call(|token| self.client.post(&url).bearer_auth(token).json(&invite))
295            .await?;
296
297        let status = resp.status();
298        if !status.is_success() {
299            let body = resp
300                .text()
301                .await
302                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
303            return Err(CloudHomeError::Storage(format!(
304                "grant access to {member_id} (HTTP {status}): {body}"
305            )));
306        }
307
308        Ok(CloudHomeJoinInfo::OneDrive {
309            drive_id: self.drive_id.clone(),
310            folder_id: self.folder_id.clone(),
311        })
312    }
313
314    async fn revoke_access(&self, member_id: &str) -> Result<(), CloudHomeError> {
315        // First, list permissions on the folder to find the one matching member_id
316        let perms_url = format!(
317            "{}/drives/{}/items/{}/permissions",
318            GRAPH_API, self.drive_id, self.folder_id
319        );
320
321        let resp = self
322            .api_call(|token| self.client.get(&perms_url).bearer_auth(token))
323            .await?;
324
325        let status = resp.status();
326        let body = resp
327            .text()
328            .await
329            .map_err(|e| CloudHomeError::Storage(format!("read body: {e}")))?;
330
331        if !status.is_success() {
332            return Err(CloudHomeError::Storage(format!(
333                "list permissions (HTTP {status}): {body}"
334            )));
335        }
336
337        let json: serde_json::Value = serde_json::from_str(&body)
338            .map_err(|e| CloudHomeError::Storage(format!("parse permissions: {e}")))?;
339
340        // Find the permission entry whose grantedTo or grantedToV2 email matches member_id
341        let permission_id = json["value"]
342            .as_array()
343            .and_then(|perms| {
344                perms.iter().find_map(|p| {
345                    let email = p["grantedToV2"]["user"]["email"]
346                        .as_str()
347                        .or_else(|| p["grantedTo"]["user"]["email"].as_str());
348                    if email.map(|e| e.eq_ignore_ascii_case(member_id)) == Some(true) {
349                        p["id"].as_str().map(|s| s.to_string())
350                    } else {
351                        None
352                    }
353                })
354            })
355            .ok_or_else(|| {
356                CloudHomeError::Storage(format!("no permission found for {member_id}"))
357            })?;
358
359        // Delete the permission
360        let delete_url = format!("{}/{}", perms_url, permission_id);
361
362        let resp = self
363            .api_call(|token| self.client.delete(&delete_url).bearer_auth(token))
364            .await?;
365
366        let status = resp.status();
367        if !status.is_success() && status != reqwest::StatusCode::NOT_FOUND {
368            let body = resp
369                .text()
370                .await
371                .unwrap_or_else(|e| format!("<body read failed: {e}>"));
372            return Err(CloudHomeError::Storage(format!(
373                "revoke access for {member_id} (HTTP {status}): {body}"
374            )));
375        }
376
377        Ok(())
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use std::sync::Arc;
385
386    #[test]
387    fn item_path_url_encodes_key() {
388        let home = OneDriveCloudHome::new(
389            "drive123".to_string(),
390            "folder456".to_string(),
391            OAuthTokens {
392                access_token: "test".to_string(),
393                refresh_token: None,
394                expires_at: None,
395            },
396            KeyService::new(true, "test".to_string()),
397            Arc::new(crate::clock::SystemClock),
398        );
399
400        // Keys with slashes are encoded to flat filenames
401        assert_eq!(
402            home.item_path_url("changes/dev1/42.enc"),
403            "https://graph.microsoft.com/v1.0/drives/drive123/items/folder456:/changes__dev1__42.enc:"
404        );
405    }
406
407    #[test]
408    fn children_url_format() {
409        let home = OneDriveCloudHome::new(
410            "drive123".to_string(),
411            "folder456".to_string(),
412            OAuthTokens {
413                access_token: "test".to_string(),
414                refresh_token: None,
415                expires_at: None,
416            },
417            KeyService::new(true, "test".to_string()),
418            Arc::new(crate::clock::SystemClock),
419        );
420
421        assert_eq!(
422            home.children_url(),
423            "https://graph.microsoft.com/v1.0/drives/drive123/items/folder456/children"
424        );
425    }
426
427    #[test]
428    fn oauth_config_uses_consumers_endpoint() {
429        let config = OneDriveCloudHome::oauth_config();
430        assert!(config.auth_url.contains("/consumers/"));
431        assert!(config.token_url.contains("/consumers/"));
432        assert!(config.scopes.contains(&"Files.ReadWrite".to_string()));
433        assert!(config.scopes.contains(&"offline_access".to_string()));
434    }
435}