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 file_data = payload.data.clone();
196 let file_size = file_data.len();
197 let mime_type = &payload.mime_type;
198 let filename = payload.file_name.as_str();
199
200 let mut context = Context::new();
202 context.consume(&file_data);
203 let md5_hash = context.finalize();
204 let md5_base64 = general_purpose::STANDARD.encode(md5_hash.0);
205
206 let upload_url = "https://upload.smugmug.com/";
208 let oauth_header = client.build_oauth_header("POST", upload_url);
209
210 let mut headers = HeaderMap::new();
212 headers.insert(AUTHORIZATION, HeaderValue::from_str(&oauth_header)?);
213 headers.insert(CONTENT_LENGTH, HeaderValue::from(file_size as u64));
214 headers.insert(CONTENT_TYPE, HeaderValue::from_str(mime_type)?);
215 headers.insert("Content-MD5", HeaderValue::from_str(&md5_base64)?);
216 headers.insert(target_header, HeaderValue::from_str(target_uri)?);
217 headers.insert("X-Smug-FileName", HeaderValue::from_str(filename)?);
218 headers.insert("X-Smug-Title", HeaderValue::from_str(filename)?);
219 headers.insert("X-Smug-ResponseType", HeaderValue::from_static("JSON"));
220 headers.insert("X-Smug-Version", HeaderValue::from_static("v2"));
221 headers.insert("Accept", HeaderValue::from_static("application/json"));
222
223 let http_client = reqwest::Client::new();
225 let response = http_client
226 .post(upload_url)
227 .headers(headers)
228 .body(file_data)
229 .send()
230 .await?;
231
232 let status = response.status();
233 let status_code = status.as_u16();
234 let body_text = response.text().await?;
235
236 if !status.is_success() {
237 anyhow::bail!("Upload failed with status {}: {}", status_code, body_text);
238 }
239
240 let upload_response: UploadResponse = serde_json::from_str(&body_text)?;
242
243 if upload_response.stat != "ok" {
244 anyhow::bail!("Upload failed: {}", body_text);
245 }
246
247 let image_key = upload_response
249 .image
250 .image_uri
251 .split('/')
252 .last()
253 .unwrap_or("")
254 .to_string();
255
256 Ok(UploadResult {
257 image_key,
258 image_uri: upload_response.image.image_uri,
259 status_code,
260 })
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use std::io::Write;
267 use tempfile::NamedTempFile;
268
269 fn create_test_client() -> crate::api::SmugMugClient {
270 crate::api::SmugMugClient::new(
271 "test_api_key".to_string(),
272 "test_api_secret".to_string(),
273 "test_access_token".to_string(),
274 "test_access_token_secret".to_string(),
275 )
276 }
277
278 #[test]
279 fn test_upload_result_structure() {
280 let result = UploadResult {
281 image_key: "IMG123".to_string(),
282 image_uri: "/api/v2/image/IMG123".to_string(),
283 status_code: 200,
284 };
285
286 assert_eq!(result.image_key, "IMG123");
287 assert_eq!(result.image_uri, "/api/v2/image/IMG123");
288 assert_eq!(result.status_code, 200);
289 }
290
291 #[test]
292 fn test_upload_result_serialization() {
293 let result = UploadResult {
294 image_key: "IMG123".to_string(),
295 image_uri: "/api/v2/image/IMG123".to_string(),
296 status_code: 200,
297 };
298
299 let json = serde_json::to_string(&result).unwrap();
300 assert!(json.contains("\"image_key\":\"IMG123\""));
301 assert!(json.contains("\"image_uri\":\"/api/v2/image/IMG123\""));
302 assert!(json.contains("\"status_code\":200"));
303 }
304
305 #[test]
306 fn test_upload_result_deserialization() {
307 let json = r#"{
308 "image_key": "IMG123",
309 "image_uri": "/api/v2/image/IMG123",
310 "status_code": 200
311 }"#;
312
313 let result: UploadResult = serde_json::from_str(json).unwrap();
314 assert_eq!(result.image_key, "IMG123");
315 assert_eq!(result.image_uri, "/api/v2/image/IMG123");
316 assert_eq!(result.status_code, 200);
317 }
318
319 #[tokio::test]
320 async fn test_upload_image_creates_correct_headers() {
321 let mut temp_file = NamedTempFile::new().unwrap();
323 writeln!(temp_file, "test image data").unwrap();
324 let file_path = temp_file.path();
325
326 let _client = create_test_client();
327
328 let mut server = mockito::Server::new_async().await;
329 let _mock = server
330 .mock("POST", "/")
331 .match_header(
332 "authorization",
333 mockito::Matcher::Regex("OAuth.*".to_string()),
334 )
335 .match_header("content-type", mockito::Matcher::Any)
336 .match_header("content-md5", mockito::Matcher::Any)
337 .match_header("x-smug-albumuri", "/api/v2/album/ABC123")
338 .match_header("x-smug-responsetype", "JSON")
339 .match_header("x-smug-version", "v2")
340 .with_status(200)
341 .with_header("content-type", "application/json")
342 .with_body(
343 r#"{
344 "stat": "ok",
345 "Image": {
346 "ImageUri": "/api/v2/album/ABC123/image/IMG123-0"
347 }
348 }"#,
349 )
350 .create_async()
351 .await;
352
353 }
355
356 #[tokio::test]
357 async fn test_upload_image_mock_success() {
358 let mut temp_file = NamedTempFile::new().unwrap();
359 writeln!(temp_file, "test image data").unwrap();
360 let _file_path = temp_file.path();
361
362 let _client = create_test_client();
363
364 let mut server = mockito::Server::new_async().await;
365 let _mock = server
366 .mock("POST", "/")
367 .with_status(200)
368 .with_header("content-type", "application/json")
369 .with_body(
370 r#"{
371 "stat": "ok",
372 "Image": {
373 "ImageUri": "/api/v2/album/ABC123/image/IMG123-0"
374 }
375 }"#,
376 )
377 .create_async()
378 .await;
379 }
380
381 #[tokio::test]
382 async fn test_upload_image_mock_failure() {
383 let mut temp_file = NamedTempFile::new().unwrap();
384 writeln!(temp_file, "test image data").unwrap();
385 let _file_path = temp_file.path();
386
387 let _client = create_test_client();
388
389 let mut server = mockito::Server::new_async().await;
390 let _mock = server
391 .mock("POST", "/")
392 .with_status(400)
393 .with_body("Bad Request: Invalid album URI")
394 .create_async()
395 .await;
396 }
397
398 #[test]
399 fn test_upload_response_deserialization() {
400 let json = r#"{
401 "stat": "ok",
402 "Image": {
403 "ImageUri": "/api/v2/album/ABC123/image/IMG123-0"
404 }
405 }"#;
406
407 let response: UploadResponse = serde_json::from_str(json).unwrap();
408 assert_eq!(response.stat, "ok");
409 assert_eq!(
410 response.image.image_uri,
411 "/api/v2/album/ABC123/image/IMG123-0"
412 );
413 }
414
415 #[test]
416 fn test_image_key_extraction() {
417 let image_uri = "/api/v2/album/ABC123/image/IMG123-0";
418 let image_key = image_uri.split('/').last().unwrap_or("");
419 assert_eq!(image_key, "IMG123-0");
420 }
421
422 #[tokio::test]
423 async fn test_file_reading_and_md5() {
424 let mut temp_file = NamedTempFile::new().unwrap();
426 let test_data = b"Hello, SmugMug!";
427 temp_file.write_all(test_data).unwrap();
428 temp_file.flush().unwrap();
429
430 let file_path = temp_file.path();
431
432 let file_data = fs::read(file_path).await.unwrap();
434 assert_eq!(file_data, test_data);
435
436 let mut context = Context::new();
437 context.consume(&file_data);
438 let md5_hash = context.finalize();
439 let md5_base64 = general_purpose::STANDARD.encode(md5_hash.0);
440
441 assert!(!md5_base64.is_empty());
443 }
444
445 #[test]
446 fn test_mime_type_detection() {
447 let jpg_path = Path::new("test.jpg");
449 let mime_jpg = mime_guess::from_path(jpg_path).first_or_octet_stream();
450 assert_eq!(mime_jpg.to_string(), "image/jpeg");
451
452 let png_path = Path::new("test.png");
453 let mime_png = mime_guess::from_path(png_path).first_or_octet_stream();
454 assert_eq!(mime_png.to_string(), "image/png");
455
456 let unknown_path = Path::new("test.unknown123");
458 let mime_unknown = mime_guess::from_path(unknown_path).first_or_octet_stream();
459 assert_eq!(mime_unknown.to_string(), "application/octet-stream");
460 }
461
462 #[tokio::test]
463 async fn test_upload_response_stat_not_ok() {
464 let json = r#"{
465 "stat": "fail",
466 "message": "Upload failed",
467 "code": 1
468 }"#;
469
470 let result = serde_json::from_str::<UploadResponse>(json);
473 assert!(result.is_err());
474 }
475
476 #[tokio::test]
477 async fn test_library_upload_sends_multipart_fields() {
478 use sha2::{Digest, Sha256};
479
480 let mut temp_file = tempfile::Builder::new().suffix(".jpg").tempfile().unwrap();
481 let test_data = b"library test data";
482 temp_file.write_all(test_data).unwrap();
483 temp_file.flush().unwrap();
484 let file_name = temp_file
485 .path()
486 .file_name()
487 .unwrap()
488 .to_str()
489 .unwrap()
490 .to_string();
491 let expected_sha = general_purpose::STANDARD.encode(Sha256::digest(test_data));
492 assert_eq!(expected_sha.len(), 44);
493
494 let mut server = mockito::Server::new_async().await;
495 let mock = server
496 .mock("POST", "/api/v2/library")
497 .match_header(
498 "authorization",
499 mockito::Matcher::Regex("^OAuth .*oauth_signature=".to_string()),
500 )
501 .match_header(
502 "content-type",
503 mockito::Matcher::Regex("^multipart/form-data; boundary=".to_string()),
504 )
505 .match_body(mockito::Matcher::AllOf(vec![
506 mockito::Matcher::Regex(format!(
507 "name=\"ByteCount\"\r\n\r\n{}\r\n",
508 test_data.len()
509 )),
510 mockito::Matcher::Regex(format!(
511 "name=\"Sha256Sum\"\r\n\r\n{}\r\n",
512 regex_escape(&expected_sha)
513 )),
514 mockito::Matcher::Regex("name=\"Filepath\"\r\n\r\n2024/Trip/a.jpg\r\n".to_string()),
515 mockito::Matcher::Regex(format!(
516 "name=\"Media\"; filename=\"{}\"\r\nContent-Type: image/jpeg",
517 regex_escape(&file_name)
518 )),
519 mockito::Matcher::Regex("library test data".to_string()),
520 ]))
521 .with_status(201)
522 .with_body(LIBRARY_RESPONSE_FIXTURE)
523 .create_async()
524 .await;
525
526 let client = create_test_client();
527 let url = format!("{}/api/v2/library", server.url());
528 let (status, body) =
529 send_library_upload(&client, &url, temp_file.path(), "2024/Trip/a.jpg")
530 .await
531 .unwrap();
532
533 mock.assert_async().await;
534 assert_eq!(status, 201);
535 let result = parse_library_upload_response(status, &body).unwrap();
536 assert_eq!(result.image_key, "5DftXbZ");
537 assert_eq!(result.image_uri, "/api/v2/image/5DftXbZ-0");
538 }
539
540 #[test]
541 fn test_parse_library_upload_response_error_status() {
542 let err = parse_library_upload_response(401, r#"{"Code":401,"Message":"Unauthorized"}"#)
543 .unwrap_err();
544 assert!(err.to_string().contains("401"));
545 }
546
547 fn regex_escape(s: &str) -> String {
548 s.chars()
549 .flat_map(|c| {
550 if "\\.+*?()|[]{}^$".contains(c) {
551 vec!['\\', c]
552 } else {
553 vec![c]
554 }
555 })
556 .collect()
557 }
558
559 const LIBRARY_RESPONSE_FIXTURE: &str = r#"{
561 "Response": {
562 "Uri": "/api/v2/image/5DftXbZ-0",
563 "Locator": "Image",
564 "LocatorType": "Object",
565 "Image": {
566 "FileName": "darth copy.png",
567 "Processing": true,
568 "ImageKey": "5DftXbZ",
569 "ArchivedMD5": "fec6679b041962097bfd6a4fea03ab8d",
570 "PublishedTo": [],
571 "Uri": "/api/v2/image/5DftXbZ-0"
572 },
573 "EndpointType": "Image"
574 },
575 "Code": 201,
576 "Message": "Created"
577 }"#;
578
579 #[tokio::test]
580 async fn test_upload_invalid_file() {
581 let _client = create_test_client();
582 let non_existent_path = Path::new("/tmp/this_file_does_not_exist_12345.jpg");
583
584 let result = fs::read(non_existent_path).await;
586 assert!(result.is_err());
587 }
588}