Skip to main content

coven_storage/providers/
oauth_folders.rs

1//! Choosing provider folders through coven-held sign-in, before storage setup.
2
3use super::{
4    http::{self, Body},
5    onedrive_item::{item_kind, Kind},
6    pagination::Pagination,
7    OAuthSession,
8};
9use crate::{
10    Cooldown, DropboxAccess, DropboxLocation, OAuthAccess, StorageConfig, StorageError,
11    StorageFailure,
12};
13use reqwest::Method;
14use serde_json::{json, Value};
15use std::sync::Arc;
16
17/// A provider folder's display name and the exact location accepted by setup.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct StorageFolder {
20    /// The provider's current folder name; never a token or credential.
21    pub name: String,
22    /// Pass unchanged to storage setup, or as a parent when browsing OneDrive.
23    pub storage: StorageConfig,
24}
25
26/// Provider metadata calls using the same session and cooldown as store setup.
27pub(super) struct OAuthFolders {
28    session: OAuthSession,
29    access: OAuthAccess,
30    api: String,
31    cooldown: Arc<dyn Cooldown>,
32}
33
34impl OAuthFolders {
35    pub(super) fn new(
36        session: OAuthSession,
37        access: OAuthAccess,
38        api: String,
39        cooldown: Arc<dyn Cooldown>,
40    ) -> Self {
41        Self { session, access, api, cooldown }
42    }
43
44    pub(super) async fn account_label(&self) -> Result<String, StorageError> {
45        crate::cooldown::cooling(self.cooldown.as_ref())?;
46        let result = self.session.account_at(&self.api).await;
47        crate::cooldown::observed(self.cooldown.as_ref(), result).await
48    }
49
50    fn require(&self, access: OAuthAccess) -> Result<(), StorageError> {
51        if self.access != access {
52            return Err(StorageFailure::InvalidConfiguration
53                .with_source("folder selection requires the matching sign-in access"));
54        }
55        Ok(())
56    }
57
58    async fn request(&self, method: Method, url: &str, body: Body) -> Result<Value, StorageError> {
59        Ok(self.request_response(method, url, body).await?.0)
60    }
61
62    /// `request`'s answer with the response it came in, for a status whose
63    /// failure the response describes.
64    async fn request_response(
65        &self,
66        method: Method,
67        url: &str,
68        body: Body,
69    ) -> Result<(Value, http::ProviderResponse), StorageError> {
70        crate::cooldown::cooling(self.cooldown.as_ref())?;
71        http::same_origin(&self.api, url)?;
72        let result = async {
73            http::json_response(
74                self.access.provider(),
75                self.session.send(method, url, &[], body, true).await?,
76            )
77            .await
78        }
79        .await;
80        crate::cooldown::observed(self.cooldown.as_ref(), result).await
81    }
82
83    /// The signed-in person's default OneDrive root, including its actual item id.
84    pub(super) async fn onedrive_root(&self) -> Result<StorageFolder, StorageError> {
85        self.require(OAuthAccess::OneDrive)?;
86        let url = http::endpoint(&self.api, &["me", "drive", "root"], &[])?;
87        let value = self.request(Method::GET, &url, Body::Empty).await?;
88        let drive = http::string(&value["parentReference"], "driveId")?;
89        if item_kind(&value)? != Kind::Folder {
90            return Err(StorageFailure::Protocol.with_source("OneDrive did not return a folder"));
91        }
92        onedrive_folder(&value, drive, http::string(&value, "name")?)
93    }
94
95    /// The immediate child folders; every page is read, without reading file bytes.
96    pub(super) async fn onedrive_folders(
97        &self,
98        parent: &StorageConfig,
99    ) -> Result<Vec<StorageFolder>, StorageError> {
100        self.require(OAuthAccess::OneDrive)?;
101        let (drive, folder) = onedrive_parent(parent)?;
102        let mut url =
103            http::endpoint(&self.api, &["drives", drive, "items", folder, "children"], &[])?;
104        let mut pages = Pagination::new();
105        let mut folders = Vec::new();
106        loop {
107            pages.check(&url)?;
108            let value = self.request(Method::GET, &url, Body::Empty).await?;
109            for item in http::array(&value, "value")? {
110                if let Some(folder) = onedrive_child(item, drive)? {
111                    folders.push(folder);
112                }
113            }
114            match http::next_link(&value)? {
115                None => return Ok(folders),
116                Some(next) => url = next.into(),
117            }
118        }
119    }
120
121    /// Create exactly the requested name. Occupied names remain provider errors.
122    pub(super) async fn create_onedrive_folder(
123        &self,
124        parent: &StorageConfig,
125        name: &str,
126    ) -> Result<StorageFolder, StorageError> {
127        self.require(OAuthAccess::OneDrive)?;
128        let (drive, folder) = onedrive_parent(parent)?;
129        let url = http::endpoint(&self.api, &["drives", drive, "items", folder, "children"], &[])?;
130        let value = self
131            .request(
132                Method::POST,
133                &url,
134                Body::Json(crate::providers::onedrive_item::new_folder(name)),
135            )
136            .await?;
137        if item_kind(&value)? != Kind::Folder {
138            return Err(StorageFailure::Protocol.with_source("OneDrive did not return a folder"));
139        }
140        onedrive_folder(&value, drive, http::string(&value, "name")?)
141    }
142
143    /// Shared folders the signed-in Dropbox account can access, including all pages.
144    pub(super) async fn dropbox_shared_folders(&self) -> Result<Vec<StorageFolder>, StorageError> {
145        if self.access == OAuthAccess::Dropbox(DropboxAccess::AppFolder) {
146            return Err(StorageFailure::DropboxAppFolderNotShareable.into());
147        }
148        self.require(OAuthAccess::Dropbox(DropboxAccess::FullDropbox))?;
149        let mut url = http::endpoint(&self.api, &["sharing", "list_folders"], &[])?;
150        let mut body = json!({});
151        let mut pages = Pagination::new();
152        let mut folders = Vec::new();
153        loop {
154            let value = self.request(Method::POST, &url, Body::Json(body)).await?;
155            for item in http::array(&value, "entries")? {
156                folders.push(dropbox_folder(item)?);
157            }
158            match http::page_cursor(&value)? {
159                None => return Ok(folders),
160                Some(cursor) => {
161                    pages.check(cursor)?;
162                    url = http::endpoint(&self.api, &["sharing", "list_folders", "continue"], &[])?;
163                    body = json!({"cursor": cursor});
164                }
165            }
166        }
167    }
168
169    /// Make a folder named `name` at the top of the signed-in Dropbox and
170    /// share it. The name is looked up first, so a name already taken fails
171    /// with `AlreadyExists` and nothing of the person's is shared; then one
172    /// `share_folder` request makes the folder and shares it, and its job, if
173    /// Dropbox runs one, is checked until it completes.
174    pub(super) async fn create_dropbox_shared_folder(
175        &self,
176        name: &str,
177    ) -> Result<StorageFolder, StorageError> {
178        if self.access == OAuthAccess::Dropbox(DropboxAccess::AppFolder) {
179            return Err(StorageFailure::DropboxAppFolderNotShareable.into());
180        }
181        self.require(OAuthAccess::Dropbox(DropboxAccess::FullDropbox))?;
182        if name.is_empty() || name.contains('/') {
183            return Err(StorageFailure::InvalidConfiguration
184                .with_source("a Dropbox folder name is one non-empty path segment"));
185        }
186        let path = format!("/{name}");
187        let lookup = http::endpoint(&self.api, &["files", "get_metadata"], &[])?;
188        match self.request(Method::POST, &lookup, Body::Json(json!({ "path": path }))).await {
189            Ok(_) => {
190                return Err(StorageFailure::AlreadyExists
191                    .with_source("the Dropbox folder name is already taken"))
192            }
193            Err(error) if error.failure() == StorageFailure::NotFound => {}
194            Err(error) => return Err(error),
195        }
196        let share = http::endpoint(&self.api, &["sharing", "share_folder"], &[])?;
197        let launched = self
198            .request(Method::POST, &share, Body::Json(json!({"path":path,"force_async":false})))
199            .await?;
200        let shared = match http::string(&launched, ".tag")? {
201            "complete" => launched,
202            "async_job_id" => {
203                let job = http::string(&launched, "async_job_id")?;
204                let status =
205                    http::endpoint(&self.api, &["sharing", "check_share_job_status"], &[])?;
206                super::dropbox_job::completed(&self.session, "folder sharing", || {
207                    self.request_response(
208                        Method::POST,
209                        &status,
210                        Body::Json(json!({"async_job_id":job})),
211                    )
212                })
213                .await?
214            }
215            _ => return Err(StorageFailure::Protocol.with_source("invalid Dropbox share launch")),
216        };
217        dropbox_folder(&shared)
218    }
219}
220
221/// The shared folder Dropbox's metadata `item` describes, as a setup
222/// location.
223fn dropbox_folder(item: &Value) -> Result<StorageFolder, StorageError> {
224    let storage = StorageConfig::Dropbox {
225        account_label: None,
226        folder_label: None,
227        location: DropboxLocation::SharedFolder {
228            namespace_id: http::string(item, "shared_folder_id")?.into(),
229        },
230    };
231    storage.validate().map_err(|error| StorageFailure::Protocol.with_source(error))?;
232    Ok(StorageFolder { name: http::string(item, "name")?.into(), storage })
233}
234
235fn onedrive_parent(parent: &StorageConfig) -> Result<(&str, &str), StorageError> {
236    parent.validate()?;
237    match parent {
238        StorageConfig::OneDrive { drive_id, folder_id, .. } => Ok((drive_id, folder_id)),
239        _ => {
240            Err(StorageFailure::InvalidConfiguration.with_source("parent is not a OneDrive folder"))
241        }
242    }
243}
244
245fn onedrive_child(item: &Value, parent_drive: &str) -> Result<Option<StorageFolder>, StorageError> {
246    let remote = match item.get("remoteItem") {
247        None | Some(Value::Null) => None,
248        Some(value) if value.is_object() => Some(value),
249        Some(_) => return Err(StorageFailure::Protocol.with_source("invalid OneDrive remote item")),
250    };
251    let target = remote.unwrap_or(item);
252    if item_kind(target)? != Kind::Folder {
253        tracing::debug!("OneDrive folder picker skipped a non-folder item");
254        return Ok(None);
255    }
256    let drive = match remote {
257        Some(_) => http::string(&target["parentReference"], "driveId")?,
258        None => parent_drive,
259    };
260    Ok(Some(onedrive_folder(target, drive, http::string(item, "name")?)?))
261}
262
263fn onedrive_folder(value: &Value, drive: &str, name: &str) -> Result<StorageFolder, StorageError> {
264    if let Some(reference) = value.get("parentReference") {
265        if http::string(reference, "driveId")? != drive {
266            return Err(
267                StorageFailure::Protocol.with_source("OneDrive folder belongs to another drive")
268            );
269        }
270    }
271    let storage = StorageConfig::OneDrive {
272        account_label: None,
273        folder_label: None,
274        drive_id: drive.into(),
275        folder_id: http::string(value, "id")?.into(),
276    };
277    storage.validate().map_err(|error| StorageFailure::Protocol.with_source(error))?;
278    Ok(StorageFolder { name: name.into(), storage })
279}