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; type 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 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 {"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 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 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 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}