any_storage/
pcloud.rs

1use std::borrow::Cow;
2use std::io::{Error, ErrorKind, Result};
3use std::path::PathBuf;
4use std::pin::Pin;
5use std::sync::Arc;
6use std::task::Poll;
7
8use futures::Stream;
9use pcloud::file::FileIdentifier;
10use pcloud::folder::{FolderIdentifier, ROOT};
11use reqwest::header;
12use tokio::io::DuplexStream;
13use tokio::task::JoinHandle;
14use tokio_util::io::ReaderStream;
15
16use crate::WriteMode;
17use crate::http::{HttpStoreFileReader, RangeHeader};
18
19#[derive(Clone, Debug)]
20#[cfg_attr(feature = "serde", derive(serde::Deserialize))]
21pub enum PCloudStoreConfigOrigin {
22    Region { region: pcloud::Region },
23    Url { url: Cow<'static, str> },
24}
25
26impl Default for PCloudStoreConfigOrigin {
27    fn default() -> Self {
28        Self::Region {
29            region: pcloud::Region::Eu,
30        }
31    }
32}
33
34#[derive(Clone, Debug)]
35#[cfg_attr(feature = "serde", derive(serde::Deserialize))]
36pub struct PCloudStoreConfig {
37    #[cfg_attr(feature = "serde", serde(default, flatten))]
38    pub origin: PCloudStoreConfigOrigin,
39    pub credentials: pcloud::Credentials,
40    #[cfg_attr(feature = "serde", serde(default))]
41    pub root: PathBuf,
42}
43
44impl PCloudStoreConfig {
45    pub fn build(&self) -> Result<PCloudStore> {
46        let mut builder = pcloud::Client::builder();
47        match self.origin {
48            PCloudStoreConfigOrigin::Region { region } => {
49                builder.set_region(region);
50            }
51            PCloudStoreConfigOrigin::Url { ref url } => {
52                builder.set_base_url(url.clone());
53            }
54        };
55        builder.set_credentials(self.credentials.clone());
56        let client = builder
57            .build()
58            .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
59        Ok(PCloudStore(Arc::new(InnerStore {
60            client,
61            root: self.root.clone(),
62        })))
63    }
64}
65
66struct InnerStore {
67    client: pcloud::Client,
68    root: PathBuf,
69}
70
71/// A store backed by the pCloud remote storage service.
72#[derive(Clone)]
73pub struct PCloudStore(Arc<InnerStore>);
74
75impl std::fmt::Debug for PCloudStore {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct(stringify!(PCloudStore))
78            .finish_non_exhaustive()
79    }
80}
81
82static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
83
84impl PCloudStore {
85    /// Creates a new `PCloudStore` using a base URL and login credentials.
86    pub fn new(
87        base_url: impl Into<Cow<'static, str>>,
88        credentials: pcloud::Credentials,
89    ) -> Result<Self> {
90        let client = pcloud::Client::builder()
91            .with_base_url(base_url)
92            .with_credentials(credentials)
93            .build()
94            .unwrap();
95        Ok(Self(Arc::new(InnerStore {
96            client,
97            root: PathBuf::new(),
98        })))
99    }
100}
101
102impl crate::Store for PCloudStore {
103    type Directory = PCloudStoreDirectory;
104    type File = PCloudStoreFile;
105
106    /// Retrieves a file handle for the given path in the pCloud store.
107    async fn get_file<P: Into<PathBuf>>(&self, path: P) -> Result<Self::File> {
108        Ok(PCloudStoreFile {
109            store: self.0.clone(),
110            path: path.into(),
111        })
112    }
113
114    /// Retrieves a directory handle for the given path in the pCloud store.
115    async fn get_dir<P: Into<PathBuf>>(&self, path: P) -> Result<Self::Directory> {
116        Ok(PCloudStoreDirectory {
117            store: self.0.clone(),
118            path: path.into(),
119        })
120    }
121}
122
123// directory
124
125/// A directory in the pCloud file store.
126pub struct PCloudStoreDirectory {
127    store: Arc<InnerStore>,
128    path: PathBuf,
129}
130
131impl std::fmt::Debug for PCloudStoreDirectory {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.debug_struct(stringify!(PCloudStoreDirectory))
134            .field("path", &self.path)
135            .finish_non_exhaustive()
136    }
137}
138
139impl crate::StoreDirectory for PCloudStoreDirectory {
140    type Entry = PCloudStoreEntry;
141    type Reader = PCloudStoreDirectoryReader;
142
143    /// Checks if the directory exists on pCloud.
144    async fn exists(&self) -> Result<bool> {
145        let identifier = FolderIdentifier::path(self.path.to_string_lossy());
146        match self.store.client.list_folder(identifier).await {
147            Ok(_) => Ok(true),
148            Err(pcloud::Error::Protocol(2005, _)) => Ok(false),
149            Err(other) => Err(Error::other(other)),
150        }
151    }
152
153    /// Reads the directory contents from pCloud and returns an entry reader.
154    async fn read(&self) -> Result<Self::Reader> {
155        let path = crate::util::merge_path(&self.store.root, &self.path)?;
156        let identifier = FolderIdentifier::path(path.to_string_lossy());
157        match self.store.client.list_folder(identifier).await {
158            Ok(folder) => Ok(PCloudStoreDirectoryReader {
159                store: self.store.clone(),
160                path: self.path.clone(),
161                entries: folder.contents.unwrap_or_default(),
162            }),
163            Err(pcloud::Error::Protocol(2005, _)) => {
164                Err(Error::new(ErrorKind::NotFound, "directory not found"))
165            }
166            Err(other) => Err(Error::other(other)),
167        }
168    }
169}
170
171/// A streaming reader over entries in a pCloud directory.
172pub struct PCloudStoreDirectoryReader {
173    store: Arc<InnerStore>,
174    path: PathBuf,
175    entries: Vec<pcloud::entry::Entry>,
176}
177
178impl std::fmt::Debug for PCloudStoreDirectoryReader {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        f.debug_struct(stringify!(PCloudStoreDirectoryReader))
181            .field("path", &self.path)
182            .field("entries", &self.entries)
183            .finish_non_exhaustive()
184    }
185}
186
187impl Stream for PCloudStoreDirectoryReader {
188    type Item = Result<PCloudStoreEntry>;
189
190    /// Polls the next entry in the directory listing.
191    fn poll_next(
192        mut self: Pin<&mut Self>,
193        _cx: &mut std::task::Context<'_>,
194    ) -> Poll<Option<Self::Item>> {
195        let mut this = self.as_mut();
196
197        if let Some(entry) = this.entries.pop() {
198            Poll::Ready(Some(PCloudStoreEntry::new(
199                self.store.clone(),
200                self.path.clone(),
201                entry,
202            )))
203        } else {
204            Poll::Ready(None)
205        }
206    }
207}
208
209impl crate::StoreDirectoryReader<PCloudStoreEntry> for PCloudStoreDirectoryReader {}
210
211// files
212
213/// A file in the pCloud file store.
214pub struct PCloudStoreFile {
215    store: Arc<InnerStore>,
216    path: PathBuf,
217}
218
219impl std::fmt::Debug for PCloudStoreFile {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        f.debug_struct(stringify!(PCloudStoreFile))
222            .field("path", &self.path)
223            .finish_non_exhaustive()
224    }
225}
226
227impl crate::StoreFile for PCloudStoreFile {
228    type FileReader = PCloudStoreFileReader;
229    type FileWriter = PCloudStoreFileWriter;
230    type Metadata = PCloudStoreFileMetadata;
231
232    /// Returns the filename portion of the file's path.
233    fn filename(&self) -> Option<Cow<'_, str>> {
234        let cmp = self.path.components().next_back()?;
235        Some(cmp.as_os_str().to_string_lossy())
236    }
237
238    /// Checks whether the file exists on pCloud.
239    async fn exists(&self) -> Result<bool> {
240        let path = crate::util::merge_path(&self.store.root, &self.path)?;
241        let identifier = FileIdentifier::path(path.to_string_lossy());
242        match self.store.client.get_file_checksum(identifier).await {
243            Ok(_) => Ok(true),
244            Err(pcloud::Error::Protocol(2009, _)) => Ok(false),
245            Err(other) => Err(Error::other(other)),
246        }
247    }
248
249    /// Retrieves metadata about the file (size, creation, and modification
250    /// times).
251    async fn metadata(&self) -> Result<Self::Metadata> {
252        let path = crate::util::merge_path(&self.store.root, &self.path)?;
253        let identifier = FileIdentifier::path(path.to_string_lossy());
254        match self.store.client.get_file_checksum(identifier).await {
255            Ok(file) => Ok(PCloudStoreFileMetadata {
256                size: file.metadata.size.unwrap_or(0) as u64,
257                created: file.metadata.base.created.timestamp() as u64,
258                modified: file.metadata.base.modified.timestamp() as u64,
259            }),
260            Err(pcloud::Error::Protocol(2009, _)) => {
261                Err(Error::new(ErrorKind::NotFound, "file not found"))
262            }
263            Err(other) => Err(Error::other(other)),
264        }
265    }
266
267    /// Reads a byte range of the file content using a download link from
268    /// pCloud.
269    async fn read<R: std::ops::RangeBounds<u64>>(&self, range: R) -> Result<Self::FileReader> {
270        let path = crate::util::merge_path(&self.store.root, &self.path)?;
271        let identifier = FileIdentifier::path(path.to_string_lossy());
272        let links = self
273            .store
274            .client
275            .get_file_link(identifier)
276            .await
277            .map_err(|err| match err {
278                pcloud::Error::Protocol(2009, _) => {
279                    Error::new(ErrorKind::NotFound, "file not found")
280                }
281                other => Error::other(other),
282            })?;
283        let link = links
284            .first_link()
285            .ok_or_else(|| Error::other("unable to fetch file link"))?;
286        let url = link.to_string();
287        let res = reqwest::Client::new()
288            .get(url)
289            .header(header::RANGE, RangeHeader(range).to_string())
290            .header(header::USER_AGENT, APP_USER_AGENT)
291            .send()
292            .await
293            .map_err(Error::other)?;
294        PCloudStoreFileReader::from_response(res)
295    }
296
297    /// Creates a writer to a file in pcloud
298    async fn write(&self, options: crate::WriteOptions) -> Result<Self::FileWriter> {
299        match options.mode {
300            WriteMode::Append => {
301                return Err(Error::new(
302                    ErrorKind::Unsupported,
303                    "pcloud store doesn't support append write",
304                ));
305            }
306            WriteMode::Truncate { offset } if offset != 0 => {
307                return Err(Error::new(
308                    ErrorKind::Unsupported,
309                    "pcloud store doesn't support truncated write",
310                ));
311            }
312            _ => {}
313        };
314
315        let path = crate::util::merge_path(&self.store.root, &self.path)?;
316        let parent: FolderIdentifier<'static> = path
317            .parent()
318            .map(|parent| parent.to_path_buf())
319            .map(|parent| {
320                let parent = if parent.is_absolute() {
321                    parent.to_string_lossy().to_string()
322                } else {
323                    format!("/{}", parent.to_string_lossy())
324                };
325                FolderIdentifier::path(parent)
326            })
327            .unwrap_or_else(|| FolderIdentifier::FolderId(ROOT));
328        let filename = path
329            .file_name()
330            .ok_or_else(|| Error::new(ErrorKind::InvalidData, "unable to get file name"))?;
331        let filename = filename.to_string_lossy().to_string();
332
333        // TODO find a way to make the 8KB buffer a parameter
334        let (write_buffer, read_buffer) = tokio::io::duplex(8192);
335
336        let client = self.store.clone();
337        let stream = ReaderStream::new(read_buffer);
338        let files = pcloud::file::upload::MultiFileUpload::default()
339            .with_stream_entry(filename, None, stream);
340
341        // spawn a task that will keep the request connected while we are pushing data
342        let upload_task: JoinHandle<Result<()>> = tokio::spawn(async move {
343            client
344                .client
345                .upload_files(parent, files)
346                .await
347                .map(|_| ())
348                .map_err(Error::other)
349        });
350
351        Ok(PCloudStoreFileWriter {
352            write_buffer,
353            upload_task,
354        })
355    }
356}
357
358/// Writer to PCloud file
359#[derive(Debug)]
360pub struct PCloudStoreFileWriter {
361    write_buffer: DuplexStream,
362    upload_task: JoinHandle<Result<()>>,
363}
364
365impl tokio::io::AsyncWrite for PCloudStoreFileWriter {
366    fn poll_write(
367        mut self: Pin<&mut Self>,
368        cx: &mut std::task::Context<'_>,
369        buf: &[u8],
370    ) -> Poll<Result<usize>> {
371        if self.upload_task.is_finished() {
372            Poll::Ready(Err(Error::new(ErrorKind::BrokenPipe, "request closed")))
373        } else {
374            Pin::new(&mut self.write_buffer).poll_write(cx, buf)
375        }
376    }
377
378    fn poll_flush(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Result<()>> {
379        if self.upload_task.is_finished() {
380            Poll::Ready(Err(Error::new(ErrorKind::BrokenPipe, "request closed")))
381        } else {
382            Pin::new(&mut self.write_buffer).poll_flush(cx)
383        }
384    }
385
386    fn poll_shutdown(
387        mut self: Pin<&mut Self>,
388        cx: &mut std::task::Context<'_>,
389    ) -> Poll<Result<()>> {
390        let shutdown = Pin::new(&mut self.write_buffer).poll_shutdown(cx);
391
392        if shutdown.is_ready() {
393            let poll = Pin::new(&mut self.upload_task).poll(cx);
394            match poll {
395                Poll::Ready(Ok(res)) => Poll::Ready(res),
396                Poll::Ready(Err(err)) => Poll::Ready(Err(Error::other(err))),
397                Poll::Pending => Poll::Pending,
398            }
399        } else {
400            Poll::Pending
401        }
402    }
403}
404
405impl crate::StoreFileWriter for PCloudStoreFileWriter {}
406
407/// Metadata for a file in the pCloud store.
408#[derive(Clone, Debug)]
409pub struct PCloudStoreFileMetadata {
410    size: u64,
411    created: u64,
412    modified: u64,
413}
414
415impl super::StoreMetadata for PCloudStoreFileMetadata {
416    /// Returns the file size in bytes.
417    fn size(&self) -> u64 {
418        self.size
419    }
420
421    /// Returns the UNIX timestamp when the file was created.
422    fn created(&self) -> u64 {
423        self.created
424    }
425
426    /// Returns the UNIX timestamp when the file was last modified.
427    fn modified(&self) -> u64 {
428        self.modified
429    }
430}
431
432/// File reader type for pCloud files.
433///
434/// Reuses `HttpStoreFileReader` for actual byte streaming via HTTP.
435pub type PCloudStoreFileReader = HttpStoreFileReader;
436
437/// Represents a file or directory entry within the pCloud store.
438pub type PCloudStoreEntry = crate::Entry<PCloudStoreFile, PCloudStoreDirectory>;
439
440impl PCloudStoreEntry {
441    /// Constructs a `PCloudStoreEntry` from a parent path and a pCloud entry.
442    ///
443    /// Determines if the entry is a file or directory.
444    fn new(store: Arc<InnerStore>, parent: PathBuf, entry: pcloud::entry::Entry) -> Result<Self> {
445        let path = parent.join(&entry.base().name);
446        Ok(match entry {
447            pcloud::entry::Entry::File(_) => Self::File(PCloudStoreFile { store, path }),
448            pcloud::entry::Entry::Folder(_) => {
449                Self::Directory(PCloudStoreDirectory { store, path })
450            }
451        })
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use mockito::Matcher;
458    use tokio::io::AsyncWriteExt;
459
460    use super::*;
461    use crate::{Store, StoreFile, WriteOptions};
462
463    #[tokio::test]
464    async fn should_write_file() {
465        crate::enable_tracing();
466        let content = include_bytes!("lib.rs");
467        let mut srv = mockito::Server::new_async().await;
468        let mock = srv
469            .mock("POST", "/uploadfile")
470            .match_query(Matcher::AllOf(vec![
471                Matcher::UrlEncoded("username".into(), "username".into()),
472                Matcher::UrlEncoded("password".into(), "password".into()),
473                Matcher::UrlEncoded("path".into(), "/foo".into()),
474            ]))
475            .match_header(
476                "content-type",
477                Matcher::Regex("multipart/form-data; boundary=.*".to_string()),
478            )
479            .match_body(Matcher::Any)
480            .with_status(200)
481            // we don't care about the body
482            .with_body(r#"{"result": 0, "metadata": [], "checksums": [], "fileids": []}"#)
483            .create_async()
484            .await;
485
486        let store = PCloudStore::new(
487            srv.url(),
488            pcloud::Credentials::UsernamePassword {
489                username: "username".into(),
490                password: "password".into(),
491            },
492        )
493        .unwrap();
494        let file = store.get_file("/foo/bar.txt").await.unwrap();
495        let mut writer = file.write(WriteOptions::create()).await.unwrap();
496        writer.write_all(content).await.unwrap();
497        writer.shutdown().await.unwrap();
498        mock.assert_async().await;
499    }
500}