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
42fn 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 #[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 #[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 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}