cyberdrop_client/client.rs
1use std::{path::Path, time::Duration};
2
3use bytes::Bytes;
4use futures_core::Stream;
5use reqwest::{Body, Client, ClientBuilder, Url, multipart::Form};
6use serde::Serialize;
7use std::pin::Pin;
8use std::task::{Context, Poll};
9use tokio::fs::File;
10use tokio::io::AsyncReadExt;
11use tokio_util::io::ReaderStream;
12use uuid::Uuid;
13
14use crate::models::{
15 AlbumFilesPage, AlbumFilesResponse, AlbumsResponse, CreateAlbumRequest, CreateAlbumResponse,
16 EditAlbumRequest, EditAlbumResponse, LoginRequest, LoginResponse, NodeResponse,
17 RegisterRequest, RegisterResponse, UploadProgress, UploadResponse, VerifyTokenRequest,
18 VerifyTokenResponse,
19};
20use crate::transport::Transport;
21use crate::{
22 AlbumsList, AuthToken, CyberdropError, EditAlbumResult, TokenVerification, UploadedFile,
23};
24
25#[derive(Debug, Clone)]
26pub(crate) struct ChunkFields {
27 pub(crate) uuid: String,
28 pub(crate) chunk_index: u64,
29 pub(crate) total_size: u64,
30 pub(crate) chunk_size: u64,
31 pub(crate) total_chunks: u64,
32 pub(crate) byte_offset: u64,
33 pub(crate) file_name: String,
34 pub(crate) mime_type: String,
35 pub(crate) album_id: Option<u64>,
36}
37
38#[derive(Debug, Serialize)]
39pub(crate) struct FinishFile {
40 pub(crate) uuid: String,
41 pub(crate) original: String,
42 #[serde(rename = "type")]
43 pub(crate) r#type: String,
44 pub(crate) albumid: Option<u64>,
45 pub(crate) filelength: Option<u64>,
46 pub(crate) age: Option<u64>,
47}
48
49#[derive(Debug, Serialize)]
50pub(crate) struct FinishChunksPayload {
51 pub(crate) files: Vec<FinishFile>,
52}
53
54struct ProgressStream<S, F> {
55 inner: S,
56 bytes_sent: u64,
57 total_bytes: u64,
58 file_name: String,
59 callback: F,
60}
61
62impl<S, F> ProgressStream<S, F>
63where
64 S: Stream<Item = Result<Bytes, std::io::Error>> + Unpin,
65 F: FnMut(UploadProgress) + Send,
66{
67 fn new(inner: S, total_bytes: u64, file_name: String, callback: F) -> Self {
68 Self {
69 inner,
70 bytes_sent: 0,
71 total_bytes,
72 file_name,
73 callback,
74 }
75 }
76}
77
78impl<S, F> Stream for ProgressStream<S, F>
79where
80 S: Stream<Item = Result<Bytes, std::io::Error>> + Unpin,
81 F: FnMut(UploadProgress) + Send,
82{
83 type Item = Result<Bytes, std::io::Error>;
84
85 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
86 let this = self.get_mut();
87 match Pin::new(&mut this.inner).poll_next(cx) {
88 Poll::Ready(Some(Ok(bytes))) => {
89 this.bytes_sent = this.bytes_sent.saturating_add(bytes.len() as u64);
90 (this.callback)(UploadProgress {
91 file_name: this.file_name.clone(),
92 bytes_sent: this.bytes_sent,
93 total_bytes: this.total_bytes,
94 });
95 Poll::Ready(Some(Ok(bytes)))
96 }
97 other => other,
98 }
99 }
100}
101
102impl<S, F> Unpin for ProgressStream<S, F>
103where
104 S: Stream<Item = Result<Bytes, std::io::Error>> + Unpin,
105 F: FnMut(UploadProgress) + Send,
106{
107}
108
109/// Async HTTP client for a subset of Cyberdrop endpoints.
110///
111/// Most higher-level methods map non-2xx responses to [`CyberdropError`]. For raw access where
112/// you want to inspect status codes and bodies directly, use [`CyberdropClient::get`].
113#[derive(Debug, Clone)]
114pub struct CyberdropClient {
115 transport: Transport,
116}
117
118/// Builder for [`CyberdropClient`].
119#[derive(Debug)]
120pub struct CyberdropClientBuilder {
121 base_url: Option<Url>,
122 user_agent: Option<String>,
123 timeout: Duration,
124 auth_token: Option<AuthToken>,
125 builder: ClientBuilder,
126}
127
128const CHUNK_SIZE: u64 = 95_000_000;
129const DEFAULT_BASE_URL: &str = "https://cyberdrop.cr/";
130const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
131
132impl CyberdropClient {
133 /// Build a client with a custom base URL.
134 ///
135 /// `base_url` is parsed as a [`Url`]. It is then used as the base for relative API paths via
136 /// [`Url::join`], so a trailing slash is recommended.
137 pub fn new(base_url: impl AsRef<str>) -> Result<Self, CyberdropError> {
138 CyberdropClientBuilder::new().base_url(base_url)?.build()
139 }
140
141 /// Start configuring a client with the crate's defaults.
142 ///
143 /// Defaults:
144 /// - Base URL: `https://cyberdrop.cr/`
145 /// - Timeout: 30 seconds
146 /// - User agent: a browser-like UA string
147 pub fn builder() -> CyberdropClientBuilder {
148 CyberdropClientBuilder::new()
149 }
150
151 /// Current base URL.
152 pub fn base_url(&self) -> &Url {
153 self.transport.base_url()
154 }
155
156 /// Current auth token if configured.
157 pub fn auth_token(&self) -> Option<&str> {
158 self.transport.auth_token()
159 }
160
161 /// Return a clone of this client that applies authentication to requests.
162 ///
163 /// The token is attached as an HTTP header named `token`.
164 pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
165 self.transport = self.transport.with_auth_token(token);
166 self
167 }
168
169 async fn get_album_by_id(&self, album_id: u64) -> Result<crate::models::Album, CyberdropError> {
170 let albums = self.list_albums().await?;
171 albums
172 .albums
173 .into_iter()
174 .find(|album| album.id == album_id)
175 .ok_or(CyberdropError::AlbumNotFound(album_id))
176 }
177
178 /// Execute a GET request against a relative path on the configured base URL.
179 ///
180 /// This method returns the raw [`reqwest::Response`] and does **not** convert non-2xx status
181 /// codes into errors. If a token is configured, it will be attached, but authentication is
182 /// not required.
183 ///
184 /// # Errors
185 ///
186 /// Returns [`CyberdropError::Http`] on transport failures (including timeouts). This method
187 /// does not map HTTP status codes to [`CyberdropError`] variants.
188 pub async fn get(&self, path: impl AsRef<str>) -> Result<reqwest::Response, CyberdropError> {
189 self.transport.get_raw(path.as_ref()).await
190 }
191
192 /// Authenticate and retrieve a token.
193 ///
194 /// The returned token can be installed on a client via [`CyberdropClient::with_auth_token`]
195 /// or [`CyberdropClientBuilder::auth_token`].
196 ///
197 /// # Errors
198 ///
199 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
200 /// - [`CyberdropError::MissingToken`] if the response body omits the token field
201 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
202 pub async fn login(
203 &self,
204 username: impl Into<String>,
205 password: impl Into<String>,
206 ) -> Result<AuthToken, CyberdropError> {
207 let payload = LoginRequest {
208 username: username.into(),
209 password: password.into(),
210 };
211
212 let response: LoginResponse = self
213 .transport
214 .post_json("api/login", &payload, false)
215 .await?;
216
217 AuthToken::try_from(response)
218 }
219
220 /// Register a new account and retrieve a token.
221 ///
222 /// The returned token can be installed on a client via [`CyberdropClient::with_auth_token`]
223 /// or [`CyberdropClientBuilder::auth_token`].
224 ///
225 /// Note: the API returns HTTP 200 even for validation failures; this method converts
226 /// `{"success":false,...}` responses into [`CyberdropError::Api`].
227 ///
228 /// # Errors
229 ///
230 /// - [`CyberdropError::Api`] if the API reports a validation failure (e.g. username taken)
231 /// - [`CyberdropError::MissingToken`] if the response body omits the token field on success
232 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
233 pub async fn register(
234 &self,
235 username: impl Into<String>,
236 password: impl Into<String>,
237 ) -> Result<AuthToken, CyberdropError> {
238 let payload = RegisterRequest {
239 username: username.into(),
240 password: password.into(),
241 };
242
243 let response: RegisterResponse = self
244 .transport
245 .post_json("api/register", &payload, false)
246 .await?;
247
248 AuthToken::try_from(response)
249 }
250
251 /// Verify a token and fetch associated permissions.
252 ///
253 /// This request does not require the client to be authenticated; the token to verify is
254 /// supplied in the request body.
255 ///
256 /// # Errors
257 ///
258 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
259 /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
260 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
261 pub async fn verify_token(
262 &self,
263 token: impl Into<String>,
264 ) -> Result<TokenVerification, CyberdropError> {
265 let payload = VerifyTokenRequest {
266 token: token.into(),
267 };
268
269 let response: VerifyTokenResponse = self
270 .transport
271 .post_json("api/tokens/verify", &payload, false)
272 .await?;
273
274 TokenVerification::try_from(response)
275 }
276
277 /// Fetch the upload node URL for the authenticated user.
278 ///
279 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
280 pub async fn get_upload_url(&self) -> Result<Url, CyberdropError> {
281 let response: NodeResponse = self.transport.get_json("api/node", true).await?;
282
283 if !response.success.unwrap_or(false) {
284 let msg = response
285 .description
286 .or(response.message)
287 .unwrap_or_else(|| "failed to fetch upload node".to_string());
288 return Err(CyberdropError::Api(msg));
289 }
290
291 let url = response
292 .url
293 .ok_or(CyberdropError::MissingField("node response missing url"))?;
294
295 Ok(Url::parse(&url)?)
296 }
297
298 /// List albums for the authenticated user.
299 ///
300 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
301 ///
302 /// # Errors
303 ///
304 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
305 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
306 /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
307 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
308 pub async fn list_albums(&self) -> Result<AlbumsList, CyberdropError> {
309 let response: AlbumsResponse = self.transport.get_json("api/albums", true).await?;
310 AlbumsList::try_from(response)
311 }
312
313 /// List all files in an album ("folder") by iterating pages until exhaustion.
314 ///
315 /// This calls [`CyberdropClient::list_album_files_page`] repeatedly starting at `page = 0` and
316 /// stops when:
317 /// - enough files have been collected to satisfy the API-reported `count`, or
318 /// - a page returns zero files, or
319 /// - a page yields no new file IDs (defensive infinite-loop guard).
320 ///
321 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
322 ///
323 /// # Returns
324 ///
325 /// An [`AlbumFilesPage`] containing all collected files. The returned `count` is the total
326 /// file count as reported by the API.
327 ///
328 /// # Errors
329 ///
330 /// Any error returned by [`CyberdropClient::list_album_files_page`].
331 pub async fn list_album_files(&self, album_id: u64) -> Result<AlbumFilesPage, CyberdropError> {
332 let mut page = 0u64;
333 let mut all_files = Vec::new();
334 let mut total_count = None::<u64>;
335 let mut albums = std::collections::HashMap::new();
336 let mut base_domain = None::<Url>;
337 let mut seen = std::collections::HashSet::<u64>::new();
338
339 loop {
340 let res = self.list_album_files_page(album_id, page).await?;
341
342 if base_domain.is_none() {
343 base_domain = res.base_domain.clone();
344 }
345 if total_count.is_none() {
346 total_count = Some(res.count);
347 }
348 albums.extend(res.albums.into_iter());
349
350 if res.files.is_empty() {
351 break;
352 }
353
354 let mut added = 0usize;
355 for file in res.files.into_iter() {
356 if seen.insert(file.id) {
357 all_files.push(file);
358 added += 1;
359 }
360 }
361
362 if added == 0 {
363 break;
364 }
365
366 if let Some(total) = total_count {
367 if all_files.len() as u64 >= total {
368 break;
369 }
370 }
371
372 page += 1;
373 }
374
375 Ok(AlbumFilesPage {
376 success: true,
377 files: all_files,
378 count: total_count.unwrap_or(0),
379 albums,
380 base_domain,
381 })
382 }
383
384 /// List files in an album ("folder") for a specific page.
385 ///
386 /// Page numbers are zero-based (`page = 0` is the first page). This is intentionally exposed
387 /// so a higher-level pagination helper can be added later.
388 ///
389 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
390 ///
391 /// # Errors
392 ///
393 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
394 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
395 /// - [`CyberdropError::Api`] for service-reported failures
396 /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
397 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
398 pub async fn list_album_files_page(
399 &self,
400 album_id: u64,
401 page: u64,
402 ) -> Result<AlbumFilesPage, CyberdropError> {
403 let path = format!("api/album/{album_id}/{page}");
404 let response: AlbumFilesResponse = self.transport.get_json(&path, true).await?;
405 AlbumFilesPage::try_from(response)
406 }
407
408 /// Create a new album and return its numeric ID.
409 ///
410 /// Requires an auth token. If the service reports that an album with a similar name already
411 /// exists, this returns [`CyberdropError::AlbumAlreadyExists`].
412 ///
413 /// # Errors
414 ///
415 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
416 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
417 /// - [`CyberdropError::AlbumAlreadyExists`] if the service indicates an album already exists
418 /// - [`CyberdropError::Api`] for other service-reported failures
419 /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
420 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
421 pub async fn create_album(
422 &self,
423 name: impl Into<String>,
424 description: Option<impl Into<String>>,
425 ) -> Result<u64, CyberdropError> {
426 let payload = CreateAlbumRequest {
427 name: name.into(),
428 description: description.map(Into::into),
429 };
430
431 let response: CreateAlbumResponse = self
432 .transport
433 .post_json("api/albums", &payload, true)
434 .await?;
435
436 u64::try_from(response)
437 }
438
439 /// Edit an existing album ("folder").
440 ///
441 /// This endpoint updates album metadata such as name/description and visibility flags.
442 /// It can also request a new link identifier.
443 ///
444 /// Requires an auth token.
445 ///
446 /// # Returns
447 ///
448 /// The API returns either a `name` (typical edits) or an `identifier` (when requesting a new
449 /// link). This crate exposes both as optional fields on [`EditAlbumResult`].
450 ///
451 /// # Errors
452 ///
453 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
454 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
455 /// - [`CyberdropError::Api`] for service-reported failures
456 /// - [`CyberdropError::MissingField`] if the response is missing expected fields
457 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
458 pub async fn edit_album(
459 &self,
460 id: u64,
461 name: impl Into<String>,
462 description: impl Into<String>,
463 download: bool,
464 public: bool,
465 request_new_link: bool,
466 ) -> Result<EditAlbumResult, CyberdropError> {
467 let payload = EditAlbumRequest {
468 id,
469 name: name.into(),
470 description: description.into(),
471 download,
472 public,
473 request_link: request_new_link,
474 };
475
476 let response: EditAlbumResponse = self
477 .transport
478 .post_json("api/albums/edit", &payload, true)
479 .await?;
480
481 EditAlbumResult::try_from(response)
482 }
483
484 /// Request a new public link identifier for an existing album, preserving its current settings.
485 ///
486 /// This is a convenience wrapper around:
487 /// 1) [`CyberdropClient::list_albums`] (to fetch current album settings)
488 /// 2) [`CyberdropClient::edit_album`] with `request_new_link = true`
489 ///
490 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
491 ///
492 /// # Returns
493 ///
494 /// The new album public URL in the form `https://cyberdrop.cr/a/<identifier>`.
495 ///
496 /// Note: this URL is always built against `https://cyberdrop.cr/` (it does not use the
497 /// client's configured base URL).
498 ///
499 /// # Errors
500 ///
501 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
502 /// - [`CyberdropError::AlbumNotFound`] if `album_id` is not present in the album list
503 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
504 /// - [`CyberdropError::Api`] for service-reported failures
505 /// - [`CyberdropError::MissingField`] if the API omits the new identifier
506 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
507 pub async fn request_new_album_link(&self, album_id: u64) -> Result<String, CyberdropError> {
508 let album = self.get_album_by_id(album_id).await?;
509
510 let edited = self
511 .edit_album(
512 album_id,
513 album.name,
514 album.description,
515 album.download,
516 album.public,
517 true,
518 )
519 .await?;
520
521 let identifier = edited.identifier.ok_or(CyberdropError::MissingField(
522 "edit album response missing identifier",
523 ))?;
524
525 let identifier = identifier.trim_start_matches('/');
526 Ok(format!("https://cyberdrop.cr/a/{identifier}"))
527 }
528
529 /// Update an album name, preserving existing description and visibility flags.
530 ///
531 /// This is a convenience wrapper around:
532 /// 1) [`CyberdropClient::list_albums`] (to fetch current album settings)
533 /// 2) [`CyberdropClient::edit_album`] with `request_new_link = false`
534 ///
535 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
536 ///
537 /// # Returns
538 ///
539 /// The API response mapped into an [`EditAlbumResult`].
540 ///
541 /// # Errors
542 ///
543 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
544 /// - [`CyberdropError::AlbumNotFound`] if `album_id` is not present in the album list
545 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
546 /// - [`CyberdropError::Api`] for service-reported failures
547 /// - [`CyberdropError::MissingField`] if the response is missing expected fields
548 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
549 pub async fn set_album_name(
550 &self,
551 album_id: u64,
552 name: impl Into<String>,
553 ) -> Result<EditAlbumResult, CyberdropError> {
554 let album = self.get_album_by_id(album_id).await?;
555 self.edit_album(
556 album_id,
557 name,
558 album.description,
559 album.download,
560 album.public,
561 false,
562 )
563 .await
564 }
565
566 /// Update an album description, preserving existing name and visibility flags.
567 ///
568 /// This is a convenience wrapper around:
569 /// 1) [`CyberdropClient::list_albums`] (to fetch current album settings)
570 /// 2) [`CyberdropClient::edit_album`] with `request_new_link = false`
571 ///
572 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
573 ///
574 /// # Returns
575 ///
576 /// The API response mapped into an [`EditAlbumResult`].
577 ///
578 /// # Errors
579 ///
580 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
581 /// - [`CyberdropError::AlbumNotFound`] if `album_id` is not present in the album list
582 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
583 /// - [`CyberdropError::Api`] for service-reported failures
584 /// - [`CyberdropError::MissingField`] if the response is missing expected fields
585 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
586 pub async fn set_album_description(
587 &self,
588 album_id: u64,
589 description: impl Into<String>,
590 ) -> Result<EditAlbumResult, CyberdropError> {
591 let album = self.get_album_by_id(album_id).await?;
592 self.edit_album(
593 album_id,
594 album.name,
595 description,
596 album.download,
597 album.public,
598 false,
599 )
600 .await
601 }
602
603 /// Update an album download flag, preserving existing name/description and public flag.
604 ///
605 /// This is a convenience wrapper around:
606 /// 1) [`CyberdropClient::list_albums`] (to fetch current album settings)
607 /// 2) [`CyberdropClient::edit_album`] with `request_new_link = false`
608 ///
609 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
610 ///
611 /// # Returns
612 ///
613 /// The API response mapped into an [`EditAlbumResult`].
614 ///
615 /// # Errors
616 ///
617 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
618 /// - [`CyberdropError::AlbumNotFound`] if `album_id` is not present in the album list
619 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
620 /// - [`CyberdropError::Api`] for service-reported failures
621 /// - [`CyberdropError::MissingField`] if the response is missing expected fields
622 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
623 pub async fn set_album_download(
624 &self,
625 album_id: u64,
626 download: bool,
627 ) -> Result<EditAlbumResult, CyberdropError> {
628 let album = self.get_album_by_id(album_id).await?;
629 self.edit_album(
630 album_id,
631 album.name,
632 album.description,
633 download,
634 album.public,
635 false,
636 )
637 .await
638 }
639
640 /// Update an album public flag, preserving existing name/description and download flag.
641 ///
642 /// This is a convenience wrapper around:
643 /// 1) [`CyberdropClient::list_albums`] (to fetch current album settings)
644 /// 2) [`CyberdropClient::edit_album`] with `request_new_link = false`
645 ///
646 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
647 ///
648 /// # Returns
649 ///
650 /// The API response mapped into an [`EditAlbumResult`].
651 ///
652 /// # Errors
653 ///
654 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
655 /// - [`CyberdropError::AlbumNotFound`] if `album_id` is not present in the album list
656 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
657 /// - [`CyberdropError::Api`] for service-reported failures
658 /// - [`CyberdropError::MissingField`] if the response is missing expected fields
659 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
660 pub async fn set_album_public(
661 &self,
662 album_id: u64,
663 public: bool,
664 ) -> Result<EditAlbumResult, CyberdropError> {
665 let album = self.get_album_by_id(album_id).await?;
666 self.edit_album(
667 album_id,
668 album.name,
669 album.description,
670 album.download,
671 public,
672 false,
673 )
674 .await
675 }
676
677 /// Upload a single file.
678 ///
679 /// Requires an auth token.
680 ///
681 /// Implementation notes:
682 /// - Small files are streamed.
683 /// - Large files are uploaded in chunks from disk.
684 /// - Files larger than `95_000_000` bytes are uploaded in chunks.
685 /// - If `album_id` is provided, it is sent as an `albumid` header on the chunk/single-upload
686 /// requests and included in the `finishchunks` payload.
687 ///
688 /// # Errors
689 ///
690 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
691 /// - [`CyberdropError::InvalidFileName`] if `file_path` does not have a valid UTF-8 file name
692 /// - [`CyberdropError::Io`] if reading the file fails
693 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
694 /// - [`CyberdropError::Api`] if the service reports an upload failure (including per-chunk failures)
695 /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
696 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
697 pub async fn upload_file(
698 &self,
699 file_path: impl AsRef<Path>,
700 album_id: Option<u64>,
701 ) -> Result<UploadedFile, CyberdropError> {
702 self.upload_file_with_progress(file_path, album_id, |_| {})
703 .await
704 }
705
706 /// Upload a single file and emit per-file progress updates.
707 ///
708 /// The `on_progress` callback is invoked as bytes are streamed or as chunks complete.
709 pub async fn upload_file_with_progress<F>(
710 &self,
711 file_path: impl AsRef<Path>,
712 album_id: Option<u64>,
713 mut on_progress: F,
714 ) -> Result<UploadedFile, CyberdropError>
715 where
716 F: FnMut(UploadProgress) + Send + 'static,
717 {
718 let file_path = file_path.as_ref();
719 let file_name = file_path
720 .file_name()
721 .and_then(|n| n.to_str())
722 .ok_or(CyberdropError::InvalidFileName)?
723 .to_string();
724
725 let mime = mime_guess::from_path(file_path)
726 .first_raw()
727 .unwrap_or("application/octet-stream")
728 .to_string();
729
730 let file = File::open(file_path).await?;
731 let total_size = file.metadata().await?.len();
732 let upload_url = self.get_upload_url().await?;
733
734 // For small files, use the simple single-upload endpoint.
735 if total_size <= CHUNK_SIZE {
736 let stream = ReaderStream::new(file);
737 let progress_stream =
738 ProgressStream::new(stream, total_size, file_name.clone(), on_progress);
739 let body = Body::wrap_stream(progress_stream);
740 let part = reqwest::multipart::Part::stream_with_length(body, total_size)
741 .file_name(file_name.clone());
742 let part = match part.mime_str(&mime) {
743 Ok(p) => p,
744 Err(_) => reqwest::multipart::Part::bytes(Vec::new()).file_name(file_name.clone()),
745 };
746 let form = Form::new().part("files[]", part);
747 let response: UploadResponse = self
748 .transport
749 .post_single_upload_url(upload_url, form, album_id)
750 .await?;
751 return UploadedFile::try_from(response);
752 }
753
754 let chunk_size = CHUNK_SIZE.min(total_size.max(1));
755 let total_chunks = ((total_size + chunk_size - 1) / chunk_size).max(1);
756 let uuid = Uuid::new_v4().to_string();
757 let mut file = file;
758 let mut bytes_sent = 0u64;
759 let mut chunk_index = 0u64;
760
761 loop {
762 let mut buffer = vec![0u8; chunk_size as usize];
763 let read = file.read(&mut buffer).await?;
764 if read == 0 {
765 break;
766 }
767 buffer.truncate(read);
768 let byte_offset = chunk_index * chunk_size;
769
770 let response: serde_json::Value = self
771 .transport
772 .post_chunk_url(
773 upload_url.clone(),
774 buffer,
775 ChunkFields {
776 uuid: uuid.clone(),
777 chunk_index,
778 total_size,
779 chunk_size,
780 total_chunks,
781 byte_offset,
782 file_name: file_name.clone(),
783 mime_type: mime.clone(),
784 album_id,
785 },
786 )
787 .await?;
788
789 if !response
790 .get("success")
791 .and_then(|v| v.as_bool())
792 .unwrap_or(false)
793 {
794 return Err(CyberdropError::Api(format!("chunk {} failed", chunk_index)));
795 }
796
797 bytes_sent = bytes_sent.saturating_add(read as u64);
798 on_progress(UploadProgress {
799 file_name: file_name.clone(),
800 bytes_sent,
801 total_bytes: total_size,
802 });
803 chunk_index = chunk_index.saturating_add(1);
804 }
805
806 let payload = FinishChunksPayload {
807 files: vec![FinishFile {
808 uuid,
809 original: file_name,
810 r#type: mime,
811 albumid: album_id,
812 filelength: None,
813 age: None,
814 }],
815 };
816
817 let finish_url = {
818 let mut url = upload_url;
819 url.set_path("/api/upload/finishchunks");
820 url
821 };
822
823 let response: UploadResponse = self
824 .transport
825 .post_json_with_upload_headers_url(finish_url, &payload)
826 .await?;
827
828 UploadedFile::try_from(response)
829 }
830}
831
832impl CyberdropClientBuilder {
833 /// Create a new builder using the crate defaults.
834 ///
835 /// This is equivalent to [`CyberdropClient::builder`].
836 pub fn new() -> Self {
837 Self {
838 base_url: None,
839 user_agent: None,
840 timeout: DEFAULT_TIMEOUT,
841 auth_token: None,
842 builder: Client::builder(),
843 }
844 }
845
846 /// Override the base URL used for requests.
847 pub fn base_url(mut self, base_url: impl AsRef<str>) -> Result<Self, CyberdropError> {
848 self.base_url = Some(Url::parse(base_url.as_ref())?);
849 Ok(self)
850 }
851
852 /// Set a custom user agent header.
853 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
854 self.user_agent = Some(user_agent.into());
855 self
856 }
857
858 /// Provide an auth token that will be sent as bearer auth.
859 pub fn auth_token(mut self, token: impl Into<String>) -> Self {
860 self.auth_token = Some(AuthToken::new(token));
861 self
862 }
863
864 /// Configure the request timeout.
865 ///
866 /// This sets [`reqwest::ClientBuilder::timeout`], which applies a single deadline per request.
867 /// Timeout failures surface as [`CyberdropError::Http`].
868 pub fn timeout(mut self, timeout: Duration) -> Self {
869 self.timeout = timeout;
870 self
871 }
872
873 /// Build a [`CyberdropClient`].
874 ///
875 /// If no base URL is configured, this uses `https://cyberdrop.cr/`.
876 /// If no user agent is configured, a browser-like UA string is used.
877 pub fn build(self) -> Result<CyberdropClient, CyberdropError> {
878 let base_url = match self.base_url {
879 Some(url) => url,
880 None => Url::parse(DEFAULT_BASE_URL)?,
881 };
882
883 let mut builder = self.builder.timeout(self.timeout);
884 builder = builder.user_agent(self.user_agent.unwrap_or_else(default_user_agent));
885
886 let client = builder.build()?;
887
888 Ok(CyberdropClient {
889 transport: Transport::new(client, base_url, self.auth_token),
890 })
891 }
892}
893
894fn default_user_agent() -> String {
895 // Match a browser UA; the service appears to expect browser-like clients.
896 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0".into()
897}