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 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
217/// SmugMug's upload endpoint.
218const UPLOAD_URL: &str = "https://upload.smugmug.com/";
219
220/// Who an upload goes to: a new image in an album, or new content for an
221/// existing image.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum UploadTarget<'a> {
224    Album(&'a str),
225    ReplaceImage(&'a str),
226}
227
228/// A file uploaded straight from disk, without holding it in memory.
229#[derive(Debug, Clone)]
230pub struct FileUpload<'a> {
231    pub path: &'a Path,
232    /// Size of the file, as sent in Content-Length.
233    pub size: u64,
234    /// Hex MD5 of the file, computed when it was read.
235    pub md5_hex: &'a str,
236    /// Name SmugMug records (usually the file's own).
237    pub file_name: &'a str,
238}
239
240/// Upload a file from disk, streaming its contents.
241pub 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
278/// Upload bytes held in memory (a JPEG rendered from a RAW file).
279pub 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
314/// A header value for a file name. `HeaderValue::from_str` takes ASCII
315/// only, which would fail every upload of a file named with accents or
316/// emoji; the UTF-8 bytes are sent as they are instead.
317fn header_text(text: &str) -> Result<HeaderValue> {
318    Ok(HeaderValue::from_bytes(text.as_bytes())?)
319}
320
321/// SmugMug refused an upload with an HTTP error.
322#[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    /// Refused for the file itself (too big, unsupported), so trying again
331    /// won't help until the file changes. Not auth, rate-limit or
332    /// conflict errors, which are about the account or the request.
333    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    // Extract image key from URI (format: /api/v2/album/<key>/image/<key>-0)
358    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        // Create a temporary test file
432        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        // In a properly architected version, we'd inject the upload URL and test the actual call
464    }
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        // Create a temporary test file with known content
535        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        // Read file and calculate MD5
543        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        // Verify MD5 is not empty
552        assert!(!md5_base64.is_empty());
553    }
554
555    #[test]
556    fn test_mime_type_detection() {
557        // Test various file extensions
558        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        // Test that unknown extensions have a default MIME type
567        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        // If we were to try to parse this as UploadResponse, it would fail
581        // because the Image field is missing. This tests that error handling works.
582        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    /// Trimmed from a real 201 response of the web uploader's Library upload.
670    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        // Attempting to read a non-existent file should fail
695        let result = fs::read(non_existent_path).await;
696        assert!(result.is_err());
697    }
698}