Skip to main content

smugmug_cli/api/
upload.rs

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/// A file to upload: its bytes and the name and type SmugMug records.
30#[derive(Debug, Clone)]
31pub struct UploadPayload {
32    /// Shared, so retries don't copy the file.
33    pub data: bytes::Bytes,
34    pub file_name: String,
35    pub mime_type: String,
36}
37
38impl UploadPayload {
39    /// Read the file at `file_path`, named and typed after the path.
40    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
66/// Replace the file content of an existing image, identified by its image URI
67/// (e.g. `/api/v2/image/<key>`). This uploads new bytes onto the existing
68/// image record, keeping its album placement, keywords, and other metadata,
69/// instead of creating a new image (which SmugMug rejects with 409 if an
70/// image with the same filename already exists in the album).
71pub 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
79/// Upload endpoint for SmugMug's Library ("All Media"): media uploaded here
80/// isn't placed in any album/gallery. Not in SmugMug's public API docs;
81/// discovered from the web uploader. It takes a multipart form (`Media`,
82/// `ByteCount`, `Sha256Sum`, optional `Filepath`/`Title`/`Caption`/
83/// `Keywords`) and answers 201 with the standard v2 envelope
84/// (`{"Response":{"Image":{...}},"Code":201}`).
85pub 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/// Upload a file to the Library (no album). `filepath` is sent as the
108/// `Filepath` field, which SmugMug describes as "the relative path to the
109/// photo or video itself on the uploader's file system".
110#[allow(dead_code)] // wired into the uploader once Library uploads are the default
111pub 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
121/// Send the Library upload request and return the HTTP status and raw body,
122/// without interpreting either.
123pub 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    // Multipart fields aren't part of the OAuth1 signature base string, so
157    // the plain POST signature for the URL is sufficient.
158    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    // 1. Calculate MD5 checksum (base64-encoded)
201    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    // 2. Build OAuth header for upload endpoint
207    let upload_url = "https://upload.smugmug.com/";
208    let oauth_header = client.build_oauth_header("POST", upload_url);
209
210    // 3. Build headers
211    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    // 4. Send POST request with file data as body
224    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    // 5. Parse response
241    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    // Extract image key from URI (format: /api/v2/album/<key>/image/<key>-0)
248    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        // Create a temporary test file
322        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        // In a properly architected version, we'd inject the upload URL and test the actual call
354    }
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        // Create a temporary test file with known content
425        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        // Read file and calculate MD5
433        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        // Verify MD5 is not empty
442        assert!(!md5_base64.is_empty());
443    }
444
445    #[test]
446    fn test_mime_type_detection() {
447        // Test various file extensions
448        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        // Test that unknown extensions have a default MIME type
457        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        // If we were to try to parse this as UploadResponse, it would fail
471        // because the Image field is missing. This tests that error handling works.
472        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    /// Trimmed from a real 201 response of the web uploader's Library upload.
560    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        // Attempting to read a non-existent file should fail
585        let result = fs::read(non_existent_path).await;
586        assert!(result.is_err());
587    }
588}