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 GRAPH_API: &str = "https://graph.microsoft.com/v1.0";
16
17pub 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 fn encode_key(key: &str) -> String {
64 key.replace('/', "__")
65 }
66
67 fn decode_key(filename: &str) -> String {
70 filename.replace("__", "/")
71 }
72
73 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 fn children_url(&self) -> String {
84 format!(
85 "{}/drives/{}/items/{}/children",
86 GRAPH_API, self.drive_id, self.folder_id
87 )
88 }
89
90 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 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 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 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 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 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 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 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}