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#[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 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 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 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
123pub 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 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 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
171pub 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 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
211pub 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 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 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 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 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 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 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 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#[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#[derive(Clone, Debug)]
409pub struct PCloudStoreFileMetadata {
410 size: u64,
411 created: u64,
412 modified: u64,
413}
414
415impl super::StoreMetadata for PCloudStoreFileMetadata {
416 fn size(&self) -> u64 {
418 self.size
419 }
420
421 fn created(&self) -> u64 {
423 self.created
424 }
425
426 fn modified(&self) -> u64 {
428 self.modified
429 }
430}
431
432pub type PCloudStoreFileReader = HttpStoreFileReader;
436
437pub type PCloudStoreEntry = crate::Entry<PCloudStoreFile, PCloudStoreDirectory>;
439
440impl PCloudStoreEntry {
441 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 .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}