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
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}