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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
23pub struct UploadedFile {
24 pub name: String,
26 pub url: String,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct UploadProgress {
33 pub file_name: String,
35 pub bytes_sent: u64,
37 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 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 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 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}