Skip to main content

wekan_cli/client/
attachments.rs

1use std::time::Duration;
2
3use base64::{Engine as _, engine::general_purpose::STANDARD};
4use reqwest::{Method, header::ACCEPT};
5use secrecy::{ExposeSecret, SecretString};
6use serde::{Deserialize, Serialize, de::DeserializeOwned};
7
8use super::{
9    ClientError, WekanClient,
10    decoding::{decode_value, parse_json},
11    transport::embedded_error,
12};
13
14pub const MAX_FILE_BYTES: usize = 64 * 1024 * 1024;
15const DOWNLOAD_RESPONSE_BYTES: usize = 96 * 1024 * 1024;
16const POST_TIMEOUT: Duration = Duration::from_secs(60);
17
18#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
19#[serde(
20    deny_unknown_fields,
21    rename_all(deserialize = "camelCase", serialize = "snake_case")
22)]
23pub struct AttachmentMetadata {
24    pub attachment_id: String,
25    pub file_name: String,
26    pub file_size: u64,
27    pub file_type: String,
28    pub storage_backend: String,
29    pub board_id: String,
30    #[serde(default, deserialize_with = "optional")]
31    pub swimlane_id: Option<String>,
32    #[serde(default, deserialize_with = "optional")]
33    pub list_id: Option<String>,
34    #[serde(default, deserialize_with = "optional")]
35    pub card_id: Option<String>,
36    #[serde(default, deserialize_with = "optional_date")]
37    pub created_at: Option<String>,
38    #[serde(default, deserialize_with = "optional")]
39    pub is_image: Option<bool>,
40}
41
42// Optional contract properties may be absent, but explicit null is not a mapped type.
43fn optional<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(
44    d: D,
45) -> Result<Option<T>, D::Error> {
46    T::deserialize(d).map(Some)
47}
48
49fn optional_date<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
50    let value = String::deserialize(d)?;
51    super::datetime::validate_response_date(&value)
52        .map_err(|()| serde::de::Error::custom("invalid attachment date-time"))?;
53    Ok(Some(value))
54}
55
56#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
57#[serde(
58    deny_unknown_fields,
59    rename_all(deserialize = "camelCase", serialize = "snake_case")
60)]
61pub struct AttachmentVersion {
62    pub version_name: String,
63    #[serde(default, deserialize_with = "optional")]
64    pub storage: Option<String>,
65    #[serde(default, deserialize_with = "optional")]
66    pub size: Option<u64>,
67    #[serde(default, deserialize_with = "optional")]
68    pub r#type: Option<String>,
69}
70
71#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
72#[serde(
73    deny_unknown_fields,
74    rename_all(deserialize = "camelCase", serialize = "snake_case")
75)]
76pub struct AttachmentInfo {
77    #[serde(skip_serializing)]
78    pub success: bool,
79    pub attachment_id: String,
80    pub file_name: String,
81    pub file_size: u64,
82    pub file_type: String,
83    pub storage_backend: String,
84    #[serde(default, deserialize_with = "optional")]
85    pub board_id: Option<String>,
86    #[serde(default, deserialize_with = "optional")]
87    pub swimlane_id: Option<String>,
88    #[serde(default, deserialize_with = "optional")]
89    pub list_id: Option<String>,
90    #[serde(default, deserialize_with = "optional")]
91    pub card_id: Option<String>,
92    pub versions: Vec<AttachmentVersion>,
93    #[serde(default, deserialize_with = "optional_date")]
94    pub created_at: Option<String>,
95    #[serde(default, deserialize_with = "optional")]
96    pub is_image: Option<bool>,
97}
98
99#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
100#[serde(deny_unknown_fields, rename_all = "camelCase")]
101pub struct AttachmentListResponse {
102    pub success: bool,
103    pub attachments: Vec<AttachmentMetadata>,
104    pub count: u32,
105}
106
107#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
108#[serde(
109    deny_unknown_fields,
110    rename_all(deserialize = "camelCase", serialize = "snake_case")
111)]
112pub struct AttachmentDownloadResponse {
113    #[serde(skip_serializing)]
114    pub success: bool,
115    pub attachment_id: String,
116    pub file_name: String,
117    pub file_size: u64,
118    pub file_type: String,
119    pub storage_backend: String,
120    #[serde(skip_serializing)]
121    pub base64_data: String,
122}
123
124#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
125#[serde(
126    deny_unknown_fields,
127    rename_all(deserialize = "camelCase", serialize = "snake_case")
128)]
129pub struct BackgroundDownloadResponse {
130    #[serde(skip_serializing)]
131    pub success: bool,
132    pub attachment_id: String,
133    pub file_name: String,
134    pub file_size: u64,
135    pub file_type: String,
136    pub storage_backend: String,
137    #[serde(skip_serializing)]
138    pub base64_data: String,
139    #[serde(rename(deserialize = "backgroundImageURL", serialize = "background_image_url"))]
140    pub background_image_url: String,
141}
142
143#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
144#[serde(rename_all = "camelCase")]
145pub struct UploadAttachmentRequest {
146    pub board_id: String,
147    pub swimlane_id: String,
148    pub list_id: String,
149    pub card_id: String,
150    pub file_name: String,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub file_type: Option<String>,
153    /// Raw file bytes; serialized as Wekan's base64 fileData string.
154    #[serde(serialize_with = "serialize_file_data")]
155    pub file_data: Vec<u8>,
156}
157
158#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
159#[serde(rename_all = "camelCase")]
160pub struct UploadBackgroundRequest {
161    pub board_id: String,
162    pub file_name: String,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub file_type: Option<String>,
165    /// Raw file bytes; serialized as Wekan's base64 fileData string.
166    #[serde(serialize_with = "serialize_file_data")]
167    pub file_data: Vec<u8>,
168}
169
170fn serialize_file_data<S: serde::Serializer>(
171    bytes: &[u8],
172    serializer: S,
173) -> Result<S::Ok, S::Error> {
174    serializer.serialize_str(&STANDARD.encode(bytes))
175}
176
177#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
178#[serde(rename_all = "camelCase")]
179pub struct TransferAttachmentRequest {
180    pub attachment_id: String,
181    pub target_board_id: String,
182    pub target_swimlane_id: String,
183    pub target_list_id: String,
184    pub target_card_id: String,
185}
186
187#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
188#[serde(
189    deny_unknown_fields,
190    rename_all(deserialize = "camelCase", serialize = "snake_case")
191)]
192pub struct AttachmentUploadResponse {
193    #[serde(skip_serializing)]
194    pub success: bool,
195    pub attachment_id: String,
196    pub file_name: String,
197    pub file_size: u64,
198    pub storage_backend: String,
199    pub message: String,
200}
201
202#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
203#[serde(
204    deny_unknown_fields,
205    rename_all(deserialize = "camelCase", serialize = "snake_case")
206)]
207pub struct BackgroundUploadResponse {
208    #[serde(skip_serializing)]
209    pub success: bool,
210    pub attachment_id: String,
211    pub file_name: String,
212    pub file_size: u64,
213    pub storage_backend: String,
214    pub message: String,
215    #[serde(rename(deserialize = "backgroundImageURL", serialize = "background_image_url"))]
216    pub background_image_url: String,
217}
218
219#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
220#[serde(
221    deny_unknown_fields,
222    rename_all(deserialize = "camelCase", serialize = "snake_case")
223)]
224pub struct AttachmentCopyResponse {
225    #[serde(skip_serializing)]
226    pub success: bool,
227    pub source_attachment_id: String,
228    pub new_attachment_id: String,
229    pub file_name: String,
230    pub file_size: u64,
231    pub message: String,
232}
233
234#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
235#[serde(
236    deny_unknown_fields,
237    rename_all(deserialize = "camelCase", serialize = "snake_case")
238)]
239pub struct AttachmentMoveResponse {
240    #[serde(skip_serializing)]
241    pub success: bool,
242    pub attachment_id: String,
243    pub file_name: String,
244    pub file_size: u64,
245    pub source_board_id: String,
246    pub target_board_id: String,
247    pub message: String,
248}
249
250#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
251#[serde(
252    deny_unknown_fields,
253    rename_all(deserialize = "camelCase", serialize = "snake_case")
254)]
255pub struct AttachmentDeleteResponse {
256    #[serde(skip_serializing)]
257    pub success: bool,
258    pub attachment_id: String,
259    pub message: String,
260    pub file_name: String,
261}
262
263pub(crate) fn protocol(message: &str) -> ClientError {
264    ClientError::Protocol {
265        message: message.to_owned(),
266        diagnostic: None,
267        success_status_received: true,
268    }
269}
270
271pub fn decode_file(data: &str) -> Result<Vec<u8>, ClientError> {
272    if data.len() > MAX_FILE_BYTES.div_ceil(3) * 4 {
273        return Err(protocol(
274            "the attachment contents exceeded the pinned 64 MiB file limit",
275        ));
276    }
277    let bytes = STANDARD
278        .decode(data)
279        .map_err(|_| protocol("the attachment contents were not valid base64"))?;
280    if bytes.len() > MAX_FILE_BYTES {
281        return Err(protocol(
282            "the attachment contents exceeded the pinned 64 MiB file limit",
283        ));
284    }
285    Ok(bytes)
286}
287
288fn require_success(success: bool) -> Result<(), ClientError> {
289    if success {
290        Ok(())
291    } else {
292        Err(protocol(
293            "Wekan returned an unsuccessful attachment envelope without an error",
294        ))
295    }
296}
297
298fn decode_attachment<T: DeserializeOwned>(body: &[u8]) -> Result<T, ClientError> {
299    let value = parse_json(body, "attachment")?;
300    // Attachment successes contain a human message, also recognized by the
301    // generic Meteor error decoder. Discriminate using the explicit success flag.
302    if value.get("success") != Some(&serde_json::Value::Bool(true)) {
303        if let Some(error) = embedded_error(&value, reqwest::StatusCode::OK)? {
304            return Err(error);
305        }
306    }
307    decode_value(value, "attachment", true)
308}
309
310impl WekanClient {
311    fn attachment_endpoint(&self, segments: &[&str]) -> Result<reqwest::Url, ClientError> {
312        let mut url = self
313            .server()
314            .join("api/attachment")
315            .map_err(|_| protocol("could not build the attachment endpoint"))?;
316        url.path_segments_mut()
317            .map_err(|_| protocol("could not build the attachment endpoint"))?
318            .extend(segments);
319        Ok(url)
320    }
321
322    async fn attachment_get<T: DeserializeOwned>(
323        &self,
324        segments: &[&str],
325        token: &SecretString,
326        download: bool,
327    ) -> Result<T, ClientError> {
328        let request = self
329            .http
330            .get(self.attachment_endpoint(segments)?)
331            .header(ACCEPT, "application/json")
332            .bearer_auth(token.expose_secret());
333        let body = if download {
334            self.execute_success_request_with_limit(request, DOWNLOAD_RESPONSE_BYTES)
335                .await?
336        } else {
337            self.execute_success_request(request).await?
338        };
339        decode_attachment(&body)
340    }
341
342    pub async fn attachments(
343        &self,
344        board: &str,
345        swimlane: Option<&str>,
346        list: Option<&str>,
347        card: Option<&str>,
348        token: &SecretString,
349    ) -> Result<AttachmentListResponse, ClientError> {
350        let mut segments = vec!["list", board];
351        segments.extend(swimlane);
352        segments.extend(list);
353        segments.extend(card);
354        let response: AttachmentListResponse = self.attachment_get(&segments, token, false).await?;
355        require_success(response.success)?;
356        if response.count as usize != response.attachments.len() {
357            return Err(protocol(
358                "the attachment count did not match the returned collection",
359            ));
360        }
361        Ok(response)
362    }
363
364    pub async fn attachment_info(
365        &self,
366        id: &str,
367        token: &SecretString,
368    ) -> Result<AttachmentInfo, ClientError> {
369        let response: AttachmentInfo = self.attachment_get(&["info", id], token, false).await?;
370        require_success(response.success)?;
371        if response.attachment_id != id {
372            return Err(protocol(
373                "the attachment response identifier did not match the request",
374            ));
375        }
376        Ok(response)
377    }
378
379    pub async fn download_attachment(
380        &self,
381        id: &str,
382        token: &SecretString,
383    ) -> Result<AttachmentDownloadResponse, ClientError> {
384        let response: AttachmentDownloadResponse =
385            self.attachment_get(&["download", id], token, true).await?;
386        require_success(response.success)?;
387        if response.attachment_id != id {
388            return Err(protocol(
389                "the attachment response identifier did not match the request",
390            ));
391        }
392        Ok(response)
393    }
394
395    pub async fn download_background(
396        &self,
397        board: &str,
398        token: &SecretString,
399    ) -> Result<BackgroundDownloadResponse, ClientError> {
400        let response: BackgroundDownloadResponse = self
401            .attachment_get(&["download-background", board], token, true)
402            .await?;
403        require_success(response.success)?;
404        Ok(response)
405    }
406
407    async fn attachment_post<T: DeserializeOwned, R: Serialize>(
408        &self,
409        operation: &str,
410        request: &R,
411        token: &SecretString,
412    ) -> Result<T, ClientError> {
413        let body = self
414            .execute_success_request(
415                self.http
416                    .post(self.attachment_endpoint(&[operation])?)
417                    .timeout(POST_TIMEOUT)
418                    .header(ACCEPT, "application/json")
419                    .bearer_auth(token.expose_secret())
420                    .json(request),
421            )
422            .await?;
423        decode_attachment(&body)
424    }
425
426    pub async fn upload_attachment(
427        &self,
428        request: &UploadAttachmentRequest,
429        token: &SecretString,
430    ) -> Result<AttachmentUploadResponse, ClientError> {
431        let response: AttachmentUploadResponse =
432            self.attachment_post("upload", request, token).await?;
433        require_success(response.success)?;
434        Ok(response)
435    }
436
437    pub async fn upload_background(
438        &self,
439        request: &UploadBackgroundRequest,
440        token: &SecretString,
441    ) -> Result<BackgroundUploadResponse, ClientError> {
442        let response: BackgroundUploadResponse = self
443            .attachment_post("upload-background", request, token)
444            .await?;
445        require_success(response.success)?;
446        Ok(response)
447    }
448
449    pub async fn copy_attachment(
450        &self,
451        request: &TransferAttachmentRequest,
452        token: &SecretString,
453    ) -> Result<AttachmentCopyResponse, ClientError> {
454        let response: AttachmentCopyResponse = self.attachment_post("copy", request, token).await?;
455        require_success(response.success)?;
456        if response.source_attachment_id != request.attachment_id {
457            return Err(protocol(
458                "the copy response source identifier did not match the request",
459            ));
460        }
461        Ok(response)
462    }
463
464    pub async fn move_attachment(
465        &self,
466        request: &TransferAttachmentRequest,
467        token: &SecretString,
468    ) -> Result<AttachmentMoveResponse, ClientError> {
469        let response: AttachmentMoveResponse = self.attachment_post("move", request, token).await?;
470        require_success(response.success)?;
471        if response.attachment_id != request.attachment_id
472            || response.target_board_id != request.target_board_id
473        {
474            return Err(protocol(
475                "the move response identifiers did not match the request",
476            ));
477        }
478        Ok(response)
479    }
480
481    pub async fn delete_attachment(
482        &self,
483        id: &str,
484        token: &SecretString,
485    ) -> Result<AttachmentDeleteResponse, ClientError> {
486        let body = self
487            .execute_success_request(
488                self.http
489                    .request(Method::DELETE, self.attachment_endpoint(&["delete", id])?)
490                    .header(ACCEPT, "application/json")
491                    .bearer_auth(token.expose_secret()),
492            )
493            .await?;
494        let response: AttachmentDeleteResponse = decode_attachment(&body)?;
495        require_success(response.success)?;
496        if response.attachment_id != id {
497            return Err(protocol(
498                "the deletion response identifier did not match the request",
499            ));
500        }
501        Ok(response)
502    }
503}
504
505impl WekanClient {
506    pub async fn export_attachment(
507        &self,
508        board: &str,
509        attachment: &str,
510        options: super::exports::ExportOptions<'_>,
511        file: &mut std::fs::File,
512    ) -> Result<super::exports::ExportMetadata, super::exports::ExportError> {
513        if options.format != super::exports::ExportFormat::Json {
514            return Err(super::exports::protocol("attachment exports require json").into());
515        }
516        self.export_resource(
517            &[board, "attachments", attachment, "export"],
518            options,
519            file,
520            true,
521        )
522        .await
523    }
524}