Skip to main content

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}