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#[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 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 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 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
130pub 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 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 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
215pub 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 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
255pub 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 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 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 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 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 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 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#[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#[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 fn size(&self) -> u64 {
478 self.size
479 }
480
481 fn created(&self) -> u64 {
483 self.created
484 }
485
486 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
496pub type PCloudStoreFileReader = HttpStoreFileReader;
500
501pub type PCloudStoreEntry = crate::Entry<PCloudStoreFile, PCloudStoreDirectory>;
503
504impl PCloudStoreEntry {
505 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 .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}