Skip to main content

wekan_cli/client/
attachments.rs

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