Skip to content

Commit 91ad14d

Browse files
authored
Presigned URLs support, both GET and PUT, closes #54 (#94)
* Bring presigned URLs back * More docs, presigned_put
1 parent 40c0133 commit 91ad14d

File tree

8 files changed

+278
-87
lines changed

8 files changed

+278
-87
lines changed

README.md

+44-12
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,22 @@
99
Rust library for working with Amazon S3 or arbitrary S3 compatible APIs, fully compatible with **async/await** and `futures ^0.3`
1010

1111
### Intro
12+
1213
Modest interface towards Amazon S3, as well as S3 compatible object storage APIs such as Wasabi, Yandex or Minio.
13-
Supports `put`, `get`, `list`, `delete`, operations on `tags` and `location`.
14+
Supports `put`, `get`, `list`, `delete`, operations on `tags` and `location`.
15+
16+
Additionally a dedicated `presign_get` `Bucket` method is available. This means you can upload to s3, and give the link to select people without having to worry about publicly accessible files on S3. This also means that you can give people
17+
a `PUT` presigned URL, meaning they can upload to a specific key in S3 for the duration of the presigned URL.
1418

1519
**[AWS, Yandex and Custom (Minio) Example](https://github.com/durch/rust-s3/blob/master/s3/bin/simple_crud.rs)**
1620

21+
#### Presign
22+
23+
| | |
24+
| ----- | ---------------------------------------------------------------------------------------------- |
25+
| `PUT` | [presign_put](https://durch.github.io/rust-s3/s3/bucket/struct.Bucket.html#method.presign_put) |
26+
| `GET` | [presign_get](https://durch.github.io/rust-s3/s3/bucket/struct.Bucket.html#method.presign_get) |
27+
1728
#### GET
1829

1930
There are a few different options for getting an object. `async` and `sync` methods are generic over `std::io::Write`,
@@ -29,7 +40,7 @@ while `tokio` methods are generic over `tokio::io::AsyncWriteExt`.
2940

3041
#### PUT
3142

32-
Each `GET` method has a put companion `sync` and `async` methods are generic over `std::io::Read`,
43+
Each `GET` method has a `PUT` companion `sync` and `async` methods are generic over `std::io::Read`,
3344
while `tokio` methods are generic over `tokio::io::AsyncReadExt`.
3445

3546
| | |
@@ -40,39 +51,60 @@ while `tokio` methods are generic over `tokio::io::AsyncReadExt`.
4051
| `sync` | [put_object_stream_blocking](https://durch.github.io/rust-s3/s3/bucket/struct.Bucket.html#method.put_object_stream_blocking) |
4152
| `tokio` | [tokio_put_object_stream](https://durch.github.io/rust-s3/s3/bucket/struct.Bucket.html#method.tokio_put_object_stream) |
4253

43-
### What else is cool? -> Broken and tracked at [#54](https://github.com/durch/rust-s3/issues/54)
54+
#### List
55+
56+
| | |
57+
| ------- | ---------------------------------------------------------------------------------------------------------- |
58+
| `async` | [list](https://durch.github.io/rust-s3/s3/bucket/struct.Bucket.html#method.list) |
59+
| `sync` | [list_blocking](https://durch.github.io/rust-s3/s3/bucket/struct.Bucket.html#method.list_blocking) |
4460

45-
The main cool feature is that `put` commands return a presigned link to the file you uploaded
46-
This means you can upload to s3, and give the link to select people without having to worry about publicly accessible files on S3.
61+
#### DELETE
4762

48-
### Configuration
63+
| | |
64+
| ------- | -------------------------------------------------------------------------------------------------------------------- |
65+
| `async` | [delete_object](https://durch.github.io/rust-s3/s3/bucket/struct.Bucket.html#method.delete_object) |
66+
| `sync` | [delete_object_blocking](https://durch.github.io/rust-s3/s3/bucket/struct.Bucket.html#method.delete_object_blocking) |
4967

50-
Getter and setter functions exist for all `Link` params... You don't really have to touch anything there, maybe `amz-expire`,
51-
it is configured for one week which is the maximum Amazon allows ATM.
68+
#### Location
69+
70+
| | |
71+
| ------- | ---------------------------------------------------------------------------------------------------------- |
72+
| `async` | [location](https://durch.github.io/rust-s3/s3/bucket/struct.Bucket.html#method.location) |
73+
| `sync` | [location_blocking](https://durch.github.io/rust-s3/s3/bucket/struct.Bucket.html#method.location_blocking) |
74+
75+
#### Tagging
76+
77+
| | |
78+
| ------- | ------------------------------------------------------------------------------------------------------------------------------ |
79+
| `async` | [put_object_tagging](https://durch.github.io/rust-s3/s3/bucket/struct.Bucket.html#method.put_object_tagging) |
80+
| `sync` | [put_object_tagging_blocking](https://durch.github.io/rust-s3/s3/bucket/struct.Bucket.html#method.put_object_tagging_blocking) |
81+
| `async` | [get_object_tagging](https://durch.github.io/rust-s3/s3/bucket/struct.Bucket.html#method.get_object_tagging) |
82+
| `sync` | [get_object_tagging_blocking](https://durch.github.io/rust-s3/s3/bucket/struct.Bucket.html#method.get_object_tagging_blocking) |
5283

5384
### Usage (in `Cargo.toml`)
5485

5586
```toml
5687
[dependencies]
57-
rust-s3 = "0.22.3"
88+
rust-s3 = "0.22.8"
5889
```
5990

6091
#### Disable SSL verification for endpoints, useful for custom regions
6192

6293
```toml
6394
[dependencies]
64-
rust-s3 = {version = "0.22.3", features = ["no-verify-ssl"]}
95+
rust-s3 = {version = "0.22.8", features = ["no-verify-ssl"]}
6596
```
6697

6798
#### Fail on HTTP error responses
6899

69100
```toml
70101
[dependencies]
71-
rust-s3 = {version = "0.22.3", features = ["fail-on-err"]}
102+
rust-s3 = {version = "0.22.8", features = ["fail-on-err"]}
72103
```
73104

74105
#### Use path style addressing, needed for Minio compatibility
106+
75107
```toml
76108
[dependencies]
77-
rust-s3 = {version = "0.22.3", features = ["path-style"]}
109+
rust-s3 = {version = "0.22.8", features = ["path-style"]}
78110
```

s3/Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rust-s3"
3-
version = "0.22.7"
3+
version = "0.22.8"
44
authors = ["Drazen Urch", "Nick Stevens"]
55
description = "Tiny Rust library for working with Amazon S3 and compatible object storage APIs"
66
repository = "https://github.com/durch/rust-s3"

s3/bin/simple_crud.rs

+1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ pub fn main() -> Result<(), S3Error> {
7676
// Put a "test_file" with the contents of MESSAGE at the root of the
7777
// bucket.
7878
let (_, code) = bucket.put_object_blocking("test_file", MESSAGE.as_bytes(), "text/plain")?;
79+
// println!("{}", bucket.presign_get("test_file", 604801)?);
7980
assert_eq!(200, code);
8081

8182
// Get the "test_file" contents and make sure that the returned message

s3/src/bucket.rs

+53-4
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,56 @@ pub struct Bucket {
3737
pub extra_query: Query,
3838
}
3939

40+
fn validate_expiry(expiry_secs: u32) -> Result<()> {
41+
if 604800 < expiry_secs {
42+
return Err(S3Error::from(format!("Max expiration for presigned URLs is one week, or 604.800 seconds, got {} instead", expiry_secs).as_ref()));
43+
}
44+
Ok(())
45+
}
46+
4047
impl Bucket {
48+
/// Get a presigned url for getting object on a given path
49+
///
50+
/// # Example:
51+
///
52+
/// ```rust,no_run
53+
/// use s3::bucket::Bucket;
54+
/// use awscreds::Credentials;
55+
///
56+
/// let bucket_name = "rust-s3-test";
57+
/// let region = "us-east-1".parse().unwrap();
58+
/// let credentials = Credentials::default_blocking().unwrap();
59+
/// let bucket = Bucket::new(bucket_name, region, credentials).unwrap();
60+
///
61+
/// let url = bucket.presign_get("/test.file", 86400).unwrap();
62+
/// println!("Presigned url: {}", url);
63+
/// ```
64+
pub fn presign_get<S: AsRef<str>>(&self, path: S, expiry_secs: u32) -> Result<String> {
65+
validate_expiry(expiry_secs)?;
66+
let request = Request::new(self, path.as_ref(), Command::PresignGet { expiry_secs });
67+
Ok(request.presigned()?)
68+
}
69+
/// Get a presigned url for putting object to a given path
70+
///
71+
/// # Example:
72+
///
73+
/// ```rust,no_run
74+
/// use s3::bucket::Bucket;
75+
/// use awscreds::Credentials;
76+
///
77+
/// let bucket_name = "rust-s3-test";
78+
/// let region = "us-east-1".parse().unwrap();
79+
/// let credentials = Credentials::default_blocking().unwrap();
80+
/// let bucket = Bucket::new(bucket_name, region, credentials).unwrap();
81+
///
82+
/// let url = bucket.presign_put("/test.file", 86400).unwrap();
83+
/// println!("Presigned url: {}", url);
84+
/// ```
85+
pub fn presign_put<S: AsRef<str>>(&self, path: S, expiry_secs: u32) -> Result<String> {
86+
validate_expiry(expiry_secs)?;
87+
let request = Request::new(self, path.as_ref(), Command::PresignPut { expiry_secs });
88+
Ok(request.presigned()?)
89+
}
4190
/// Instantiate a new `Bucket`.
4291
///
4392
/// # Example
@@ -183,7 +232,7 @@ impl Bucket {
183232
Ok(request.response_data_to_writer_future(writer).await?)
184233
}
185234

186-
/// Stream file from S3 path to a local file, generic over T: Write, async.
235+
/// Stream file from S3 path to a local file, generic over T: Write, async.
187236
///
188237
/// # Example:
189238
///
@@ -815,7 +864,7 @@ impl Bucket {
815864
loop {
816865
results.push(result.clone());
817866
if !result.0.is_truncated {
818-
break
867+
break;
819868
}
820869
match result.0.next_continuation_token {
821870
Some(token) => {
@@ -907,7 +956,7 @@ impl Bucket {
907956
/// Get a reference to the AWS access key.
908957
pub fn access_key(&self) -> Option<String> {
909958
if let Some(access_key) = self.credentials.access_key.clone() {
910-
Some(access_key.replace('\n',""))
959+
Some(access_key.replace('\n', ""))
911960
} else {
912961
None
913962
}
@@ -916,7 +965,7 @@ impl Bucket {
916965
/// Get a reference to the AWS secret key.
917966
pub fn secret_key(&self) -> Option<String> {
918967
if let Some(secret_key) = self.credentials.secret_key.clone() {
919-
Some(secret_key.replace('\n',""))
968+
Some(secret_key.replace('\n', ""))
920969
} else {
921970
None
922971
}

s3/src/command.rs

+10-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use reqwest::Method;
22

3-
#[derive(Clone)]
3+
#[derive(Clone, Debug)]
44
pub enum Command<'a> {
55
DeleteObject,
66
DeleteObjectTagging,
@@ -19,14 +19,20 @@ pub enum Command<'a> {
1919
delimiter: Option<String>,
2020
continuation_token: Option<String>
2121
},
22-
GetBucketLocation
22+
GetBucketLocation,
23+
PresignGet {
24+
expiry_secs: u32
25+
},
26+
PresignPut {
27+
expiry_secs: u32
28+
}
2329
}
2430

2531
impl<'a> Command<'a> {
2632
pub fn http_verb(&self) -> Method {
2733
match *self {
28-
Command::GetObject | Command::ListBucket { .. } | Command::GetBucketLocation | Command::GetObjectTagging => Method::GET,
29-
Command::PutObject { .. } | Command::PutObjectTagging { .. } => Method::PUT,
34+
Command::GetObject | Command::ListBucket { .. } | Command::GetBucketLocation | Command::GetObjectTagging | Command::PresignGet { .. } => Method::GET,
35+
Command::PutObject { .. } | Command::PutObjectTagging { .. } | Command::PresignPut { .. } => Method::PUT,
3036
Command::DeleteObject | Command::DeleteObjectTagging => Method::DELETE,
3137
}
3238
}

s3/src/lib.rs

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ simpl::err!(S3Error, {
2727
Io@std::io::Error;
2828
Region@awsregion::AwsRegionError;
2929
Creds@awscreds::AwsCredsError;
30+
UrlParse@url::ParseError;
3031
});
3132

3233
const LONG_DATE: &str = "%Y%m%dT%H%M%SZ";

s3/src/request.rs

+82-20
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ impl<'a> Request<'a> {
6262
}
6363
}
6464

65+
pub fn presigned(&self) -> Result<String> {
66+
let expiry = match self.command {
67+
Command::PresignGet { expiry_secs} => expiry_secs,
68+
Command::PresignPut { expiry_secs} => expiry_secs,
69+
_ => unreachable!()
70+
};
71+
let authorization = self.presigned_authorization()?;
72+
Ok(format!("{}&X-Amz-Signature={}", self.presigned_url_no_sig(expiry)?, authorization))
73+
}
74+
6575
fn url(&self) -> Url {
6676
let mut url_str = if cfg!(feature = "path-style") {
6777
format!(
@@ -159,6 +169,33 @@ impl<'a> Request<'a> {
159169
)
160170
}
161171

172+
fn presigned_url_no_sig(&self, expiry: u32) -> Result<Url> {
173+
Ok(Url::parse(&format!(
174+
"{}{}",
175+
self.url(),
176+
signing::authorization_query_params_no_sig(
177+
&self.bucket.access_key().unwrap(),
178+
&self.datetime,
179+
&self.bucket.region(),
180+
expiry
181+
)
182+
))?)
183+
}
184+
185+
fn presigned_canonical_request(&self, headers: &HeaderMap) -> Result<String> {
186+
let expiry = match self.command {
187+
Command::PresignGet { expiry_secs } => expiry_secs,
188+
_ => unreachable!()
189+
};
190+
let canonical_request = signing::canonical_request(
191+
self.command.http_verb().as_str(),
192+
&self.presigned_url_no_sig(expiry)?,
193+
headers,
194+
"UNSIGNED-PAYLOAD",
195+
);
196+
Ok(canonical_request)
197+
}
198+
162199
fn string_to_sign(&self, request: &str) -> String {
163200
signing::string_to_sign(&self.datetime, &self.bucket.region(), request)
164201
}
@@ -175,6 +212,21 @@ impl<'a> Request<'a> {
175212
)?)
176213
}
177214

215+
fn presigned_authorization(&self) -> Result<String> {
216+
let mut headers = HeaderMap::new();
217+
headers.insert(
218+
header::HOST,
219+
HeaderValue::from_str(&self.bucket.self_host()).unwrap(),
220+
);
221+
let canonical_request = self.presigned_canonical_request(&headers)?;
222+
let string_to_sign = self.string_to_sign(&canonical_request);
223+
let mut hmac = signing::HmacSha256::new_varkey(&self.signing_key()?)?;
224+
hmac.input(string_to_sign.as_bytes());
225+
let signature = hex::encode(hmac.result().code());
226+
// let signed_header = signing::signed_header_string(&headers);
227+
Ok(signature)
228+
}
229+
178230
fn authorization(&self, headers: &HeaderMap) -> Result<String> {
179231
let canonical_request = self.canonical_request(headers);
180232
let string_to_sign = self.string_to_sign(&canonical_request);
@@ -364,16 +416,21 @@ impl<'a> Request<'a> {
364416
// This must be last, as it signs the other headers, omitted if no secret key is provided
365417
if self.bucket.secret_key().is_some() {
366418
let authorization = self.authorization(&headers)?;
367-
headers.insert(header::AUTHORIZATION, match authorization.parse() {
368-
Ok(authorization) => authorization,
369-
Err(_) => return Err(S3Error::from(
370-
format!(
371-
"Could not parse AUTHORIZATION header value {}",
372-
authorization
373-
)
374-
.as_ref(),
375-
))
376-
});
419+
headers.insert(
420+
header::AUTHORIZATION,
421+
match authorization.parse() {
422+
Ok(authorization) => authorization,
423+
Err(_) => {
424+
return Err(S3Error::from(
425+
format!(
426+
"Could not parse AUTHORIZATION header value {}",
427+
authorization
428+
)
429+
.as_ref(),
430+
))
431+
}
432+
},
433+
);
377434
}
378435

379436
// The format of RFC2822 is somewhat malleable, so including it in
@@ -382,16 +439,21 @@ impl<'a> Request<'a> {
382439
// range and can't be used again e.g. reply attacks. Adding this header
383440
// after the generation of the Authorization header leaves it out of
384441
// the signed headers.
385-
headers.insert(header::DATE, match self.datetime.to_rfc2822().parse() {
386-
Ok(date) => date,
387-
Err(_) => return Err(S3Error::from(
388-
format!(
389-
"Could not parse DATE header value {}",
390-
self.datetime.to_rfc2822()
391-
)
392-
.as_ref(),
393-
))
394-
});
442+
headers.insert(
443+
header::DATE,
444+
match self.datetime.to_rfc2822().parse() {
445+
Ok(date) => date,
446+
Err(_) => {
447+
return Err(S3Error::from(
448+
format!(
449+
"Could not parse DATE header value {}",
450+
self.datetime.to_rfc2822()
451+
)
452+
.as_ref(),
453+
))
454+
}
455+
},
456+
);
395457

396458
Ok(headers)
397459
}

0 commit comments

Comments
 (0)