Skip to main content

cyberdrop_client/
uploads.rs

1use std::path::Path;
2use std::pin::Pin;
3use std::task::{Context, Poll};
4
5use bytes::Bytes;
6use futures_core::Stream;
7use reqwest::{
8    Body, Url,
9    multipart::{Form, Part},
10};
11use serde::{Deserialize, Serialize};
12use tokio::fs::File;
13use tokio::io::AsyncReadExt;
14use tokio_util::io::ReaderStream;
15use uuid::Uuid;
16
17use crate::CyberdropError;
18use crate::client::CyberdropClient;
19use crate::config::CHUNK_SIZE;
20
21/// Uploaded file metadata returned by [`crate::CyberdropClient::upload_file`].
22#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
23pub struct UploadedFile {
24    /// Name of the uploaded file.
25    pub name: String,
26    /// URL of the uploaded file (stringified URL).
27    pub url: String,
28}
29
30/// Upload progress information emitted during file uploads.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct UploadProgress {
33    /// File name currently being uploaded.
34    pub file_name: String,
35    /// Bytes sent so far.
36    pub bytes_sent: u64,
37    /// Total file size in bytes.
38    pub total_bytes: u64,
39}
40
41#[derive(Debug, Deserialize)]
42pub(crate) struct UploadResponse {
43    pub(crate) success: Option<bool>,
44    pub(crate) description: Option<String>,
45    pub(crate) files: Option<Vec<UploadedFile>>,
46}
47
48#[derive(Debug, Deserialize)]
49pub(crate) struct NodeResponse {
50    pub(crate) success: Option<bool>,
51    pub(crate) url: Option<String>,
52    pub(crate) message: Option<String>,
53    pub(crate) description: Option<String>,
54}
55
56#[derive(Debug, Deserialize)]
57pub(crate) struct ChunkResponse {
58    pub(crate) success: Option<bool>,
59}
60
61#[derive(Debug, Serialize)]
62pub(crate) struct FinishFile {
63    pub(crate) uuid: String,
64    pub(crate) original: String,
65    #[serde(rename = "type")]
66    pub(crate) r#type: String,
67    pub(crate) albumid: Option<u64>,
68    pub(crate) filelength: Option<u64>,
69    pub(crate) age: Option<u64>,
70}
71
72#[derive(Debug, Serialize)]
73pub(crate) struct FinishChunksPayload {
74    pub(crate) files: Vec<FinishFile>,
75}
76
77struct ProgressStream<S, F> {
78    inner: S,
79    bytes_sent: u64,
80    total_bytes: u64,
81    file_name: String,
82    callback: F,
83}
84
85impl<S, F> ProgressStream<S, F>
86where
87    S: Stream<Item = Result<Bytes, std::io::Error>> + Unpin,
88    F: FnMut(UploadProgress) + Send,
89{
90    fn new(inner: S, total_bytes: u64, file_name: String, callback: F) -> Self {
91        Self {
92            inner,
93            bytes_sent: 0,
94            total_bytes,
95            file_name,
96            callback,
97        }
98    }
99}
100
101impl<S, F> Stream for ProgressStream<S, F>
102where
103    S: Stream<Item = Result<Bytes, std::io::Error>> + Unpin,
104    F: FnMut(UploadProgress) + Send,
105{
106    type Item = Result<Bytes, std::io::Error>;
107
108    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
109        let this = self.get_mut();
110        match Pin::new(&mut this.inner).poll_next(cx) {
111            Poll::Ready(Some(Ok(bytes))) => {
112                this.bytes_sent = this.bytes_sent.saturating_add(bytes.len() as u64);
113                (this.callback)(UploadProgress {
114                    file_name: this.file_name.clone(),
115                    bytes_sent: this.bytes_sent,
116                    total_bytes: this.total_bytes,
117                });
118                Poll::Ready(Some(Ok(bytes)))
119            }
120            other => other,
121        }
122    }
123}
124
125impl<S, F> Unpin for ProgressStream<S, F>
126where
127    S: Stream<Item = Result<Bytes, std::io::Error>> + Unpin,
128    F: FnMut(UploadProgress) + Send,
129{
130}
131
132struct PreparedUpload {
133    file: File,
134    file_name: String,
135    mime: String,
136    total_size: u64,
137}
138
139async fn prepare_upload_file(file_path: &Path) -> Result<PreparedUpload, CyberdropError> {
140    let file_name = file_path
141        .file_name()
142        .and_then(|n| n.to_str())
143        .ok_or(CyberdropError::InvalidFileName)?
144        .to_string();
145
146    let mime = mime_guess::from_path(file_path)
147        .first_raw()
148        .unwrap_or("application/octet-stream")
149        .to_string();
150
151    let file = File::open(file_path).await?;
152    let total_size = file.metadata().await?.len();
153
154    Ok(PreparedUpload {
155        file,
156        file_name,
157        mime,
158        total_size,
159    })
160}
161
162impl CyberdropClient {
163    /// Fetch the upload node URL for the authenticated user.
164    ///
165    /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
166    pub async fn get_upload_url(&self) -> Result<Url, CyberdropError> {
167        let response: NodeResponse = self.get_json("api/node", true).await?;
168
169        if !response.success.unwrap_or(false) {
170            let msg = response
171                .description
172                .or(response.message)
173                .unwrap_or_else(|| "failed to fetch upload node".to_string());
174            return Err(CyberdropError::Api(msg));
175        }
176
177        let url = response
178            .url
179            .ok_or(CyberdropError::MissingField("node response missing url"))?;
180
181        Ok(Url::parse(&url)?)
182    }
183
184    /// Upload a single file.
185    ///
186    /// Requires an auth token.
187    ///
188    /// Implementation notes:
189    /// - Small files are streamed.
190    /// - Large files are uploaded in chunks from disk.
191    /// - Files larger than `95_000_000` bytes are uploaded in chunks.
192    /// - If `album_id` is provided, it is sent as an `albumid` header on the chunk/single-upload
193    ///   requests and included in the `finishchunks` payload.
194    ///
195    /// # Errors
196    ///
197    /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
198    /// - [`CyberdropError::InvalidFileName`] if `file_path` does not have a valid UTF-8 file name
199    /// - [`CyberdropError::Io`] if reading the file fails
200    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
201    /// - [`CyberdropError::Api`] if the service reports an upload failure (including per-chunk failures)
202    /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
203    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
204    pub async fn upload_file(
205        &self,
206        file_path: impl AsRef<Path>,
207        album_id: Option<u64>,
208    ) -> Result<UploadedFile, CyberdropError> {
209        self.upload_file_with_progress(file_path, album_id, |_| {})
210            .await
211    }
212
213    /// Upload a single file and emit per-file progress updates.
214    ///
215    /// The `on_progress` callback is invoked as bytes are streamed or as chunks complete.
216    pub async fn upload_file_with_progress<F>(
217        &self,
218        file_path: impl AsRef<Path>,
219        album_id: Option<u64>,
220        on_progress: F,
221    ) -> Result<UploadedFile, CyberdropError>
222    where
223        F: FnMut(UploadProgress) + Send + 'static,
224    {
225        let prepared = prepare_upload_file(file_path.as_ref()).await?;
226        let upload_url = self.get_upload_url().await?;
227
228        if prepared.total_size <= CHUNK_SIZE {
229            self.upload_small_file_with_progress(upload_url, prepared, album_id, on_progress)
230                .await
231        } else {
232            self.upload_chunked_file_with_progress(upload_url, prepared, album_id, on_progress)
233                .await
234        }
235    }
236
237    async fn upload_small_file_with_progress<F>(
238        &self,
239        upload_url: Url,
240        prepared: PreparedUpload,
241        album_id: Option<u64>,
242        on_progress: F,
243    ) -> Result<UploadedFile, CyberdropError>
244    where
245        F: FnMut(UploadProgress) + Send + 'static,
246    {
247        let PreparedUpload {
248            file,
249            file_name,
250            mime,
251            total_size,
252        } = prepared;
253
254        let stream = ReaderStream::new(file);
255        let progress_stream =
256            ProgressStream::new(stream, total_size, file_name.clone(), on_progress);
257        let body = Body::wrap_stream(progress_stream);
258        let part = Part::stream_with_length(body, total_size).file_name(file_name.clone());
259        let part = match part.mime_str(&mime) {
260            Ok(p) => p,
261            Err(_) => Part::bytes(Vec::new()).file_name(file_name),
262        };
263        let response: UploadResponse = self
264            .post_upload_multipart_url(upload_url, Form::new().part("files[]", part), album_id)
265            .await?;
266
267        parse_upload_response(response)
268    }
269
270    async fn upload_chunked_file_with_progress<F>(
271        &self,
272        upload_url: Url,
273        prepared: PreparedUpload,
274        album_id: Option<u64>,
275        mut on_progress: F,
276    ) -> Result<UploadedFile, CyberdropError>
277    where
278        F: FnMut(UploadProgress) + Send + 'static,
279    {
280        let PreparedUpload {
281            mut file,
282            file_name,
283            mime,
284            total_size,
285        } = prepared;
286
287        let chunk_size = CHUNK_SIZE.min(total_size.max(1));
288        let total_chunks = total_size.div_ceil(chunk_size).max(1);
289        let uuid = Uuid::new_v4().to_string();
290        let mut bytes_sent = 0u64;
291        let mut chunk_index = 0u64;
292        let mut buffer = Vec::with_capacity(chunk_size as usize);
293
294        loop {
295            buffer.clear();
296            let read = file.read_buf(&mut buffer).await?;
297            if read == 0 {
298                break;
299            }
300
301            let part = Part::bytes(buffer).file_name(file_name.clone());
302            let part = match part.mime_str(&mime) {
303                Ok(p) => p,
304                Err(_) => Part::bytes(Vec::new()).file_name(file_name.clone()),
305            };
306            let form = Form::new()
307                .text("dzuuid", uuid.clone())
308                .text("dzchunkindex", chunk_index.to_string())
309                .text("dztotalfilesize", total_size.to_string())
310                .text("dzchunksize", chunk_size.to_string())
311                .text("dztotalchunkcount", total_chunks.to_string())
312                .text("dzchunkbyteoffset", (chunk_index * chunk_size).to_string())
313                .part("files[]", part);
314
315            let response: ChunkResponse = self
316                .post_upload_multipart_url(upload_url.clone(), form, album_id)
317                .await?;
318
319            if !response.success.unwrap_or(false) {
320                return Err(CyberdropError::Api(format!("chunk {} failed", chunk_index)));
321            }
322
323            bytes_sent = bytes_sent.saturating_add(read as u64);
324            on_progress(UploadProgress {
325                file_name: file_name.clone(),
326                bytes_sent,
327                total_bytes: total_size,
328            });
329            chunk_index = chunk_index.saturating_add(1);
330            buffer = Vec::with_capacity(chunk_size as usize);
331        }
332
333        self.finish_chunked_upload(upload_url, uuid, file_name, mime, album_id)
334            .await
335    }
336
337    async fn finish_chunked_upload(
338        &self,
339        upload_url: Url,
340        uuid: String,
341        file_name: String,
342        mime: String,
343        album_id: Option<u64>,
344    ) -> Result<UploadedFile, CyberdropError> {
345        let payload = FinishChunksPayload {
346            files: vec![FinishFile {
347                uuid,
348                original: file_name,
349                r#type: mime,
350                albumid: album_id,
351                filelength: None,
352                age: None,
353            }],
354        };
355        let mut finish_url = upload_url;
356        finish_url.set_path("/api/upload/finishchunks");
357
358        let response: UploadResponse = self.post_upload_json_url(finish_url, &payload).await?;
359
360        parse_upload_response(response)
361    }
362}
363
364fn parse_upload_response(body: UploadResponse) -> Result<UploadedFile, CyberdropError> {
365    if body.success.unwrap_or(false) {
366        let first =
367            body.files
368                .and_then(|mut files| files.pop())
369                .ok_or(CyberdropError::MissingField(
370                    "upload response missing files",
371                ))?;
372        let url = Url::parse(&first.url)?;
373        Ok(UploadedFile {
374            name: first.name,
375            url: url.to_string(),
376        })
377    } else {
378        let msg = body
379            .description
380            .unwrap_or_else(|| "upload failed".to_string());
381        Err(CyberdropError::Api(msg))
382    }
383}