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