Skip to main content

cyberdrop_client/
models.rs

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