1use anyhow::Result;
2use base64::{Engine as _, engine::general_purpose};
3use md5::Context;
4use reqwest::header::{AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, HeaderValue};
5use serde::{Deserialize, Serialize};
6use std::path::Path;
7use tokio::fs;
8
9#[derive(Debug, Serialize, Deserialize)]
10pub struct UploadResult {
11 pub image_key: String,
12 pub image_uri: String,
13 pub status_code: u16,
14}
15
16#[derive(Debug, Deserialize)]
17struct UploadResponse {
18 stat: String,
19 #[serde(rename = "Image")]
20 image: ImageInfo,
21}
22
23#[derive(Debug, Deserialize)]
24struct ImageInfo {
25 #[serde(rename = "ImageUri")]
26 image_uri: String,
27}
28
29#[derive(Debug, Clone)]
31pub struct UploadPayload {
32 pub data: bytes::Bytes,
34 pub file_name: String,
35 pub mime_type: String,
36}
37
38impl UploadPayload {
39 pub async fn from_path(file_path: &Path) -> Result<Self> {
41 let data = fs::read(file_path).await?.into();
42 let file_name = file_path
43 .file_name()
44 .and_then(|n| n.to_str())
45 .unwrap_or("image")
46 .to_string();
47 let mime_type = mime_guess::from_path(file_path)
48 .first_or_octet_stream()
49 .to_string();
50 Ok(UploadPayload {
51 data,
52 file_name,
53 mime_type,
54 })
55 }
56}
57
58pub async fn upload_image(
59 client: &crate::api::SmugMugClient,
60 album_uri: &str,
61 payload: &UploadPayload,
62) -> Result<UploadResult> {
63 send_upload(client, "X-Smug-AlbumUri", album_uri, payload).await
64}
65
66pub async fn replace_image(
72 client: &crate::api::SmugMugClient,
73 image_uri: &str,
74 payload: &UploadPayload,
75) -> Result<UploadResult> {
76 send_upload(client, "X-Smug-ImageUri", image_uri, payload).await
77}
78
79pub const LIBRARY_UPLOAD_URL: &str = "https://upload.smugmug.com/api/v2/library";
86
87#[derive(Debug, Deserialize)]
88struct LibraryUploadResponse {
89 #[serde(rename = "Response")]
90 response: LibraryUploadResponseBody,
91}
92
93#[derive(Debug, Deserialize)]
94struct LibraryUploadResponseBody {
95 #[serde(rename = "Image")]
96 image: LibraryImage,
97}
98
99#[derive(Debug, Deserialize)]
100struct LibraryImage {
101 #[serde(rename = "ImageKey")]
102 image_key: String,
103 #[serde(rename = "Uri")]
104 uri: String,
105}
106
107#[allow(dead_code)] pub async fn upload_to_library(
112 client: &crate::api::SmugMugClient,
113 file_path: &Path,
114 filepath: &str,
115) -> Result<UploadResult> {
116 let (status_code, body_text) =
117 send_library_upload(client, LIBRARY_UPLOAD_URL, file_path, filepath).await?;
118 parse_library_upload_response(status_code, &body_text)
119}
120
121pub async fn send_library_upload(
124 client: &crate::api::SmugMugClient,
125 upload_url: &str,
126 file_path: &Path,
127 filepath: &str,
128) -> Result<(u16, String)> {
129 use reqwest::multipart::{Form, Part};
130 use sha2::{Digest, Sha256};
131
132 let file_data = fs::read(file_path).await?;
133 let byte_count = file_data.len();
134 let sha256_base64 = general_purpose::STANDARD.encode(Sha256::digest(&file_data));
135
136 let mime_type = mime_guess::from_path(file_path)
137 .first_or_octet_stream()
138 .to_string();
139 let filename = file_path
140 .file_name()
141 .and_then(|n| n.to_str())
142 .unwrap_or("image")
143 .to_string();
144
145 let form = Form::new()
146 .text("ByteCount", byte_count.to_string())
147 .text("Filepath", filepath.to_string())
148 .text("Sha256Sum", sha256_base64)
149 .part(
150 "Media",
151 Part::bytes(file_data)
152 .file_name(filename)
153 .mime_str(&mime_type)?,
154 );
155
156 let oauth_header = client.build_oauth_header("POST", upload_url);
159
160 let response = reqwest::Client::new()
161 .post(upload_url)
162 .header(AUTHORIZATION, oauth_header)
163 .header("Accept", "application/json")
164 .multipart(form)
165 .send()
166 .await?;
167
168 let status_code = response.status().as_u16();
169 Ok((status_code, response.text().await?))
170}
171
172pub fn parse_library_upload_response(status_code: u16, body_text: &str) -> Result<UploadResult> {
173 if !(200..300).contains(&status_code) {
174 anyhow::bail!(
175 "Library upload failed with status {}: {}",
176 status_code,
177 body_text
178 );
179 }
180
181 let parsed: LibraryUploadResponse = serde_json::from_str(body_text)?;
182 Ok(UploadResult {
183 image_key: parsed.response.image.image_key,
184 image_uri: parsed.response.image.uri,
185 status_code,
186 })
187}
188
189async fn send_upload(
190 client: &crate::api::SmugMugClient,
191 target_header: &'static str,
192 target_uri: &str,
193 payload: &UploadPayload,
194) -> Result<UploadResult> {
195 let mut context = Context::new();
196 context.consume(&payload.data);
197 let md5_base64 = general_purpose::STANDARD.encode(context.finalize().0);
198 let headers = upload_headers(
199 client,
200 target_header,
201 target_uri,
202 payload.data.len() as u64,
203 &md5_base64,
204 &payload.mime_type,
205 &payload.file_name,
206 )?;
207 let response = client
208 .http()
209 .post(UPLOAD_URL)
210 .headers(headers)
211 .body(payload.data.clone())
212 .send()
213 .await?;
214 read_upload_response(response).await
215}
216
217const UPLOAD_URL: &str = "https://upload.smugmug.com/";
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum UploadTarget<'a> {
224 Album(&'a str),
225 ReplaceImage(&'a str),
226}
227
228#[derive(Debug, Clone)]
230pub struct FileUpload<'a> {
231 pub path: &'a Path,
232 pub size: u64,
234 pub md5_hex: &'a str,
236 pub file_name: &'a str,
238}
239
240pub async fn upload_file(
242 client: &crate::api::SmugMugClient,
243 target: UploadTarget<'_>,
244 file: &FileUpload<'_>,
245) -> Result<UploadResult> {
246 let (target_header, target_uri) = match target {
247 UploadTarget::Album(uri) => ("X-Smug-AlbumUri", uri),
248 UploadTarget::ReplaceImage(uri) => ("X-Smug-ImageUri", uri),
249 };
250 let md5 = hex::decode(file.md5_hex)?;
251 let mime_type = mime_guess::from_path(file.path)
252 .first_or_octet_stream()
253 .to_string();
254 let headers = upload_headers(
255 client,
256 target_header,
257 target_uri,
258 file.size,
259 &general_purpose::STANDARD.encode(md5),
260 &mime_type,
261 file.file_name,
262 )?;
263 let reader = fs::File::open(file.path).await?;
264 let body = reqwest::Body::wrap_stream(tokio_util::io::ReaderStream::with_capacity(
265 reader,
266 256 * 1024,
267 ));
268 let response = client
269 .http()
270 .post(UPLOAD_URL)
271 .headers(headers)
272 .body(body)
273 .send()
274 .await?;
275 read_upload_response(response).await
276}
277
278pub async fn upload_bytes(
280 client: &crate::api::SmugMugClient,
281 target: UploadTarget<'_>,
282 payload: &UploadPayload,
283) -> Result<UploadResult> {
284 match target {
285 UploadTarget::Album(uri) => upload_image(client, uri, payload).await,
286 UploadTarget::ReplaceImage(uri) => replace_image(client, uri, payload).await,
287 }
288}
289
290fn upload_headers(
291 client: &crate::api::SmugMugClient,
292 target_header: &'static str,
293 target_uri: &str,
294 size: u64,
295 md5_base64: &str,
296 mime_type: &str,
297 filename: &str,
298) -> Result<HeaderMap> {
299 let oauth_header = client.build_oauth_header("POST", UPLOAD_URL);
300 let mut headers = HeaderMap::new();
301 headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
302 headers.insert(CONTENT_LENGTH, HeaderValue::from(size));
303 headers.insert(CONTENT_TYPE, HeaderValue::from_str(mime_type)?);
304 headers.insert("Content-MD5", HeaderValue::from_str(md5_base64)?);
305 headers.insert(target_header, HeaderValue::from_str(target_uri)?);
306 headers.insert("X-Smug-FileName", header_text(filename)?);
307 headers.insert("X-Smug-Title", header_text(filename)?);
308 headers.insert("X-Smug-ResponseType", HeaderValue::from_static("JSON"));
309 headers.insert("X-Smug-Version", HeaderValue::from_static("v2"));
310 headers.insert("Accept", HeaderValue::from_static("application/json"));
311 Ok(headers)
312}
313
314fn header_text(text: &str) -> Result<HeaderValue> {
318 Ok(HeaderValue::from_bytes(text.as_bytes())?)
319}
320
321#[derive(Debug, thiserror::Error)]
323#[error("Upload failed with status {status}: {body}")]
324pub struct UploadRejected {
325 pub status: u16,
326 pub body: String,
327}
328
329impl UploadRejected {
330 pub fn is_permanent(&self) -> bool {
334 matches!(self.status, 400 | 413 | 415 | 422)
335 }
336}
337
338async fn read_upload_response(response: reqwest::Response) -> Result<UploadResult> {
339 let status = response.status();
340 let status_code = status.as_u16();
341 let body_text = response.text().await?;
342
343 if !status.is_success() {
344 return Err(UploadRejected {
345 status: status_code,
346 body: body_text,
347 }
348 .into());
349 }
350
351 let upload_response: UploadResponse = serde_json::from_str(&body_text)?;
352
353 if upload_response.stat != "ok" {
354 anyhow::bail!("Upload failed: {}", body_text);
355 }
356
357 let image_key = upload_response
359 .image
360 .image_uri
361 .split('/')
362 .next_back()
363 .unwrap_or("")
364 .to_string();
365
366 Ok(UploadResult {
367 image_key,
368 image_uri: upload_response.image.image_uri,
369 status_code,
370 })
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use std::io::Write;
377 use tempfile::NamedTempFile;
378
379 fn create_test_client() -> crate::api::SmugMugClient {
380 crate::api::SmugMugClient::new(
381 "test_api_key".to_string(),
382 "test_api_secret".to_string(),
383 "test_access_token".to_string(),
384 "test_access_token_secret".to_string(),
385 )
386 }
387
388 #[test]
389 fn test_upload_result_structure() {
390 let result = UploadResult {
391 image_key: "IMG123".to_string(),
392 image_uri: "/api/v2/image/IMG123".to_string(),
393 status_code: 200,
394 };
395
396 assert_eq!(result.image_key, "IMG123");
397 assert_eq!(result.image_uri, "/api/v2/image/IMG123");
398 assert_eq!(result.status_code, 200);
399 }
400
401 #[test]
402 fn test_upload_result_serialization() {
403 let result = UploadResult {
404 image_key: "IMG123".to_string(),
405 image_uri: "/api/v2/image/IMG123".to_string(),
406 status_code: 200,
407 };
408
409 let json = serde_json::to_string(&result).unwrap();
410 assert!(json.contains("\"image_key\":\"IMG123\""));
411 assert!(json.contains("\"image_uri\":\"/api/v2/image/IMG123\""));
412 assert!(json.contains("\"status_code\":200"));
413 }
414
415 #[test]
416 fn test_upload_result_deserialization() {
417 let json = r#"{
418 "image_key": "IMG123",
419 "image_uri": "/api/v2/image/IMG123",
420 "status_code": 200
421 }"#;
422
423 let result: UploadResult = serde_json::from_str(json).unwrap();
424 assert_eq!(result.image_key, "IMG123");
425 assert_eq!(result.image_uri, "/api/v2/image/IMG123");
426 assert_eq!(result.status_code, 200);
427 }
428
429 #[tokio::test]
430 async fn test_upload_image_creates_correct_headers() {
431 let mut temp_file = NamedTempFile::new().unwrap();
433 writeln!(temp_file, "test image data").unwrap();
434 let file_path = temp_file.path();
435
436 let _client = create_test_client();
437
438 let mut server = mockito::Server::new_async().await;
439 let _mock = server
440 .mock("POST", "/")
441 .match_header(
442 "authorization",
443 mockito::Matcher::Regex("OAuth.*".to_string()),
444 )
445 .match_header("content-type", mockito::Matcher::Any)
446 .match_header("content-md5", mockito::Matcher::Any)
447 .match_header("x-smug-albumuri", "/api/v2/album/ABC123")
448 .match_header("x-smug-responsetype", "JSON")
449 .match_header("x-smug-version", "v2")
450 .with_status(200)
451 .with_header("content-type", "application/json")
452 .with_body(
453 r#"{
454 "stat": "ok",
455 "Image": {
456 "ImageUri": "/api/v2/album/ABC123/image/IMG123-0"
457 }
458 }"#,
459 )
460 .create_async()
461 .await;
462
463 }
465
466 #[tokio::test]
467 async fn test_upload_image_mock_success() {
468 let mut temp_file = NamedTempFile::new().unwrap();
469 writeln!(temp_file, "test image data").unwrap();
470 let _file_path = temp_file.path();
471
472 let _client = create_test_client();
473
474 let mut server = mockito::Server::new_async().await;
475 let _mock = server
476 .mock("POST", "/")
477 .with_status(200)
478 .with_header("content-type", "application/json")
479 .with_body(
480 r#"{
481 "stat": "ok",
482 "Image": {
483 "ImageUri": "/api/v2/album/ABC123/image/IMG123-0"
484 }
485 }"#,
486 )
487 .create_async()
488 .await;
489 }
490
491 #[tokio::test]
492 async fn test_upload_image_mock_failure() {
493 let mut temp_file = NamedTempFile::new().unwrap();
494 writeln!(temp_file, "test image data").unwrap();
495 let _file_path = temp_file.path();
496
497 let _client = create_test_client();
498
499 let mut server = mockito::Server::new_async().await;
500 let _mock = server
501 .mock("POST", "/")
502 .with_status(400)
503 .with_body("Bad Request: Invalid album URI")
504 .create_async()
505 .await;
506 }
507
508 #[test]
509 fn test_upload_response_deserialization() {
510 let json = r#"{
511 "stat": "ok",
512 "Image": {
513 "ImageUri": "/api/v2/album/ABC123/image/IMG123-0"
514 }
515 }"#;
516
517 let response: UploadResponse = serde_json::from_str(json).unwrap();
518 assert_eq!(response.stat, "ok");
519 assert_eq!(
520 response.image.image_uri,
521 "/api/v2/album/ABC123/image/IMG123-0"
522 );
523 }
524
525 #[test]
526 fn test_image_key_extraction() {
527 let image_uri = "/api/v2/album/ABC123/image/IMG123-0";
528 let image_key = image_uri.split('/').last().unwrap_or("");
529 assert_eq!(image_key, "IMG123-0");
530 }
531
532 #[tokio::test]
533 async fn test_file_reading_and_md5() {
534 let mut temp_file = NamedTempFile::new().unwrap();
536 let test_data = b"Hello, SmugMug!";
537 temp_file.write_all(test_data).unwrap();
538 temp_file.flush().unwrap();
539
540 let file_path = temp_file.path();
541
542 let file_data = fs::read(file_path).await.unwrap();
544 assert_eq!(file_data, test_data);
545
546 let mut context = Context::new();
547 context.consume(&file_data);
548 let md5_hash = context.finalize();
549 let md5_base64 = general_purpose::STANDARD.encode(md5_hash.0);
550
551 assert!(!md5_base64.is_empty());
553 }
554
555 #[test]
556 fn test_mime_type_detection() {
557 let jpg_path = Path::new("test.jpg");
559 let mime_jpg = mime_guess::from_path(jpg_path).first_or_octet_stream();
560 assert_eq!(mime_jpg.to_string(), "image/jpeg");
561
562 let png_path = Path::new("test.png");
563 let mime_png = mime_guess::from_path(png_path).first_or_octet_stream();
564 assert_eq!(mime_png.to_string(), "image/png");
565
566 let unknown_path = Path::new("test.unknown123");
568 let mime_unknown = mime_guess::from_path(unknown_path).first_or_octet_stream();
569 assert_eq!(mime_unknown.to_string(), "application/octet-stream");
570 }
571
572 #[tokio::test]
573 async fn test_upload_response_stat_not_ok() {
574 let json = r#"{
575 "stat": "fail",
576 "message": "Upload failed",
577 "code": 1
578 }"#;
579
580 let result = serde_json::from_str::<UploadResponse>(json);
583 assert!(result.is_err());
584 }
585
586 #[tokio::test]
587 async fn test_library_upload_sends_multipart_fields() {
588 use sha2::{Digest, Sha256};
589
590 let mut temp_file = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
591 let test_data = b"library test data";
592 temp_file.write_all(test_data).unwrap();
593 temp_file.flush().unwrap();
594 let file_name = temp_file
595 .path()
596 .file_name()
597 .unwrap()
598 .to_str()
599 .unwrap()
600 .to_string();
601 let expected_sha = general_purpose::STANDARD.encode(Sha256::digest(test_data));
602 assert_eq!(expected_sha.len(), 44);
603
604 let mut server = mockito::Server::new_async().await;
605 let mock = server
606 .mock("POST", "/api/v2/library")
607 .match_header(
608 "authorization",
609 mockito::Matcher::Regex("^OAuth .*oauth_signature=".to_string()),
610 )
611 .match_header(
612 "content-type",
613 mockito::Matcher::Regex("^multipart/form-data; boundary=".to_string()),
614 )
615 .match_body(mockito::Matcher::AllOf(vec![
616 mockito::Matcher::Regex(format!(
617 "name=\"ByteCount\"\r\n\r\n{}\r\n",
618 test_data.len()
619 )),
620 mockito::Matcher::Regex(format!(
621 "name=\"Sha256Sum\"\r\n\r\n{}\r\n",
622 regex_escape(&expected_sha)
623 )),
624 mockito::Matcher::Regex("name=\"Filepath\"\r\n\r\n2024/Trip/a.jpg\r\n".to_string()),
625 mockito::Matcher::Regex(format!(
626 "name=\"Media\"; filename=\"{}\"\r\nContent-Type: image/jpeg",
627 regex_escape(&file_name)
628 )),
629 mockito::Matcher::Regex("library test data".to_string()),
630 ]))
631 .with_status(201)
632 .with_body(LIBRARY_RESPONSE_FIXTURE)
633 .create_async()
634 .await;
635
636 let client = create_test_client();
637 let url = format!("{}/api/v2/library", server.url());
638 let (status, body) =
639 send_library_upload(&client, &url, temp_file.path(), "2024/Trip/a.jpg")
640 .await
641 .unwrap();
642
643 mock.assert_async().await;
644 assert_eq!(status, 201);
645 let result = parse_library_upload_response(status, &body).unwrap();
646 assert_eq!(result.image_key, "5DftXbZ");
647 assert_eq!(result.image_uri, "/api/v2/image/5DftXbZ-0");
648 }
649
650 #[test]
651 fn test_parse_library_upload_response_error_status() {
652 let err = parse_library_upload_response(401, r#"{"Code":401,"Message":"Unauthorized"}"#)
653 .unwrap_err();
654 assert!(err.to_string().contains("401"));
655 }
656
657 fn regex_escape(s: &str) -> String {
658 s.chars()
659 .flat_map(|c| {
660 if "\\.+*?()|[]{}^$".contains(c) {
661 vec!['\\', c]
662 } else {
663 vec![c]
664 }
665 })
666 .collect()
667 }
668
669 const LIBRARY_RESPONSE_FIXTURE: &str = r#"{
671 "Response": {
672 "Uri": "/api/v2/image/5DftXbZ-0",
673 "Locator": "Image",
674 "LocatorType": "Object",
675 "Image": {
676 "FileName": "darth copy.png",
677 "Processing": true,
678 "ImageKey": "5DftXbZ",
679 "ArchivedMD5": "fec6679b041962097bfd6a4fea03ab8d",
680 "PublishedTo": [],
681 "Uri": "/api/v2/image/5DftXbZ-0"
682 },
683 "EndpointType": "Image"
684 },
685 "Code": 201,
686 "Message": "Created"
687 }"#;
688
689 #[tokio::test]
690 async fn test_upload_invalid_file() {
691 let _client = create_test_client();
692 let non_existent_path = Path::new("/tmp/this_file_does_not_exist_12345.jpg");
693
694 let result = fs::read(non_existent_path).await;
696 assert!(result.is_err());
697 }
698}