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 pub 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
310 .transport
311 .get_json_with_header("api/albums", true, "Simple", "1")
312 .await?;
313 AlbumsList::try_from(response)
314 }
315
316 /// List all files in an album ("folder") by iterating pages until exhaustion.
317 ///
318 /// This calls [`CyberdropClient::list_album_files_page`] repeatedly starting at `page = 0` and
319 /// stops when:
320 /// - enough files have been collected to satisfy the API-reported `count`, or
321 /// - a page returns zero files, or
322 /// - a page yields no new file IDs (defensive infinite-loop guard).
323 ///
324 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
325 ///
326 /// # Returns
327 ///
328 /// An [`AlbumFilesPage`] containing all collected files. The returned `count` is the total
329 /// file count as reported by the API.
330 ///
331 /// # Errors
332 ///
333 /// Any error returned by [`CyberdropClient::list_album_files_page`].
334 pub async fn list_album_files(&self, album_id: u64) -> Result<AlbumFilesPage, CyberdropError> {
335 let mut page = 0u64;
336 let mut all_files = Vec::new();
337 let mut total_count = None::<u64>;
338 let mut albums = std::collections::HashMap::new();
339 let mut base_domain = None::<Url>;
340 let mut seen = std::collections::HashSet::<u64>::new();
341
342 loop {
343 let res = self.list_album_files_page(album_id, page).await?;
344
345 if base_domain.is_none() {
346 base_domain = res.base_domain.clone();
347 }
348 if total_count.is_none() {
349 total_count = Some(res.count);
350 }
351 albums.extend(res.albums.into_iter());
352
353 if res.files.is_empty() {
354 break;
355 }
356
357 let mut added = 0usize;
358 for file in res.files.into_iter() {
359 if seen.insert(file.id) {
360 all_files.push(file);
361 added += 1;
362 }
363 }
364
365 if added == 0 {
366 break;
367 }
368
369 if let Some(total) = total_count {
370 if all_files.len() as u64 >= total {
371 break;
372 }
373 }
374
375 page += 1;
376 }
377
378 Ok(AlbumFilesPage {
379 success: true,
380 files: all_files,
381 count: total_count.unwrap_or(0),
382 albums,
383 base_domain,
384 })
385 }
386
387 /// List files in an album ("folder") for a specific page.
388 ///
389 /// Page numbers are zero-based (`page = 0` is the first page). This is intentionally exposed
390 /// so a higher-level pagination helper can be added later.
391 ///
392 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
393 ///
394 /// # Errors
395 ///
396 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
397 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
398 /// - [`CyberdropError::Api`] for service-reported failures
399 /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
400 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
401 pub async fn list_album_files_page(
402 &self,
403 album_id: u64,
404 page: u64,
405 ) -> Result<AlbumFilesPage, CyberdropError> {
406 let path = format!("api/album/{album_id}/{page}");
407 let response: AlbumFilesResponse = self.transport.get_json(&path, true).await?;
408 AlbumFilesPage::try_from(response)
409 }
410
411 /// Create a new album and return its numeric ID.
412 ///
413 /// Requires an auth token. If the service reports that an album with a similar name already
414 /// exists, this returns [`CyberdropError::AlbumAlreadyExists`].
415 ///
416 /// # Errors
417 ///
418 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
419 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
420 /// - [`CyberdropError::AlbumAlreadyExists`] if the service indicates an album already exists
421 /// - [`CyberdropError::Api`] for other service-reported failures
422 /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
423 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
424 pub async fn create_album(
425 &self,
426 name: impl Into<String>,
427 description: Option<impl Into<String>>,
428 ) -> Result<u64, CyberdropError> {
429 let payload = CreateAlbumRequest {
430 name: name.into(),
431 description: description.map(Into::into),
432 };
433
434 let response: CreateAlbumResponse = self
435 .transport
436 .post_json("api/albums", &payload, true)
437 .await?;
438
439 u64::try_from(response)
440 }
441
442 /// Edit an existing album ("folder").
443 ///
444 /// This endpoint updates album metadata such as name/description and visibility flags.
445 /// It can also request a new link identifier.
446 ///
447 /// Requires an auth token.
448 ///
449 /// # Returns
450 ///
451 /// The API returns either a `name` (typical edits) or an `identifier` (when requesting a new
452 /// link). This crate exposes both as optional fields on [`EditAlbumResult`].
453 ///
454 /// # Errors
455 ///
456 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
457 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
458 /// - [`CyberdropError::Api`] for service-reported failures
459 /// - [`CyberdropError::MissingField`] if the response is missing expected fields
460 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
461 pub async fn edit_album(
462 &self,
463 id: u64,
464 name: impl Into<String>,
465 description: impl Into<String>,
466 download: bool,
467 public: bool,
468 request_new_link: bool,
469 ) -> Result<EditAlbumResult, CyberdropError> {
470 let payload = EditAlbumRequest {
471 id,
472 name: name.into(),
473 description: description.into(),
474 download,
475 public,
476 request_link: request_new_link,
477 };
478
479 let response: EditAlbumResponse = self
480 .transport
481 .post_json("api/albums/edit", &payload, true)
482 .await?;
483
484 EditAlbumResult::try_from(response)
485 }
486
487 /// Request a new public link identifier for an existing album, preserving its current settings.
488 ///
489 /// This is a convenience wrapper around:
490 /// 1) [`CyberdropClient::list_albums`] (to fetch current album settings)
491 /// 2) [`CyberdropClient::edit_album`] with `request_new_link = true`
492 ///
493 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
494 ///
495 /// # Returns
496 ///
497 /// The new album public URL in the form `https://cyberdrop.cr/a/<identifier>`.
498 ///
499 /// Note: this URL is always built against `https://cyberdrop.cr/` (it does not use the
500 /// client's configured base URL).
501 ///
502 /// # Errors
503 ///
504 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
505 /// - [`CyberdropError::AlbumNotFound`] if `album_id` is not present in the album list
506 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
507 /// - [`CyberdropError::Api`] for service-reported failures
508 /// - [`CyberdropError::MissingField`] if the API omits the new identifier
509 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
510 pub async fn request_new_album_link(&self, album_id: u64) -> Result<String, CyberdropError> {
511 let album = self.get_album_by_id(album_id).await?;
512
513 let edited = self
514 .edit_album(
515 album_id,
516 album.name,
517 album.description,
518 album.download,
519 album.public,
520 true,
521 )
522 .await?;
523
524 let identifier = edited.identifier.ok_or(CyberdropError::MissingField(
525 "edit album response missing identifier",
526 ))?;
527
528 let identifier = identifier.trim_start_matches('/');
529 Ok(identifier.to_string())
530 }
531
532 /// Update an album name, preserving existing description and visibility flags.
533 ///
534 /// This is a convenience wrapper around:
535 /// 1) [`CyberdropClient::list_albums`] (to fetch current album settings)
536 /// 2) [`CyberdropClient::edit_album`] with `request_new_link = false`
537 ///
538 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
539 ///
540 /// # Returns
541 ///
542 /// The API response mapped into an [`EditAlbumResult`].
543 ///
544 /// # Errors
545 ///
546 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
547 /// - [`CyberdropError::AlbumNotFound`] if `album_id` is not present in the album list
548 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
549 /// - [`CyberdropError::Api`] for service-reported failures
550 /// - [`CyberdropError::MissingField`] if the response is missing expected fields
551 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
552 pub async fn set_album_name(
553 &self,
554 album_id: u64,
555 name: impl Into<String>,
556 ) -> Result<EditAlbumResult, CyberdropError> {
557 let album = self.get_album_by_id(album_id).await?;
558 self.edit_album(
559 album_id,
560 name,
561 album.description,
562 album.download,
563 album.public,
564 false,
565 )
566 .await
567 }
568
569 /// Update an album description, preserving existing name and visibility flags.
570 ///
571 /// This is a convenience wrapper around:
572 /// 1) [`CyberdropClient::list_albums`] (to fetch current album settings)
573 /// 2) [`CyberdropClient::edit_album`] with `request_new_link = false`
574 ///
575 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
576 ///
577 /// # Returns
578 ///
579 /// The API response mapped into an [`EditAlbumResult`].
580 ///
581 /// # Errors
582 ///
583 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
584 /// - [`CyberdropError::AlbumNotFound`] if `album_id` is not present in the album list
585 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
586 /// - [`CyberdropError::Api`] for service-reported failures
587 /// - [`CyberdropError::MissingField`] if the response is missing expected fields
588 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
589 pub async fn set_album_description(
590 &self,
591 album_id: u64,
592 description: impl Into<String>,
593 ) -> Result<EditAlbumResult, CyberdropError> {
594 let album = self.get_album_by_id(album_id).await?;
595 self.edit_album(
596 album_id,
597 album.name,
598 description,
599 album.download,
600 album.public,
601 false,
602 )
603 .await
604 }
605
606 /// Update an album download flag, preserving existing name/description and public flag.
607 ///
608 /// This is a convenience wrapper around:
609 /// 1) [`CyberdropClient::list_albums`] (to fetch current album settings)
610 /// 2) [`CyberdropClient::edit_album`] with `request_new_link = false`
611 ///
612 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
613 ///
614 /// # Returns
615 ///
616 /// The API response mapped into an [`EditAlbumResult`].
617 ///
618 /// # Errors
619 ///
620 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
621 /// - [`CyberdropError::AlbumNotFound`] if `album_id` is not present in the album list
622 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
623 /// - [`CyberdropError::Api`] for service-reported failures
624 /// - [`CyberdropError::MissingField`] if the response is missing expected fields
625 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
626 pub async fn set_album_download(
627 &self,
628 album_id: u64,
629 download: bool,
630 ) -> Result<EditAlbumResult, CyberdropError> {
631 let album = self.get_album_by_id(album_id).await?;
632 self.edit_album(
633 album_id,
634 album.name,
635 album.description,
636 download,
637 album.public,
638 false,
639 )
640 .await
641 }
642
643 /// Update an album public flag, preserving existing name/description and download flag.
644 ///
645 /// This is a convenience wrapper around:
646 /// 1) [`CyberdropClient::list_albums`] (to fetch current album settings)
647 /// 2) [`CyberdropClient::edit_album`] with `request_new_link = false`
648 ///
649 /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
650 ///
651 /// # Returns
652 ///
653 /// The API response mapped into an [`EditAlbumResult`].
654 ///
655 /// # Errors
656 ///
657 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
658 /// - [`CyberdropError::AlbumNotFound`] if `album_id` is not present in the album list
659 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
660 /// - [`CyberdropError::Api`] for service-reported failures
661 /// - [`CyberdropError::MissingField`] if the response is missing expected fields
662 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
663 pub async fn set_album_public(
664 &self,
665 album_id: u64,
666 public: bool,
667 ) -> Result<EditAlbumResult, CyberdropError> {
668 let album = self.get_album_by_id(album_id).await?;
669 self.edit_album(
670 album_id,
671 album.name,
672 album.description,
673 album.download,
674 public,
675 false,
676 )
677 .await
678 }
679
680 /// Upload a single file.
681 ///
682 /// Requires an auth token.
683 ///
684 /// Implementation notes:
685 /// - Small files are streamed.
686 /// - Large files are uploaded in chunks from disk.
687 /// - Files larger than `95_000_000` bytes are uploaded in chunks.
688 /// - If `album_id` is provided, it is sent as an `albumid` header on the chunk/single-upload
689 /// requests and included in the `finishchunks` payload.
690 ///
691 /// # Errors
692 ///
693 /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
694 /// - [`CyberdropError::InvalidFileName`] if `file_path` does not have a valid UTF-8 file name
695 /// - [`CyberdropError::Io`] if reading the file fails
696 /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
697 /// - [`CyberdropError::Api`] if the service reports an upload failure (including per-chunk failures)
698 /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
699 /// - [`CyberdropError::Http`] for transport failures (including timeouts)
700 pub async fn upload_file(
701 &self,
702 file_path: impl AsRef<Path>,
703 album_id: Option<u64>,
704 ) -> Result<UploadedFile, CyberdropError> {
705 self.upload_file_with_progress(file_path, album_id, |_| {})
706 .await
707 }
708
709 /// Upload a single file and emit per-file progress updates.
710 ///
711 /// The `on_progress` callback is invoked as bytes are streamed or as chunks complete.
712 pub async fn upload_file_with_progress<F>(
713 &self,
714 file_path: impl AsRef<Path>,
715 album_id: Option<u64>,
716 mut on_progress: F,
717 ) -> Result<UploadedFile, CyberdropError>
718 where
719 F: FnMut(UploadProgress) + Send + 'static,
720 {
721 let file_path = file_path.as_ref();
722 let file_name = file_path
723 .file_name()
724 .and_then(|n| n.to_str())
725 .ok_or(CyberdropError::InvalidFileName)?
726 .to_string();
727
728 let mime = mime_guess::from_path(file_path)
729 .first_raw()
730 .unwrap_or("application/octet-stream")
731 .to_string();
732
733 let file = File::open(file_path).await?;
734 let total_size = file.metadata().await?.len();
735 let upload_url = self.get_upload_url().await?;
736
737 // For small files, use the simple single-upload endpoint.
738 if total_size <= CHUNK_SIZE {
739 let stream = ReaderStream::new(file);
740 let progress_stream =
741 ProgressStream::new(stream, total_size, file_name.clone(), on_progress);
742 let body = Body::wrap_stream(progress_stream);
743 let part = reqwest::multipart::Part::stream_with_length(body, total_size)
744 .file_name(file_name.clone());
745 let part = match part.mime_str(&mime) {
746 Ok(p) => p,
747 Err(_) => reqwest::multipart::Part::bytes(Vec::new()).file_name(file_name.clone()),
748 };
749 let form = Form::new().part("files[]", part);
750 let response: UploadResponse = self
751 .transport
752 .post_single_upload_url(upload_url, form, album_id)
753 .await?;
754 return UploadedFile::try_from(response);
755 }
756
757 let chunk_size = CHUNK_SIZE.min(total_size.max(1));
758 let total_chunks = ((total_size + chunk_size - 1) / chunk_size).max(1);
759 let uuid = Uuid::new_v4().to_string();
760 let mut file = file;
761 let mut bytes_sent = 0u64;
762 let mut chunk_index = 0u64;
763
764 loop {
765 let mut buffer = vec![0u8; chunk_size as usize];
766 let read = file.read(&mut buffer).await?;
767 if read == 0 {
768 break;
769 }
770 buffer.truncate(read);
771 let byte_offset = chunk_index * chunk_size;
772
773 let response: serde_json::Value = self
774 .transport
775 .post_chunk_url(
776 upload_url.clone(),
777 buffer,
778 ChunkFields {
779 uuid: uuid.clone(),
780 chunk_index,
781 total_size,
782 chunk_size,
783 total_chunks,
784 byte_offset,
785 file_name: file_name.clone(),
786 mime_type: mime.clone(),
787 album_id,
788 },
789 )
790 .await?;
791
792 if !response
793 .get("success")
794 .and_then(|v| v.as_bool())
795 .unwrap_or(false)
796 {
797 return Err(CyberdropError::Api(format!("chunk {} failed", chunk_index)));
798 }
799
800 bytes_sent = bytes_sent.saturating_add(read as u64);
801 on_progress(UploadProgress {
802 file_name: file_name.clone(),
803 bytes_sent,
804 total_bytes: total_size,
805 });
806 chunk_index = chunk_index.saturating_add(1);
807 }
808
809 let payload = FinishChunksPayload {
810 files: vec![FinishFile {
811 uuid,
812 original: file_name,
813 r#type: mime,
814 albumid: album_id,
815 filelength: None,
816 age: None,
817 }],
818 };
819
820 let finish_url = {
821 let mut url = upload_url;
822 url.set_path("/api/upload/finishchunks");
823 url
824 };
825
826 let response: UploadResponse = self
827 .transport
828 .post_json_with_upload_headers_url(finish_url, &payload)
829 .await?;
830
831 UploadedFile::try_from(response)
832 }
833}
834
835impl CyberdropClientBuilder {
836 /// Create a new builder using the crate defaults.
837 ///
838 /// This is equivalent to [`CyberdropClient::builder`].
839 pub fn new() -> Self {
840 Self {
841 base_url: None,
842 user_agent: None,
843 timeout: DEFAULT_TIMEOUT,
844 auth_token: None,
845 builder: Client::builder(),
846 }
847 }
848
849 /// Override the base URL used for requests.
850 pub fn base_url(mut self, base_url: impl AsRef<str>) -> Result<Self, CyberdropError> {
851 self.base_url = Some(Url::parse(base_url.as_ref())?);
852 Ok(self)
853 }
854
855 /// Set a custom user agent header.
856 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
857 self.user_agent = Some(user_agent.into());
858 self
859 }
860
861 /// Provide an auth token that will be sent as bearer auth.
862 pub fn auth_token(mut self, token: impl Into<String>) -> Self {
863 self.auth_token = Some(AuthToken::new(token));
864 self
865 }
866
867 /// Configure the request timeout.
868 ///
869 /// This sets [`reqwest::ClientBuilder::timeout`], which applies a single deadline per request.
870 /// Timeout failures surface as [`CyberdropError::Http`].
871 pub fn timeout(mut self, timeout: Duration) -> Self {
872 self.timeout = timeout;
873 self
874 }
875
876 /// Build a [`CyberdropClient`].
877 ///
878 /// If no base URL is configured, this uses `https://cyberdrop.cr/`.
879 /// If no user agent is configured, a browser-like UA string is used.
880 pub fn build(self) -> Result<CyberdropClient, CyberdropError> {
881 let base_url = match self.base_url {
882 Some(url) => url,
883 None => Url::parse(DEFAULT_BASE_URL)?,
884 };
885
886 let mut builder = self.builder.timeout(self.timeout);
887 builder = builder.user_agent(self.user_agent.unwrap_or_else(default_user_agent));
888
889 let client = builder.build()?;
890
891 Ok(CyberdropClient {
892 transport: Transport::new(client, base_url, self.auth_token),
893 })
894 }
895}
896
897fn default_user_agent() -> String {
898 // Match a browser UA; the service appears to expect browser-like clients.
899 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0".into()
900}