Skip to main content

cyberdrop_client/
models.rs

1use std::collections::HashMap;
2
3use reqwest::Url;
4use serde::{Deserialize, Serialize};
5use serde::de::{self, Visitor};
6use std::fmt;
7
8use crate::CyberdropError;
9
10/// Authentication token returned by [`crate::CyberdropClient::login`] and
11/// [`crate::CyberdropClient::register`].
12///
13/// This type is `#[serde(transparent)]` and typically deserializes from a JSON string.
14#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
15#[serde(transparent)]
16pub struct AuthToken {
17    pub(crate) token: String,
18}
19
20impl AuthToken {
21    /// Construct a new token wrapper.
22    pub fn new(token: impl Into<String>) -> Self {
23        Self {
24            token: token.into(),
25        }
26    }
27
28    /// Borrow the underlying token string.
29    pub fn as_str(&self) -> &str {
30        &self.token
31    }
32
33    /// Consume this value and return the underlying token string.
34    pub fn into_string(self) -> String {
35        self.token
36    }
37}
38
39/// Permission flags associated with a user/token verification response.
40#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
41pub struct Permissions {
42    /// Whether the account has "user" privileges.
43    pub user: bool,
44    /// Whether the account has "poweruser" privileges.
45    pub poweruser: bool,
46    /// Whether the account has "moderator" privileges.
47    pub moderator: bool,
48    /// Whether the account has "admin" privileges.
49    pub admin: bool,
50    /// Whether the account has "superadmin" privileges.
51    pub superadmin: bool,
52}
53
54/// Result of verifying a token via [`crate::CyberdropClient::verify_token`].
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct TokenVerification {
57    /// Whether the token verification succeeded.
58    pub success: bool,
59    /// Username associated with the token.
60    pub username: String,
61    /// Permission flags associated with the token.
62    pub permissions: Permissions,
63}
64
65/// Album metadata as returned by the Cyberdrop API.
66///
67/// Field semantics (timestamps/flags) are intentionally documented minimally: values are exposed
68/// as returned by the service without additional interpretation.
69#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct Album {
72    /// Album numeric ID.
73    pub id: u64,
74    /// Display name.
75    pub name: String,
76    /// Service-provided timestamp value.
77    #[serde(default)]
78    pub timestamp: u64,
79    /// Service-provided identifier string.
80    pub identifier: String,
81    /// Service-provided "edited at" timestamp value.
82    #[serde(default)]
83    pub edited_at: u64,
84    /// Service-provided download flag.
85    #[serde(default)]
86    pub download: bool,
87    /// Service-provided public flag.
88    #[serde(default)]
89    pub public: bool,
90    /// Album description (may be empty).
91    #[serde(default)]
92    pub description: String,
93    /// Number of files in the album.
94    #[serde(default)]
95    pub files: u64,
96}
97
98/// Album listing for the authenticated user.
99///
100/// Values produced by this crate always have `success == true`; failures are returned as
101/// [`CyberdropError`].
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct AlbumsList {
104    /// Whether the API request was successful.
105    pub success: bool,
106    /// Albums returned by the service.
107    pub albums: Vec<Album>,
108    /// Optional home domain returned by the service, parsed as a URL.
109    pub home_domain: Option<Url>,
110}
111
112/// File metadata as returned by the album listing endpoint.
113#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
114pub struct AlbumFile {
115    pub id: u64,
116    pub name: String,
117    #[serde(rename = "userid", deserialize_with = "de_string_or_number")]
118    pub user_id: String,
119    #[serde(deserialize_with = "de_u64_or_string")]
120    pub size: u64,
121    pub timestamp: u64,
122    #[serde(rename = "last_visited_at")]
123    pub last_visited_at: Option<String>,
124    pub slug: String,
125    /// Base domain for file media (for example, `https://sun-i.cyberdrop.cr`).
126    pub image: String,
127    /// Nullable expiry date as returned by the service.
128    pub expirydate: Option<String>,
129    #[serde(rename = "albumid", deserialize_with = "de_string_or_number")]
130    pub album_id: String,
131    pub extname: String,
132    /// Thumbnail path relative to `image` (for example, `thumbs/<...>.png`).
133    pub thumb: String,
134}
135
136fn de_string_or_number<'de, D>(deserializer: D) -> Result<String, D::Error>
137where
138    D: serde::Deserializer<'de>,
139{
140    struct StringOrNumber;
141
142    impl<'de> Visitor<'de> for StringOrNumber {
143        type Value = String;
144
145        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
146            formatter.write_str("a string or number")
147        }
148
149        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
150        where
151            E: de::Error,
152        {
153            Ok(v.to_string())
154        }
155
156        fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
157        where
158            E: de::Error,
159        {
160            Ok(v)
161        }
162
163        fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
164        where
165            E: de::Error,
166        {
167            Ok(v.to_string())
168        }
169
170        fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
171        where
172            E: de::Error,
173        {
174            Ok(v.to_string())
175        }
176    }
177
178    deserializer.deserialize_any(StringOrNumber)
179}
180
181fn de_u64_or_string<'de, D>(deserializer: D) -> Result<u64, D::Error>
182where
183    D: serde::Deserializer<'de>,
184{
185    struct U64OrString;
186
187    impl<'de> Visitor<'de> for U64OrString {
188        type Value = u64;
189
190        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
191            formatter.write_str("a u64 or numeric string")
192        }
193
194        fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
195        where
196            E: de::Error,
197        {
198            Ok(v)
199        }
200
201        fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
202        where
203            E: de::Error,
204        {
205            if v < 0 {
206                return Err(E::custom("negative value not allowed"));
207            }
208            Ok(v as u64)
209        }
210
211        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
212        where
213            E: de::Error,
214        {
215            v.parse::<u64>().map_err(E::custom)
216        }
217
218        fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
219        where
220            E: de::Error,
221        {
222            v.parse::<u64>().map_err(E::custom)
223        }
224    }
225
226    deserializer.deserialize_any(U64OrString)
227}
228
229/// Page of files returned by the album listing endpoint.
230///
231/// This type represents a single response page; the API currently returns at most 25 files per
232/// request and provides a total `count` for pagination.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct AlbumFilesPage {
235    /// Whether the API request was successful.
236    pub success: bool,
237    /// Files returned for the requested page.
238    pub files: Vec<AlbumFile>,
239    /// Total number of files in the album (across all pages).
240    pub count: u64,
241    /// Album mapping returned by the service (keyed by album id as a string).
242    pub albums: HashMap<String, String>,
243    /// Base domain returned by the service (parsed as a URL).
244    ///
245    /// Note: the API omits this field for empty albums, so it can be `None`.
246    pub base_domain: Option<Url>,
247}
248
249#[derive(Debug, Serialize)]
250#[serde(rename_all = "camelCase")]
251pub struct CreateAlbumRequest {
252    pub name: String,
253    pub description: Option<String>,
254}
255
256#[derive(Debug, Deserialize)]
257pub struct CreateAlbumResponse {
258    pub success: Option<bool>,
259    pub id: Option<u64>,
260    pub message: Option<String>,
261    pub description: Option<String>,
262}
263
264#[derive(Debug, Deserialize)]
265pub struct UploadResponse {
266    pub success: Option<bool>,
267    pub description: Option<String>,
268    pub files: Option<Vec<UploadedFile>>,
269}
270
271#[derive(Debug, Serialize)]
272#[serde(rename_all = "camelCase")]
273pub(crate) struct EditAlbumRequest {
274    pub(crate) id: u64,
275    pub(crate) name: String,
276    pub(crate) description: String,
277    pub(crate) download: bool,
278    pub(crate) public: bool,
279    #[serde(rename = "requestLink")]
280    pub(crate) request_link: bool,
281}
282
283#[derive(Debug, Deserialize)]
284pub(crate) struct EditAlbumResponse {
285    pub(crate) success: Option<bool>,
286    pub(crate) name: Option<String>,
287    pub(crate) identifier: Option<String>,
288    pub(crate) message: Option<String>,
289    pub(crate) description: Option<String>,
290}
291
292/// Result of editing an album via [`crate::CyberdropClient::edit_album`].
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct EditAlbumResult {
295    /// Updated name if the API returned it.
296    pub name: Option<String>,
297    /// New identifier if `request_new_link` was set and the API returned it.
298    pub identifier: Option<String>,
299}
300
301/// Uploaded file metadata returned by [`crate::CyberdropClient::upload_file`].
302#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
303pub struct UploadedFile {
304    /// Name of the uploaded file.
305    pub name: String,
306    /// URL of the uploaded file (stringified URL).
307    pub url: String,
308}
309
310/// Upload progress information emitted during file uploads.
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub struct UploadProgress {
313    pub file_name: String,
314    pub bytes_sent: u64,
315    pub total_bytes: u64,
316}
317
318#[derive(Debug, Serialize)]
319pub(crate) struct LoginRequest {
320    pub(crate) username: String,
321    pub(crate) password: String,
322}
323
324#[derive(Debug, Deserialize)]
325pub(crate) struct LoginResponse {
326    pub(crate) token: Option<AuthToken>,
327}
328
329#[derive(Debug, Serialize)]
330pub(crate) struct RegisterRequest {
331    pub(crate) username: String,
332    pub(crate) password: String,
333}
334
335#[derive(Debug, Deserialize)]
336pub(crate) struct RegisterResponse {
337    pub(crate) success: Option<bool>,
338    pub(crate) token: Option<AuthToken>,
339    pub(crate) message: Option<String>,
340    pub(crate) description: Option<String>,
341}
342
343#[derive(Debug, Deserialize)]
344pub(crate) struct NodeResponse {
345    pub(crate) success: Option<bool>,
346    pub(crate) url: Option<String>,
347    pub(crate) message: Option<String>,
348    pub(crate) description: Option<String>,
349}
350
351#[derive(Debug, Serialize)]
352pub(crate) struct VerifyTokenRequest {
353    pub(crate) token: String,
354}
355
356#[derive(Debug, Deserialize)]
357pub(crate) struct VerifyTokenResponse {
358    pub(crate) success: Option<bool>,
359    pub(crate) username: Option<String>,
360    pub(crate) permissions: Option<Permissions>,
361}
362
363#[derive(Debug, Deserialize)]
364#[serde(rename_all = "camelCase")]
365pub(crate) struct AlbumsResponse {
366    pub(crate) success: Option<bool>,
367    pub(crate) albums: Option<Vec<Album>>,
368    pub(crate) home_domain: Option<String>,
369}
370
371#[derive(Debug, Deserialize)]
372pub(crate) struct AlbumFilesResponse {
373    pub(crate) success: Option<bool>,
374    pub(crate) files: Option<Vec<AlbumFile>>,
375    pub(crate) count: Option<u64>,
376    pub(crate) albums: Option<HashMap<String, String>>,
377    pub(crate) basedomain: Option<String>,
378    pub(crate) message: Option<String>,
379    pub(crate) description: Option<String>,
380}
381
382impl TryFrom<LoginResponse> for AuthToken {
383    type Error = CyberdropError;
384
385    fn try_from(response: LoginResponse) -> Result<Self, Self::Error> {
386        response.token.ok_or(CyberdropError::MissingToken)
387    }
388}
389
390impl TryFrom<RegisterResponse> for AuthToken {
391    type Error = CyberdropError;
392
393    fn try_from(body: RegisterResponse) -> Result<Self, Self::Error> {
394        if body.success.unwrap_or(false) {
395            return body.token.ok_or(CyberdropError::MissingToken);
396        }
397
398        let msg = body
399            .description
400            .or(body.message)
401            .unwrap_or_else(|| "registration failed".to_string());
402
403        Err(CyberdropError::Api(msg))
404    }
405}
406
407impl TryFrom<VerifyTokenResponse> for TokenVerification {
408    type Error = CyberdropError;
409
410    fn try_from(body: VerifyTokenResponse) -> Result<Self, Self::Error> {
411        let success = body.success.ok_or(CyberdropError::MissingField(
412            "verification response missing success",
413        ))?;
414        let username = body.username.ok_or(CyberdropError::MissingField(
415            "verification response missing username",
416        ))?;
417        let permissions = body.permissions.ok_or(CyberdropError::MissingField(
418            "verification response missing permissions",
419        ))?;
420
421        Ok(TokenVerification {
422            success,
423            username,
424            permissions,
425        })
426    }
427}
428
429impl TryFrom<AlbumsResponse> for AlbumsList {
430    type Error = CyberdropError;
431
432    fn try_from(body: AlbumsResponse) -> Result<Self, Self::Error> {
433        if !body.success.unwrap_or(false) {
434            return Err(CyberdropError::Api("failed to fetch albums".into()));
435        }
436
437        let albums = body.albums.ok_or(CyberdropError::MissingField(
438            "albums response missing albums",
439        ))?;
440
441        let home_domain = match body.home_domain {
442            Some(url) => Some(Url::parse(&url)?),
443            None => None,
444        };
445
446        Ok(AlbumsList {
447            success: true,
448            albums,
449            home_domain,
450        })
451    }
452}
453
454impl TryFrom<AlbumFilesResponse> for AlbumFilesPage {
455    type Error = CyberdropError;
456
457    fn try_from(body: AlbumFilesResponse) -> Result<Self, Self::Error> {
458        if !body.success.unwrap_or(false) {
459            let msg = body
460                .description
461                .or(body.message)
462                .unwrap_or_else(|| "failed to fetch album files".to_string());
463            return Err(CyberdropError::Api(msg));
464        }
465
466        let files = body.files.ok_or(CyberdropError::MissingField(
467            "album files response missing files",
468        ))?;
469
470        let count = body.count.ok_or(CyberdropError::MissingField(
471            "album files response missing count",
472        ))?;
473
474        let base_domain = if files.is_empty() {
475            match body.basedomain {
476                Some(url) => Some(Url::parse(&url)?),
477                None => None,
478            }
479        } else {
480            let url = body.basedomain.ok_or(CyberdropError::MissingField(
481                "album files response missing basedomain",
482            ))?;
483            Some(Url::parse(&url)?)
484        };
485
486        Ok(AlbumFilesPage {
487            success: true,
488            files,
489            count,
490            albums: body.albums.unwrap_or_default(),
491            base_domain,
492        })
493    }
494}
495
496impl TryFrom<CreateAlbumResponse> for u64 {
497    type Error = CyberdropError;
498
499    fn try_from(body: CreateAlbumResponse) -> Result<Self, Self::Error> {
500        if body.success.unwrap_or(false) {
501            return body.id.ok_or(CyberdropError::MissingField(
502                "create album response missing id",
503            ));
504        }
505
506        let msg = body
507            .description
508            .or(body.message)
509            .unwrap_or_else(|| "create album failed".to_string());
510
511        if msg.to_lowercase().contains("already an album") {
512            Err(CyberdropError::AlbumAlreadyExists(msg))
513        } else {
514            Err(CyberdropError::Api(msg))
515        }
516    }
517}
518
519impl TryFrom<UploadResponse> for UploadedFile {
520    type Error = CyberdropError;
521
522    fn try_from(body: UploadResponse) -> Result<Self, Self::Error> {
523        if body.success.unwrap_or(false) {
524            let first = body.files.and_then(|mut files| files.pop()).ok_or(
525                CyberdropError::MissingField("upload response missing files"),
526            )?;
527            let url = Url::parse(&first.url)?;
528            Ok(UploadedFile {
529                name: first.name,
530                url: url.to_string(),
531            })
532        } else {
533            let msg = body
534                .description
535                .unwrap_or_else(|| "upload failed".to_string());
536            Err(CyberdropError::Api(msg))
537        }
538    }
539}
540
541impl TryFrom<EditAlbumResponse> for EditAlbumResult {
542    type Error = CyberdropError;
543
544    fn try_from(body: EditAlbumResponse) -> Result<Self, Self::Error> {
545        if !body.success.unwrap_or(false) {
546            let msg = body
547                .description
548                .or(body.message)
549                .unwrap_or_else(|| "edit album failed".to_string());
550            return Err(CyberdropError::Api(msg));
551        }
552
553        if body.name.is_none() && body.identifier.is_none() {
554            return Err(CyberdropError::MissingField(
555                "edit album response missing name/identifier",
556            ));
557        }
558
559        Ok(EditAlbumResult {
560            name: body.name,
561            identifier: body.identifier,
562        })
563    }
564}