any_storage/
pcloud.rs

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