presigned_post_rs/
ppo.rs

1use std::collections::HashMap;
2
3use base64::Engine;
4use hmac::digest::InvalidLength;
5use hmac::{Hmac, Mac};
6use serde::{Deserialize, Serialize};
7use sha2::Sha256;
8use time::macros::format_description;
9use time::{Duration, OffsetDateTime};
10
11use crate::impl_debug;
12
13const MEGABYTE_BYTES: u64 = 1_000_000;
14const MAX_DEFAULT_SIZE_BYTES: u64 = MEGABYTE_BYTES * 1000; // 1 GB
15
16type HmacSha256 = Hmac<Sha256>;
17
18#[derive(thiserror::Error)]
19pub enum Error {
20    #[error("Date parsing error: {0}")]
21    DateParsingError(#[from] time::error::Format),
22    #[error("HMAC signing error: {0}")]
23    HmacError(#[from] InvalidLength),
24    #[error("JSON serialization error: {0}")]
25    JsonSerError(#[from] serde_json::Error),
26}
27
28impl_debug!(Error);
29
30#[cfg_attr(feature = "utoipa",
31    derive(utoipa::ToSchema, utoipa::ToResponse),
32    response(
33        description = "Presigned post object data",
34        content_type = "application/json",
35        example = json!(
36            {
37                "url": "http://ghashy.garage:9000/mustore-data",
38                "fields": {
39                "policy": "... long policy ...",
40                "key": "abc123-d3090bb8-493b-4837-80fc-cc2deeae3705-image.png",
41                "Content-Disposition": "attachment; filename=\"abc123-d3090bb8-493b-4837-80fc-cc2deeae3705-image.png\"",
42                "acl": "private",
43                "success_action_status": "200",
44                "X-Amz-Date": "20240121T211735Z",
45                "Content-Type": "image/png",
46                "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
47                "X-Amz-Signature": "4b08ff30d6f18e95ebe6b797831304c8ab750d7cb98875daeebc2005fb205312",
48                "X-Amz-Credential": "garage/20240121/ru-central1/s3/aws4_request"
49                }
50            }
51        )
52    ),
53)]
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct PresignedPostData {
56    pub url: String,
57    pub fields: HashMap<String, String>,
58}
59
60impl PresignedPostData {
61    pub fn builder<'a>(
62        access_key: &'a str,
63        access_key_id: &'a str,
64        obj_storage_endpoint: &'a str,
65        region_name: &'a str,
66        bucket: &'a str,
67        object_key: &'a str,
68    ) -> PresignedPostDataBuilder<'a> {
69        PresignedPostDataBuilder {
70            access_key,
71            access_key_id,
72            obj_storage_endpoint,
73            bucket,
74            object_key,
75            region_name,
76            date: None,
77            content_disposition: None,
78            expiration: None,
79            mime: None,
80            service_name: None,
81            min_obj_length: None,
82            max_obj_length: None,
83        }
84    }
85}
86
87pub struct PresignedPostDataBuilder<'a> {
88    access_key: &'a str,
89    access_key_id: &'a str,
90    obj_storage_endpoint: &'a str,
91    bucket: &'a str,
92    object_key: &'a str,
93    region_name: &'a str,
94    date: Option<OffsetDateTime>,
95    content_disposition: Option<&'a str>,
96    expiration: Option<Duration>,
97    mime: Option<mediatype::MediaType<'a>>,
98    service_name: Option<&'a str>,
99    min_obj_length: Option<u64>,
100    max_obj_length: Option<u64>,
101}
102
103impl<'a> PresignedPostDataBuilder<'a> {
104    pub fn with_date(self, date: OffsetDateTime) -> Self {
105        Self {
106            date: Some(date),
107            ..self
108        }
109    }
110
111    pub fn with_content_disposition(
112        self,
113        content_disposition: &'a str,
114    ) -> Self {
115        Self {
116            content_disposition: Some(content_disposition),
117            ..self
118        }
119    }
120
121    pub fn with_expiration(self, expiration: Duration) -> Self {
122        Self {
123            expiration: Some(expiration),
124            ..self
125        }
126    }
127
128    pub fn with_mime(self, mime: mediatype::MediaType<'a>) -> Self {
129        Self {
130            mime: Some(mime),
131            ..self
132        }
133    }
134
135    pub fn with_service_name(self, service_name: &'a str) -> Self {
136        Self {
137            service_name: Some(service_name),
138            ..self
139        }
140    }
141
142    pub fn with_content_length_range(self, min: u64, max: u64) -> Self {
143        Self {
144            min_obj_length: Some(min),
145            max_obj_length: Some(max),
146            ..self
147        }
148    }
149
150    pub fn build(self) -> Result<PresignedPostData, Error> {
151        let date = self.date.unwrap_or(OffsetDateTime::now_utc());
152        let expiration = date + self.expiration.unwrap_or(Duration::MINUTE);
153        let service_name = self.service_name.unwrap_or("s3");
154        let mime = self
155            .mime
156            .unwrap_or(mediatype::media_type!(APPLICATION / OCTET_STREAM));
157        let default_disposition =
158            format!("attachment; filename=\"{}\"", self.object_key);
159        let content_disposition =
160            self.content_disposition.unwrap_or(&default_disposition);
161        let yyyymmdd_date = get_date_yyyymmdd(date)?;
162        let iso8601_date = get_date_iso8601(date)?;
163        let x_amz_credential = format!(
164            "{}/{}/{}/{}/aws4_request",
165            self.access_key_id, yyyymmdd_date, self.region_name, service_name
166        );
167
168        let policy = Self::create_policy_document(
169            self.bucket,
170            self.object_key,
171            &mime,
172            expiration,
173            content_disposition,
174            &x_amz_credential,
175            &iso8601_date,
176            self.min_obj_length.unwrap_or(0),
177            self.max_obj_length.unwrap_or(MAX_DEFAULT_SIZE_BYTES),
178        )?;
179
180        let signing_key = get_signing_key(
181            self.access_key,
182            &yyyymmdd_date,
183            self.region_name,
184            service_name,
185        )?;
186
187        let policy_signature = get_policy_signature(&signing_key, &policy)?;
188
189        let mut map: HashMap<String, String> = HashMap::new();
190        map.insert("X-Amz-Algorithm".into(), "AWS4-HMAC-SHA256".into());
191        map.insert("X-Amz-Date".into(), iso8601_date.into());
192        map.insert("success_action_status".into(), "200".into());
193        map.insert("X-Amz-Signature".into(), policy_signature.into());
194        map.insert("key".into(), self.object_key.into());
195        map.insert("bucket".into(), self.bucket.to_owned());
196        // NOTE: Garage doesn't support S3 ACL access control mechanisms, uses its own system instead, built around a per-access-key-per-bucket logic
197        // map.insert("acl".into(), "private".into());
198        map.insert("policy".into(), policy.into());
199        map.insert("X-Amz-Credential".into(), x_amz_credential.into());
200        map.insert("Content-Type".into(), mime.to_string());
201        map.insert(
202            "Content-Disposition".into(),
203            content_disposition.to_string(),
204        );
205
206        Ok(PresignedPostData {
207            url: format!("{}/{}", self.obj_storage_endpoint, self.bucket),
208            fields: map,
209        })
210    }
211
212    #[allow(clippy::too_many_arguments)]
213    fn create_policy_document(
214        bucket: &str,
215        object_key: &str,
216        mime: &mediatype::MediaType<'_>,
217        expiration: OffsetDateTime,
218        content_disposition: &str,
219        x_amz_credential: &str,
220        iso8601_date: &str,
221        min: u64,
222        max: u64,
223    ) -> Result<String, Error> {
224        let policy = serde_json::json!({
225            "expiration": expiration.format(&time::format_description::well_known::Rfc3339).map_err(Error::DateParsingError)?,
226            "conditions": [
227                {"X-Amz-Algorithm": "AWS4-HMAC-SHA256"},
228                {"X-Amz-Date": iso8601_date},
229                {"X-Amz-Credential": x_amz_credential},
230                {"bucket": bucket},
231                {"key": object_key},
232                // NOTE: Garage doesn't support S3 ACL access control mechanisms, uses its own system instead, built around a per-access-key-per-bucket logic
233                // {"acl": "private"},
234                {"success_action_status": "200"},
235                {"Content-Disposition": content_disposition},
236                ["starts-with", "$Content-Type", mime.ty.as_ref()],
237                ["content-length-range", min, max]
238            ]
239        });
240        let policy =
241            serde_json::to_string(&policy).map_err(Error::JsonSerError)?;
242        // Base64 encode the policy document
243        Ok(base64::engine::general_purpose::STANDARD.encode(policy))
244    }
245}
246
247fn get_date_yyyymmdd(date: OffsetDateTime) -> Result<String, Error> {
248    let yyyymmdd_format = format_description!("[year][month][day]");
249    let yyyymmdd_date = date
250        .format(&yyyymmdd_format)
251        .map_err(Error::DateParsingError)?;
252    Ok(yyyymmdd_date)
253}
254
255fn get_date_iso8601(date: OffsetDateTime) -> Result<String, Error> {
256    let iso8601_format =
257        format_description!("[year][month][day]T[hour][minute][second]Z");
258    let iso8601_date = date
259        .format(&iso8601_format)
260        .map_err(Error::DateParsingError)?;
261    Ok(iso8601_date)
262}
263
264fn sign(key: &[u8], msg: &[u8]) -> Result<Vec<u8>, Error> {
265    let mut mac = HmacSha256::new_from_slice(key).map_err(Error::HmacError)?;
266    mac.update(msg);
267    Ok(mac.finalize().into_bytes().to_vec())
268}
269
270fn get_signing_key(
271    access_key: &str,
272    date: &str,
273    region_name: &str,
274    service_name: &str,
275) -> Result<Vec<u8>, Error> {
276    let k_date =
277        sign(format!("AWS4{}", access_key).as_bytes(), date.as_bytes())?;
278    let k_region = sign(&k_date, region_name.as_bytes())?;
279    let k_service = sign(&k_region, service_name.as_bytes())?;
280    sign(&k_service, b"aws4_request")
281}
282
283fn get_policy_signature(
284    signing_key: &[u8],
285    policy_document_base64: &str,
286) -> Result<String, Error> {
287    // Sign the policy document
288    let signature = sign(signing_key, policy_document_base64.as_bytes())?;
289    Ok(hex::encode(signature))
290}
291
292#[cfg(test)]
293mod tests {
294    use time::OffsetDateTime;
295
296    use crate::ppo::MEGABYTE_BYTES;
297
298    use super::PresignedPostData;
299
300    #[test]
301    fn data_form_correct_from_template() {
302        let key_id = "test_key_id";
303        let access_key = "test_access_id";
304
305        let presigned_post = PresignedPostData::builder(
306            access_key,
307            key_id,
308            "https://storage.yandexcloud.net",
309            "ru-central1",
310            "test-data",
311            "image.png",
312        )
313        .with_mime(mediatype::media_type!(IMAGE / PNG))
314        .with_date(OffsetDateTime::UNIX_EPOCH)
315        .with_expiration(time::Duration::minutes(10))
316        .with_content_length_range(0, 5 * MEGABYTE_BYTES)
317        .build()
318        .expect("Failed to build presigned post form");
319
320        assert_eq!(
321            presigned_post.url,
322            "https://storage.yandexcloud.net/test-data"
323        );
324        assert_eq!(
325            presigned_post
326                .fields
327                .get("X-Amz-Algorithm")
328                .map(|alg| alg.as_str()),
329            Some("AWS4-HMAC-SHA256")
330        );
331        assert_eq!(
332            presigned_post
333                .fields
334                .get("X-Amz-Credential")
335                .map(|cred| cred.as_str()),
336            Some("test_key_id/19700101/ru-central1/s3/aws4_request")
337        );
338        assert_eq!(
339            presigned_post
340                .fields
341                .get("success_action_status")
342                .map(|s| s.as_str()),
343            Some("200")
344        );
345        assert_eq!(
346            presigned_post
347                .fields
348                .get("X-Amz-Date")
349                .map(|date| date.as_str()),
350            Some("19700101T000000Z")
351        );
352        assert_eq!(
353            presigned_post.fields.get("key").map(|key| key.as_str()),
354            Some("image.png")
355        );
356        assert_eq!(
357            presigned_post
358                .fields
359                .get("Content-Type")
360                .map(|t| t.as_str()),
361            Some("image/png")
362        );
363        // NOTE: Garage doesn't support S3 ACL access control mechanisms, uses its own system instead, built around a per-access-key-per-bucket logic
364        // assert_eq!(
365        //     presigned_post.fields.get("acl").map(|acl| acl.as_str()),
366        //     Some("private")
367        // );
368        assert_eq!(
369            presigned_post.fields.get("policy").map(|p| p.as_str()),
370            Some("eyJjb25kaXRpb25zIjpbeyJYLUFtei1BbGdvcml0aG0iOiJBV1M0LUhNQUMtU0hBMjU2In0seyJYLUFtei1EYXRlIjoiMTk3MDAxMDFUMDAwMDAwWiJ9LHsiWC1BbXotQ3JlZGVudGlhbCI6InRlc3Rfa2V5X2lkLzE5NzAwMTAxL3J1LWNlbnRyYWwxL3MzL2F3czRfcmVxdWVzdCJ9LHsiYnVja2V0IjoidGVzdC1kYXRhIn0seyJrZXkiOiJpbWFnZS5wbmcifSx7InN1Y2Nlc3NfYWN0aW9uX3N0YXR1cyI6IjIwMCJ9LHsiQ29udGVudC1EaXNwb3NpdGlvbiI6ImF0dGFjaG1lbnQ7IGZpbGVuYW1lPVwiaW1hZ2UucG5nXCIifSxbInN0YXJ0cy13aXRoIiwiJENvbnRlbnQtVHlwZSIsImltYWdlIl0sWyJjb250ZW50LWxlbmd0aC1yYW5nZSIsMCw1MDAwMDAwMDAwMDAwXV0sImV4cGlyYXRpb24iOiIxOTcwLTAxLTAxVDAwOjEwOjAwWiJ9")
371        );
372        assert_eq!(
373            presigned_post.fields.get("X-Amz-Signature").map(|s| s.as_str()),
374            Some("731a9fb7beb797517bc6336c4a956e06daeb6dc6c1ddee3e64909e01212b0dba")
375        );
376        assert_eq!(
377            presigned_post
378                .fields
379                .get("Content-Disposition")
380                .map(|s| s.as_str()),
381            Some("attachment; filename=\"image.png\"")
382        );
383    }
384}