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