Skip to main content

cyberdrop_client/
client.rs

1use reqwest::{
2    Client, Method, RequestBuilder, StatusCode, Url,
3    header::{ACCEPT, ACCEPT_LANGUAGE},
4    multipart::Form,
5};
6use serde::de::DeserializeOwned;
7
8use crate::CyberdropError;
9use crate::config::{DEFAULT_BASE_URL, DEFAULT_TIMEOUT};
10use crate::token::AuthToken;
11
12/// Async HTTP client for a subset of Cyberdrop endpoints.
13#[derive(Debug, Clone)]
14pub struct CyberdropClient {
15    pub(crate) client: Client,
16    pub(crate) base_url: Url,
17    pub(crate) auth_token: Option<AuthToken>,
18}
19
20impl CyberdropClient {
21    /// Build a client with the crate defaults.
22    pub fn new() -> Result<Self, CyberdropError> {
23        let client = Client::builder()
24            .timeout(DEFAULT_TIMEOUT)
25            .user_agent(default_user_agent())
26            .build()?;
27
28        Ok(Self {
29            client,
30            base_url: Url::parse(DEFAULT_BASE_URL)?,
31            auth_token: None,
32        })
33    }
34
35    /// Build a client with the crate defaults and an auth token.
36    pub fn new_with_token(token: impl Into<String>) -> Result<Self, CyberdropError> {
37        Ok(Self::new()?.with_auth_token(token))
38    }
39
40    /// Current auth token if configured.
41    pub fn auth_token(&self) -> Option<&str> {
42        self.auth_token.as_ref().map(AuthToken::as_str)
43    }
44
45    /// Return a clone of this client that applies authentication to requests.
46    ///
47    /// The token is attached as an HTTP header named `token`.
48    pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
49        self.auth_token = Some(AuthToken::new(token));
50        self
51    }
52
53    pub(crate) async fn get_json<T>(
54        &self,
55        path: &str,
56        requires_auth: bool,
57    ) -> Result<T, CyberdropError>
58    where
59        T: DeserializeOwned,
60    {
61        let builder = self.build_request(Method::GET, path, requires_auth)?;
62        self.send_json(builder).await
63    }
64
65    pub(crate) async fn get_json_with_header<T>(
66        &self,
67        path: &str,
68        requires_auth: bool,
69        header_name: &'static str,
70        header_value: &'static str,
71    ) -> Result<T, CyberdropError>
72    where
73        T: DeserializeOwned,
74    {
75        let builder = self
76            .build_request(Method::GET, path, requires_auth)?
77            .header(header_name, header_value);
78        self.send_json(builder).await
79    }
80
81    pub(crate) async fn post_json<B, T>(
82        &self,
83        path: &str,
84        body: &B,
85        requires_auth: bool,
86    ) -> Result<T, CyberdropError>
87    where
88        B: serde::Serialize + ?Sized,
89        T: DeserializeOwned,
90    {
91        let builder = self
92            .build_request(Method::POST, path, requires_auth)?
93            .json(body);
94        self.send_json(builder).await
95    }
96
97    pub(crate) async fn post_upload_json_url<B, T>(
98        &self,
99        url: Url,
100        body: &B,
101    ) -> Result<T, CyberdropError>
102    where
103        B: serde::Serialize + ?Sized,
104        T: DeserializeOwned,
105    {
106        let builder = self
107            .upload_headers(self.build_request_url(Method::POST, url, true)?)
108            .json(body);
109
110        self.send_json(builder).await
111    }
112
113    pub(crate) async fn post_upload_multipart_url<T>(
114        &self,
115        url: Url,
116        form: Form,
117        album_id: Option<u64>,
118    ) -> Result<T, CyberdropError>
119    where
120        T: DeserializeOwned,
121    {
122        let mut builder = self.upload_headers(self.build_request_url(Method::POST, url, true)?);
123        if let Some(id) = album_id {
124            builder = builder.header("albumid", id);
125        }
126
127        self.send_json(builder.multipart(form)).await
128    }
129
130    async fn send_json<T>(&self, builder: RequestBuilder) -> Result<T, CyberdropError>
131    where
132        T: DeserializeOwned,
133    {
134        let response = builder.send().await?;
135        Self::map_status(response.status())?;
136        Ok(response.json().await?)
137    }
138
139    fn build_request(
140        &self,
141        method: Method,
142        path: &str,
143        requires_auth: bool,
144    ) -> Result<RequestBuilder, CyberdropError> {
145        let url = self.base_url.join(path)?;
146        self.build_request_url(method, url, requires_auth)
147    }
148
149    fn build_request_url(
150        &self,
151        method: Method,
152        url: Url,
153        requires_auth: bool,
154    ) -> Result<RequestBuilder, CyberdropError> {
155        let builder = self
156            .client
157            .request(method, url)
158            .header(ACCEPT, "application/json, text/plain, */*")
159            .header(ACCEPT_LANGUAGE, "nl,en-US;q=0.9,en;q=0.8");
160
161        if requires_auth {
162            self.apply_auth(builder)
163        } else {
164            Ok(builder)
165        }
166    }
167
168    fn map_status(status: StatusCode) -> Result<(), CyberdropError> {
169        if status.is_success() {
170            return Ok(());
171        }
172
173        if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
174            Err(CyberdropError::AuthenticationFailed(status))
175        } else {
176            Err(CyberdropError::RequestFailed(status))
177        }
178    }
179
180    fn apply_auth(&self, builder: RequestBuilder) -> Result<RequestBuilder, CyberdropError> {
181        let token = self
182            .auth_token
183            .as_ref()
184            .ok_or(CyberdropError::MissingAuthToken)?;
185
186        Ok(builder.header("token", token.as_str()))
187    }
188
189    fn upload_headers(&self, builder: RequestBuilder) -> RequestBuilder {
190        let origin = self.base_url.origin().ascii_serialization();
191        let referer = self.base_url.as_str();
192
193        builder
194            .header("X-Requested-With", "XMLHttpRequest")
195            .header("striptags", "undefined")
196            .header("Origin", origin)
197            .header("Referer", referer)
198            .header("Cache-Control", "no-cache")
199            .header("Pragma", "no-cache")
200    }
201}
202
203fn default_user_agent() -> String {
204    // Match a browser UA; the service appears to expect browser-like clients.
205    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0".into()
206}