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
109struct PreparedUpload {
110    file: File,
111    file_name: String,
112    mime: String,
113    total_size: u64,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117enum UploadStrategy {
118    Single,
119    Chunked,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123struct ChunkUploadPlan {
124    chunk_size: u64,
125    total_chunks: u64,
126}
127
128impl ChunkUploadPlan {
129    fn new(total_size: u64) -> Self {
130        let chunk_size = CHUNK_SIZE.min(total_size.max(1));
131        let total_chunks = total_size.div_ceil(chunk_size).max(1);
132
133        Self {
134            chunk_size,
135            total_chunks,
136        }
137    }
138
139    fn byte_offset(self, chunk_index: u64) -> u64 {
140        chunk_index * self.chunk_size
141    }
142}
143
144async fn prepare_upload_file(file_path: &Path) -> Result<PreparedUpload, CyberdropError> {
145    let file_name = file_path
146        .file_name()
147        .and_then(|n| n.to_str())
148        .ok_or(CyberdropError::InvalidFileName)?
149        .to_string();
150
151    let mime = mime_guess::from_path(file_path)
152        .first_raw()
153        .unwrap_or("application/octet-stream")
154        .to_string();
155
156    let file = File::open(file_path).await?;
157    let total_size = file.metadata().await?.len();
158
159    Ok(PreparedUpload {
160        file,
161        file_name,
162        mime,
163        total_size,
164    })
165}
166
167fn select_upload_strategy(total_size: u64) -> UploadStrategy {
168    if total_size <= CHUNK_SIZE {
169        UploadStrategy::Single
170    } else {
171        UploadStrategy::Chunked
172    }
173}
174
175fn build_chunk_fields(
176    uuid: &str,
177    chunk_index: u64,
178    plan: ChunkUploadPlan,
179    total_size: u64,
180    file_name: &str,
181    mime_type: &str,
182    album_id: Option<u64>,
183) -> ChunkFields {
184    ChunkFields {
185        uuid: uuid.to_string(),
186        chunk_index,
187        total_size,
188        chunk_size: plan.chunk_size,
189        total_chunks: plan.total_chunks,
190        byte_offset: plan.byte_offset(chunk_index),
191        file_name: file_name.to_string(),
192        mime_type: mime_type.to_string(),
193        album_id,
194    }
195}
196
197fn build_finish_chunks_payload(
198    uuid: String,
199    file_name: String,
200    mime: String,
201    album_id: Option<u64>,
202) -> FinishChunksPayload {
203    FinishChunksPayload {
204        files: vec![FinishFile {
205            uuid,
206            original: file_name,
207            r#type: mime,
208            albumid: album_id,
209            filelength: None,
210            age: None,
211        }],
212    }
213}
214
215fn finish_chunks_url(mut upload_url: Url) -> Url {
216    upload_url.set_path("/api/upload/finishchunks");
217    upload_url
218}
219
220/// Async HTTP client for a subset of Cyberdrop endpoints.
221///
222/// Most higher-level methods map non-2xx responses to [`CyberdropError`]. For raw access where
223/// you want to inspect status codes and bodies directly, use [`CyberdropClient::get`].
224#[derive(Debug, Clone)]
225pub struct CyberdropClient {
226    transport: Transport,
227}
228
229/// Builder for [`CyberdropClient`].
230#[derive(Debug)]
231pub struct CyberdropClientBuilder {
232    base_url: Option<Url>,
233    user_agent: Option<String>,
234    timeout: Duration,
235    auth_token: Option<AuthToken>,
236    builder: ClientBuilder,
237}
238
239const CHUNK_SIZE: u64 = 95_000_000;
240const DEFAULT_BASE_URL: &str = "https://cyberdrop.cr/";
241const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
242
243impl CyberdropClient {
244    /// Build a client with a custom base URL.
245    ///
246    /// `base_url` is parsed as a [`Url`]. It is then used as the base for relative API paths via
247    /// [`Url::join`], so a trailing slash is recommended.
248    pub fn new(base_url: impl AsRef<str>) -> Result<Self, CyberdropError> {
249        CyberdropClientBuilder::new().base_url(base_url)?.build()
250    }
251
252    /// Start configuring a client with the crate's defaults.
253    ///
254    /// Defaults:
255    /// - Base URL: `https://cyberdrop.cr/`
256    /// - Timeout: 30 seconds
257    /// - User agent: a browser-like UA string
258    pub fn builder() -> CyberdropClientBuilder {
259        CyberdropClientBuilder::new()
260    }
261
262    /// Current base URL.
263    pub fn base_url(&self) -> &Url {
264        self.transport.base_url()
265    }
266
267    /// Current auth token if configured.
268    pub fn auth_token(&self) -> Option<&str> {
269        self.transport.auth_token()
270    }
271
272    /// Return a clone of this client that applies authentication to requests.
273    ///
274    /// The token is attached as an HTTP header named `token`.
275    pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
276        self.transport = self.transport.with_auth_token(token);
277        self
278    }
279
280    pub async fn get_album_by_id(
281        &self,
282        album_id: u64,
283    ) -> Result<crate::models::Album, CyberdropError> {
284        let albums = self.list_albums().await?;
285        albums
286            .albums
287            .into_iter()
288            .find(|album| album.id == album_id)
289            .ok_or(CyberdropError::AlbumNotFound(album_id))
290    }
291
292    /// Execute a GET request against a relative path on the configured base URL.
293    ///
294    /// This method returns the raw [`reqwest::Response`] and does **not** convert non-2xx status
295    /// codes into errors. If a token is configured, it will be attached, but authentication is
296    /// not required.
297    ///
298    /// # Errors
299    ///
300    /// Returns [`CyberdropError::Http`] on transport failures (including timeouts). This method
301    /// does not map HTTP status codes to [`CyberdropError`] variants.
302    pub async fn get(&self, path: impl AsRef<str>) -> Result<reqwest::Response, CyberdropError> {
303        self.transport.get_raw(path.as_ref()).await
304    }
305
306    /// Authenticate and retrieve a token.
307    ///
308    /// The returned token can be installed on a client via [`CyberdropClient::with_auth_token`]
309    /// or [`CyberdropClientBuilder::auth_token`].
310    ///
311    /// # Errors
312    ///
313    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
314    /// - [`CyberdropError::MissingToken`] if the response body omits the token field
315    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
316    pub async fn login(
317        &self,
318        username: impl Into<String>,
319        password: impl Into<String>,
320    ) -> Result<AuthToken, CyberdropError> {
321        let payload = LoginRequest {
322            username: username.into(),
323            password: password.into(),
324        };
325
326        let response: LoginResponse = self
327            .transport
328            .post_json("api/login", &payload, false)
329            .await?;
330
331        AuthToken::try_from(response)
332    }
333
334    /// Register a new account and retrieve a token.
335    ///
336    /// The returned token can be installed on a client via [`CyberdropClient::with_auth_token`]
337    /// or [`CyberdropClientBuilder::auth_token`].
338    ///
339    /// Note: the API returns HTTP 200 even for validation failures; this method converts
340    /// `{"success":false,...}` responses into [`CyberdropError::Api`].
341    ///
342    /// # Errors
343    ///
344    /// - [`CyberdropError::Api`] if the API reports a validation failure (e.g. username taken)
345    /// - [`CyberdropError::MissingToken`] if the response body omits the token field on success
346    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
347    pub async fn register(
348        &self,
349        username: impl Into<String>,
350        password: impl Into<String>,
351    ) -> Result<AuthToken, CyberdropError> {
352        let payload = RegisterRequest {
353            username: username.into(),
354            password: password.into(),
355        };
356
357        let response: RegisterResponse = self
358            .transport
359            .post_json("api/register", &payload, false)
360            .await?;
361
362        AuthToken::try_from(response)
363    }
364
365    /// Verify a token and fetch associated permissions.
366    ///
367    /// This request does not require the client to be authenticated; the token to verify is
368    /// supplied in the request body.
369    ///
370    /// # Errors
371    ///
372    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
373    /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
374    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
375    pub async fn verify_token(
376        &self,
377        token: impl Into<String>,
378    ) -> Result<TokenVerification, CyberdropError> {
379        let payload = VerifyTokenRequest {
380            token: token.into(),
381        };
382
383        let response: VerifyTokenResponse = self
384            .transport
385            .post_json("api/tokens/verify", &payload, false)
386            .await?;
387
388        TokenVerification::try_from(response)
389    }
390
391    /// Fetch the upload node URL for the authenticated user.
392    ///
393    /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
394    pub async fn get_upload_url(&self) -> Result<Url, CyberdropError> {
395        let response: NodeResponse = self.transport.get_json("api/node", true).await?;
396
397        if !response.success.unwrap_or(false) {
398            let msg = response
399                .description
400                .or(response.message)
401                .unwrap_or_else(|| "failed to fetch upload node".to_string());
402            return Err(CyberdropError::Api(msg));
403        }
404
405        let url = response
406            .url
407            .ok_or(CyberdropError::MissingField("node response missing url"))?;
408
409        Ok(Url::parse(&url)?)
410    }
411
412    /// List albums for the authenticated user.
413    ///
414    /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
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::MissingField`] if expected fields are missing in the response body
421    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
422    pub async fn list_albums(&self) -> Result<AlbumsList, CyberdropError> {
423        let response: AlbumsResponse = self
424            .transport
425            .get_json_with_header("api/albums", true, "Simple", "1")
426            .await?;
427        AlbumsList::try_from(response)
428    }
429
430    /// List all files in an album ("folder") by iterating pages until exhaustion.
431    ///
432    /// This calls [`CyberdropClient::list_album_files_page`] repeatedly starting at `page = 0` and
433    /// stops when:
434    /// - enough files have been collected to satisfy the API-reported `count`, or
435    /// - a page returns zero files, or
436    /// - a page yields no new file IDs (defensive infinite-loop guard).
437    ///
438    /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
439    ///
440    /// # Returns
441    ///
442    /// An [`AlbumFilesPage`] containing all collected files. The returned `count` is the total
443    /// file count as reported by the API.
444    ///
445    /// # Errors
446    ///
447    /// Any error returned by [`CyberdropClient::list_album_files_page`].
448    pub async fn list_album_files(&self, album_id: u64) -> Result<AlbumFilesPage, CyberdropError> {
449        let mut page = 0u64;
450        let mut all_files = Vec::new();
451        let mut total_count = None::<u64>;
452        let mut albums = std::collections::HashMap::new();
453        let mut base_domain = None::<Url>;
454        let mut seen = std::collections::HashSet::<u64>::new();
455
456        loop {
457            let res = self.list_album_files_page(album_id, page).await?;
458
459            if base_domain.is_none() {
460                base_domain = res.base_domain.clone();
461            }
462            if total_count.is_none() {
463                total_count = Some(res.count);
464            }
465            albums.extend(res.albums.into_iter());
466
467            if res.files.is_empty() {
468                break;
469            }
470
471            let mut added = 0usize;
472            for file in res.files.into_iter() {
473                if seen.insert(file.id) {
474                    all_files.push(file);
475                    added += 1;
476                }
477            }
478
479            if added == 0 {
480                break;
481            }
482
483            if let Some(total) = total_count
484                && all_files.len() as u64 >= total
485            {
486                break;
487            }
488
489            page += 1;
490        }
491
492        Ok(AlbumFilesPage {
493            success: true,
494            files: all_files,
495            count: total_count.unwrap_or(0),
496            albums,
497            base_domain,
498        })
499    }
500
501    /// List files in an album ("folder") for a specific page.
502    ///
503    /// Page numbers are zero-based (`page = 0` is the first page). This is intentionally exposed
504    /// so a higher-level pagination helper can be added later.
505    ///
506    /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
507    ///
508    /// # Errors
509    ///
510    /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
511    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
512    /// - [`CyberdropError::Api`] for service-reported failures
513    /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
514    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
515    pub async fn list_album_files_page(
516        &self,
517        album_id: u64,
518        page: u64,
519    ) -> Result<AlbumFilesPage, CyberdropError> {
520        let path = format!("api/album/{album_id}/{page}");
521        let response: AlbumFilesResponse = self.transport.get_json(&path, true).await?;
522        AlbumFilesPage::try_from(response)
523    }
524
525    /// Create a new album and return its numeric ID.
526    ///
527    /// Requires an auth token. If the service reports that an album with a similar name already
528    /// exists, this returns [`CyberdropError::AlbumAlreadyExists`].
529    ///
530    /// # Errors
531    ///
532    /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
533    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
534    /// - [`CyberdropError::AlbumAlreadyExists`] if the service indicates an album already exists
535    /// - [`CyberdropError::Api`] for other service-reported failures
536    /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
537    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
538    pub async fn create_album(
539        &self,
540        name: impl Into<String>,
541        description: Option<impl Into<String>>,
542    ) -> Result<u64, CyberdropError> {
543        let payload = CreateAlbumRequest {
544            name: name.into(),
545            description: description.map(Into::into),
546        };
547
548        let response: CreateAlbumResponse = self
549            .transport
550            .post_json("api/albums", &payload, true)
551            .await?;
552
553        u64::try_from(response)
554    }
555
556    /// Edit an existing album ("folder").
557    ///
558    /// This endpoint updates album metadata such as name/description and visibility flags.
559    /// It can also request a new link identifier.
560    ///
561    /// Requires an auth token.
562    ///
563    /// # Returns
564    ///
565    /// The API returns either a `name` (typical edits) or an `identifier` (when requesting a new
566    /// link). This crate exposes both as optional fields on [`EditAlbumResult`].
567    ///
568    /// # Errors
569    ///
570    /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
571    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
572    /// - [`CyberdropError::Api`] for service-reported failures
573    /// - [`CyberdropError::MissingField`] if the response is missing expected fields
574    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
575    pub async fn edit_album(
576        &self,
577        id: u64,
578        name: impl Into<String>,
579        description: impl Into<String>,
580        download: bool,
581        public: bool,
582        request_new_link: bool,
583    ) -> Result<EditAlbumResult, CyberdropError> {
584        let payload = EditAlbumRequest {
585            id,
586            name: name.into(),
587            description: description.into(),
588            download,
589            public,
590            request_link: request_new_link,
591        };
592
593        let response: EditAlbumResponse = self
594            .transport
595            .post_json("api/albums/edit", &payload, true)
596            .await?;
597
598        EditAlbumResult::try_from(response)
599    }
600
601    /// Request a new public link identifier for an existing album, preserving its current settings.
602    ///
603    /// This is a convenience wrapper around:
604    /// 1) [`CyberdropClient::list_albums`] (to fetch current album settings)
605    /// 2) [`CyberdropClient::edit_album`] with `request_new_link = true`
606    ///
607    /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
608    ///
609    /// # Returns
610    ///
611    /// The new album public URL in the form `https://cyberdrop.cr/a/<identifier>`.
612    ///
613    /// Note: this URL is always built against `https://cyberdrop.cr/` (it does not use the
614    /// client's configured base URL).
615    ///
616    /// # Errors
617    ///
618    /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
619    /// - [`CyberdropError::AlbumNotFound`] if `album_id` is not present in the album list
620    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
621    /// - [`CyberdropError::Api`] for service-reported failures
622    /// - [`CyberdropError::MissingField`] if the API omits the new identifier
623    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
624    pub async fn request_new_album_link(&self, album_id: u64) -> Result<String, CyberdropError> {
625        let album = self.get_album_by_id(album_id).await?;
626
627        let edited = self
628            .edit_album(
629                album_id,
630                album.name,
631                album.description,
632                album.download,
633                album.public,
634                true,
635            )
636            .await?;
637
638        let identifier = edited.identifier.ok_or(CyberdropError::MissingField(
639            "edit album response missing identifier",
640        ))?;
641
642        let identifier = identifier.trim_start_matches('/');
643        Ok(identifier.to_string())
644    }
645
646    /// Update an album name, preserving existing description and visibility flags.
647    ///
648    /// This is a convenience wrapper around:
649    /// 1) [`CyberdropClient::list_albums`] (to fetch current album settings)
650    /// 2) [`CyberdropClient::edit_album`] with `request_new_link = false`
651    ///
652    /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
653    ///
654    /// # Returns
655    ///
656    /// The API response mapped into an [`EditAlbumResult`].
657    ///
658    /// # Errors
659    ///
660    /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
661    /// - [`CyberdropError::AlbumNotFound`] if `album_id` is not present in the album list
662    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
663    /// - [`CyberdropError::Api`] for service-reported failures
664    /// - [`CyberdropError::MissingField`] if the response is missing expected fields
665    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
666    pub async fn set_album_name(
667        &self,
668        album_id: u64,
669        name: impl Into<String>,
670    ) -> Result<EditAlbumResult, CyberdropError> {
671        let album = self.get_album_by_id(album_id).await?;
672        self.edit_album(
673            album_id,
674            name,
675            album.description,
676            album.download,
677            album.public,
678            false,
679        )
680        .await
681    }
682
683    /// Update an album description, preserving existing name and visibility flags.
684    ///
685    /// This is a convenience wrapper around:
686    /// 1) [`CyberdropClient::list_albums`] (to fetch current album settings)
687    /// 2) [`CyberdropClient::edit_album`] with `request_new_link = false`
688    ///
689    /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
690    ///
691    /// # Returns
692    ///
693    /// The API response mapped into an [`EditAlbumResult`].
694    ///
695    /// # Errors
696    ///
697    /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
698    /// - [`CyberdropError::AlbumNotFound`] if `album_id` is not present in the album list
699    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
700    /// - [`CyberdropError::Api`] for service-reported failures
701    /// - [`CyberdropError::MissingField`] if the response is missing expected fields
702    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
703    pub async fn set_album_description(
704        &self,
705        album_id: u64,
706        description: impl Into<String>,
707    ) -> Result<EditAlbumResult, CyberdropError> {
708        let album = self.get_album_by_id(album_id).await?;
709        self.edit_album(
710            album_id,
711            album.name,
712            description,
713            album.download,
714            album.public,
715            false,
716        )
717        .await
718    }
719
720    /// Update an album download flag, preserving existing name/description and public flag.
721    ///
722    /// This is a convenience wrapper around:
723    /// 1) [`CyberdropClient::list_albums`] (to fetch current album settings)
724    /// 2) [`CyberdropClient::edit_album`] with `request_new_link = false`
725    ///
726    /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
727    ///
728    /// # Returns
729    ///
730    /// The API response mapped into an [`EditAlbumResult`].
731    ///
732    /// # Errors
733    ///
734    /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
735    /// - [`CyberdropError::AlbumNotFound`] if `album_id` is not present in the album list
736    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
737    /// - [`CyberdropError::Api`] for service-reported failures
738    /// - [`CyberdropError::MissingField`] if the response is missing expected fields
739    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
740    pub async fn set_album_download(
741        &self,
742        album_id: u64,
743        download: bool,
744    ) -> Result<EditAlbumResult, CyberdropError> {
745        let album = self.get_album_by_id(album_id).await?;
746        self.edit_album(
747            album_id,
748            album.name,
749            album.description,
750            download,
751            album.public,
752            false,
753        )
754        .await
755    }
756
757    /// Update an album public flag, preserving existing name/description and download flag.
758    ///
759    /// This is a convenience wrapper around:
760    /// 1) [`CyberdropClient::list_albums`] (to fetch current album settings)
761    /// 2) [`CyberdropClient::edit_album`] with `request_new_link = false`
762    ///
763    /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
764    ///
765    /// # Returns
766    ///
767    /// The API response mapped into an [`EditAlbumResult`].
768    ///
769    /// # Errors
770    ///
771    /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
772    /// - [`CyberdropError::AlbumNotFound`] if `album_id` is not present in the album list
773    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
774    /// - [`CyberdropError::Api`] for service-reported failures
775    /// - [`CyberdropError::MissingField`] if the response is missing expected fields
776    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
777    pub async fn set_album_public(
778        &self,
779        album_id: u64,
780        public: bool,
781    ) -> Result<EditAlbumResult, CyberdropError> {
782        let album = self.get_album_by_id(album_id).await?;
783        self.edit_album(
784            album_id,
785            album.name,
786            album.description,
787            album.download,
788            public,
789            false,
790        )
791        .await
792    }
793
794    /// Upload a single file.
795    ///
796    /// Requires an auth token.
797    ///
798    /// Implementation notes:
799    /// - Small files are streamed.
800    /// - Large files are uploaded in chunks from disk.
801    /// - Files larger than `95_000_000` bytes are uploaded in chunks.
802    /// - If `album_id` is provided, it is sent as an `albumid` header on the chunk/single-upload
803    ///   requests and included in the `finishchunks` payload.
804    ///
805    /// # Errors
806    ///
807    /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
808    /// - [`CyberdropError::InvalidFileName`] if `file_path` does not have a valid UTF-8 file name
809    /// - [`CyberdropError::Io`] if reading the file fails
810    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
811    /// - [`CyberdropError::Api`] if the service reports an upload failure (including per-chunk failures)
812    /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
813    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
814    pub async fn upload_file(
815        &self,
816        file_path: impl AsRef<Path>,
817        album_id: Option<u64>,
818    ) -> Result<UploadedFile, CyberdropError> {
819        self.upload_file_with_progress(file_path, album_id, |_| {})
820            .await
821    }
822
823    /// Upload a single file and emit per-file progress updates.
824    ///
825    /// The `on_progress` callback is invoked as bytes are streamed or as chunks complete.
826    pub async fn upload_file_with_progress<F>(
827        &self,
828        file_path: impl AsRef<Path>,
829        album_id: Option<u64>,
830        on_progress: F,
831    ) -> Result<UploadedFile, CyberdropError>
832    where
833        F: FnMut(UploadProgress) + Send + 'static,
834    {
835        let prepared = prepare_upload_file(file_path.as_ref()).await?;
836        let upload_url = self.get_upload_url().await?;
837
838        match select_upload_strategy(prepared.total_size) {
839            UploadStrategy::Single => {
840                self.upload_small_file_with_progress(upload_url, prepared, album_id, on_progress)
841                    .await
842            }
843            UploadStrategy::Chunked => {
844                self.upload_chunked_file_with_progress(upload_url, prepared, album_id, on_progress)
845                    .await
846            }
847        }
848    }
849
850    async fn upload_small_file_with_progress<F>(
851        &self,
852        upload_url: Url,
853        prepared: PreparedUpload,
854        album_id: Option<u64>,
855        on_progress: F,
856    ) -> Result<UploadedFile, CyberdropError>
857    where
858        F: FnMut(UploadProgress) + Send + 'static,
859    {
860        let PreparedUpload {
861            file,
862            file_name,
863            mime,
864            total_size,
865        } = prepared;
866
867        let stream = ReaderStream::new(file);
868        let progress_stream =
869            ProgressStream::new(stream, total_size, file_name.clone(), on_progress);
870        let body = Body::wrap_stream(progress_stream);
871        let part = reqwest::multipart::Part::stream_with_length(body, total_size)
872            .file_name(file_name.clone());
873        let part = match part.mime_str(&mime) {
874            Ok(p) => p,
875            Err(_) => reqwest::multipart::Part::bytes(Vec::new()).file_name(file_name.clone()),
876        };
877        let form = Form::new().part("files[]", part);
878        let response: UploadResponse = self
879            .transport
880            .post_single_upload_url(upload_url, form, album_id)
881            .await?;
882
883        UploadedFile::try_from(response)
884    }
885
886    async fn upload_chunked_file_with_progress<F>(
887        &self,
888        upload_url: Url,
889        prepared: PreparedUpload,
890        album_id: Option<u64>,
891        mut on_progress: F,
892    ) -> Result<UploadedFile, CyberdropError>
893    where
894        F: FnMut(UploadProgress) + Send + 'static,
895    {
896        let PreparedUpload {
897            mut file,
898            file_name,
899            mime,
900            total_size,
901        } = prepared;
902
903        let plan = ChunkUploadPlan::new(total_size);
904        let uuid = Uuid::new_v4().to_string();
905        let mut bytes_sent = 0u64;
906        let mut chunk_index = 0u64;
907        let mut buffer = Vec::with_capacity(plan.chunk_size as usize);
908
909        loop {
910            buffer.clear();
911            let read = file.read_buf(&mut buffer).await?;
912            if read == 0 {
913                break;
914            }
915
916            let response: serde_json::Value = self
917                .transport
918                .post_chunk_url(
919                    upload_url.clone(),
920                    buffer,
921                    build_chunk_fields(
922                        &uuid,
923                        chunk_index,
924                        plan,
925                        total_size,
926                        &file_name,
927                        &mime,
928                        album_id,
929                    ),
930                )
931                .await?;
932
933            if !response
934                .get("success")
935                .and_then(|v| v.as_bool())
936                .unwrap_or(false)
937            {
938                return Err(CyberdropError::Api(format!("chunk {} failed", chunk_index)));
939            }
940
941            bytes_sent = bytes_sent.saturating_add(read as u64);
942            on_progress(UploadProgress {
943                file_name: file_name.clone(),
944                bytes_sent,
945                total_bytes: total_size,
946            });
947            chunk_index = chunk_index.saturating_add(1);
948            buffer = Vec::with_capacity(plan.chunk_size as usize);
949        }
950
951        self.finish_chunked_upload(upload_url, uuid, file_name, mime, album_id)
952            .await
953    }
954
955    async fn finish_chunked_upload(
956        &self,
957        upload_url: Url,
958        uuid: String,
959        file_name: String,
960        mime: String,
961        album_id: Option<u64>,
962    ) -> Result<UploadedFile, CyberdropError> {
963        let payload = build_finish_chunks_payload(uuid, file_name, mime, album_id);
964        let finish_url = finish_chunks_url(upload_url);
965
966        let response: UploadResponse = self
967            .transport
968            .post_json_with_upload_headers_url(finish_url, &payload)
969            .await?;
970
971        UploadedFile::try_from(response)
972    }
973}
974
975impl CyberdropClientBuilder {
976    /// Create a new builder using the crate defaults.
977    ///
978    /// This is equivalent to [`CyberdropClient::builder`].
979    pub fn new() -> Self {
980        Self {
981            base_url: None,
982            user_agent: None,
983            timeout: DEFAULT_TIMEOUT,
984            auth_token: None,
985            builder: Client::builder(),
986        }
987    }
988
989    /// Override the base URL used for requests.
990    pub fn base_url(mut self, base_url: impl AsRef<str>) -> Result<Self, CyberdropError> {
991        self.base_url = Some(Url::parse(base_url.as_ref())?);
992        Ok(self)
993    }
994
995    /// Set a custom user agent header.
996    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
997        self.user_agent = Some(user_agent.into());
998        self
999    }
1000
1001    /// Provide an auth token that will be sent as bearer auth.
1002    pub fn auth_token(mut self, token: impl Into<String>) -> Self {
1003        self.auth_token = Some(AuthToken::new(token));
1004        self
1005    }
1006
1007    /// Configure the request timeout.
1008    ///
1009    /// This sets [`reqwest::ClientBuilder::timeout`], which applies a single deadline per request.
1010    /// Timeout failures surface as [`CyberdropError::Http`].
1011    pub fn timeout(mut self, timeout: Duration) -> Self {
1012        self.timeout = timeout;
1013        self
1014    }
1015
1016    /// Build a [`CyberdropClient`].
1017    ///
1018    /// If no base URL is configured, this uses `https://cyberdrop.cr/`.
1019    /// If no user agent is configured, a browser-like UA string is used.
1020    pub fn build(self) -> Result<CyberdropClient, CyberdropError> {
1021        let base_url = match self.base_url {
1022            Some(url) => url,
1023            None => Url::parse(DEFAULT_BASE_URL)?,
1024        };
1025
1026        let mut builder = self.builder.timeout(self.timeout);
1027        builder = builder.user_agent(self.user_agent.unwrap_or_else(default_user_agent));
1028
1029        let client = builder.build()?;
1030
1031        Ok(CyberdropClient {
1032            transport: Transport::new(client, base_url, self.auth_token),
1033        })
1034    }
1035}
1036
1037impl Default for CyberdropClientBuilder {
1038    fn default() -> Self {
1039        Self::new()
1040    }
1041}
1042
1043fn default_user_agent() -> String {
1044    // Match a browser UA; the service appears to expect browser-like clients.
1045    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0".into()
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050    use super::*;
1051
1052    #[test]
1053    fn select_upload_strategy_uses_chunk_threshold() {
1054        assert_eq!(select_upload_strategy(0), UploadStrategy::Single);
1055        assert_eq!(select_upload_strategy(CHUNK_SIZE), UploadStrategy::Single);
1056        assert_eq!(
1057            select_upload_strategy(CHUNK_SIZE + 1),
1058            UploadStrategy::Chunked
1059        );
1060    }
1061
1062    #[test]
1063    fn chunk_upload_plan_calculates_chunk_boundaries() {
1064        let one_chunk = ChunkUploadPlan::new(CHUNK_SIZE);
1065        assert_eq!(one_chunk.chunk_size, CHUNK_SIZE);
1066        assert_eq!(one_chunk.total_chunks, 1);
1067        assert_eq!(one_chunk.byte_offset(0), 0);
1068
1069        let partial_second_chunk = ChunkUploadPlan::new(CHUNK_SIZE + 1);
1070        assert_eq!(partial_second_chunk.chunk_size, CHUNK_SIZE);
1071        assert_eq!(partial_second_chunk.total_chunks, 2);
1072        assert_eq!(partial_second_chunk.byte_offset(1), CHUNK_SIZE);
1073
1074        let empty_file_plan = ChunkUploadPlan::new(0);
1075        assert_eq!(empty_file_plan.chunk_size, 1);
1076        assert_eq!(empty_file_plan.total_chunks, 1);
1077    }
1078
1079    #[test]
1080    fn build_chunk_fields_maps_plan_and_metadata() {
1081        let plan = ChunkUploadPlan::new(CHUNK_SIZE + 1);
1082        let fields = build_chunk_fields(
1083            "upload-id",
1084            1,
1085            plan,
1086            CHUNK_SIZE + 1,
1087            "image.jpg",
1088            "image/jpeg",
1089            Some(42),
1090        );
1091
1092        assert_eq!(fields.uuid, "upload-id");
1093        assert_eq!(fields.chunk_index, 1);
1094        assert_eq!(fields.total_size, CHUNK_SIZE + 1);
1095        assert_eq!(fields.chunk_size, CHUNK_SIZE);
1096        assert_eq!(fields.total_chunks, 2);
1097        assert_eq!(fields.byte_offset, CHUNK_SIZE);
1098        assert_eq!(fields.file_name, "image.jpg");
1099        assert_eq!(fields.mime_type, "image/jpeg");
1100        assert_eq!(fields.album_id, Some(42));
1101    }
1102
1103    #[test]
1104    fn build_finish_chunks_payload_preserves_file_metadata() {
1105        let payload = build_finish_chunks_payload(
1106            "upload-id".to_string(),
1107            "image.jpg".to_string(),
1108            "image/jpeg".to_string(),
1109            Some(42),
1110        );
1111
1112        assert_eq!(payload.files.len(), 1);
1113        let file = &payload.files[0];
1114        assert_eq!(file.uuid, "upload-id");
1115        assert_eq!(file.original, "image.jpg");
1116        assert_eq!(file.r#type, "image/jpeg");
1117        assert_eq!(file.albumid, Some(42));
1118        assert_eq!(file.filelength, None);
1119        assert_eq!(file.age, None);
1120    }
1121
1122    #[test]
1123    fn finish_chunks_url_replaces_upload_path() {
1124        let url = Url::parse("https://node.example/upload?token=abc").unwrap();
1125        let finish_url = finish_chunks_url(url);
1126
1127        assert_eq!(
1128            finish_url.as_str(),
1129            "https://node.example/api/upload/finishchunks?token=abc"
1130        );
1131    }
1132
1133    #[tokio::test]
1134    async fn prepare_upload_file_rejects_missing_file_name_before_opening() {
1135        match prepare_upload_file(Path::new("")).await {
1136            Err(CyberdropError::InvalidFileName) => {}
1137            Err(err) => panic!("expected invalid file name, got {err}"),
1138            Ok(_) => panic!("expected invalid file name, got prepared upload"),
1139        }
1140    }
1141}