Skip to main content

cyberdrop_client/
client.rs

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