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}