├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── conditions.go
├── examples
└── form.go
├── s3pp.go
└── s3pp_test.go
/.gitignore:
--------------------------------------------------------------------------------
1 | # Compiled Object files, Static and Dynamic libs (Shared Objects)
2 | *.o
3 | *.a
4 | *.so
5 |
6 | # Folders
7 | _obj
8 | _test
9 |
10 | # Architecture specific extensions/prefixes
11 | *.[568vq]
12 | [568vq].out
13 |
14 | *.cgo1.go
15 | *.cgo2.c
16 | _cgo_defun.c
17 | _cgo_gotypes.go
18 | _cgo_export.*
19 |
20 | _testmain.go
21 |
22 | *.exe
23 | *.test
24 | *.prof
25 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: go
2 | go:
3 | - 1.11.x
4 | - 1.12.x
5 | - 1.13.x
6 | go_import_path: github.com/teamwork/s3pp
7 | notifications:
8 | email: false
9 | install:
10 | script: |
11 | go test -v ./...
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Teamwork.com
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://travis-ci.com/Teamwork/s3pp)
2 | # s3pp
3 |
4 | A package to help you create [POST policies](http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html) to upload files directly to Amazon S3, see the [AWS docs](http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-UsingHTTPPOST.html) for the all the available conditions and values.
5 |
6 | ## Example
7 |
8 | A working example is available in [examples/form.go](examples/form.go), below are the relevant parts:
9 | ```go
10 | form, err := s3pp.New(s3pp.Config{
11 | AWSCredentials: s3pp.AWSCredentials{
12 | AccessKeyID: "key",
13 | SecretAccessKey: "secret",
14 | },
15 | Bucket: "mybucket",
16 | Region: "us-east-1",
17 | Expires: 10 * time.Minute,
18 | Key: s3pp.Match("key", uuid.New()),
19 | Conditions: []s3pp.Condition{
20 | s3pp.Match("acl", "public-read"),
21 | s3pp.Match("success_action_status", "201"),
22 | s3pp.ContentLengthRange(1, 55000),
23 | },
24 | })
25 | ```
26 | `form.Fields` will contain all the fields generated from the conditions for the form and you can pass any additional conditions in `Conditions`. Available conditions are documented here: [Creating a POST Policy](http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html).
27 |
28 | The form:
29 | ```html
30 |
38 | ```
39 |
--------------------------------------------------------------------------------
/conditions.go:
--------------------------------------------------------------------------------
1 | package s3pp
2 |
3 | import "encoding/json"
4 |
5 | type Condition interface {
6 | Name() string
7 | Value() string
8 | }
9 |
10 | // Match returns a condition where the field must match value.
11 | // Fields created from this Condition return value from Value.
12 | func Match(field, value string) Condition {
13 | return matchCondition{field, value}
14 | }
15 |
16 | // StartsWith returns a condition where field must start with value.
17 | // Fields created from this Condition return value from Value.
18 | func StartsWith(field, value string) Condition {
19 | return startsWithCondition{field, value}
20 | }
21 |
22 | // Any returns a condition where field can have any content.
23 | // Fields created from this Condition return an empty string from Value.
24 | func Any(field string) Condition {
25 | return startsWithCondition{field, ""}
26 | }
27 |
28 | // ContentLengthRange specifies the minimum and maximum allowable size for the uploaded content.
29 | // This condition is excluded from Fields.
30 | func ContentLengthRange(min, max int64) Condition {
31 | return contentLengthRangeCondition{min, max}
32 | }
33 |
34 | type startsWithCondition struct {
35 | name, value string
36 | }
37 |
38 | func (c startsWithCondition) Name() string {
39 | return c.name
40 | }
41 |
42 | func (c startsWithCondition) Value() string {
43 | return c.value
44 | }
45 |
46 | func (c startsWithCondition) MarshalJSON() ([]byte, error) {
47 | return json.Marshal([]string{"starts-with", "$" + c.name, c.value})
48 | }
49 |
50 | type matchCondition struct {
51 | name, value string
52 | }
53 |
54 | func (c matchCondition) Name() string {
55 | return c.name
56 | }
57 |
58 | func (c matchCondition) Value() string {
59 | return c.value
60 | }
61 |
62 | func (c matchCondition) MarshalJSON() ([]byte, error) {
63 | return json.Marshal(map[string]string{c.name: c.value})
64 | }
65 |
66 | type contentLengthRangeCondition struct {
67 | min int64
68 | max int64
69 | }
70 |
71 | func (c contentLengthRangeCondition) MarshalJSON() ([]byte, error) {
72 | return json.Marshal([]interface{}{c.Name(), c.min, c.max})
73 | }
74 |
75 | func (c contentLengthRangeCondition) Name() string {
76 | return "content-length-range"
77 | }
78 |
79 | func (c contentLengthRangeCondition) Value() string {
80 | return ""
81 | }
82 |
--------------------------------------------------------------------------------
/examples/form.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "html/template"
5 | "net/http"
6 | "time"
7 |
8 | "github.com/pborman/uuid"
9 | "github.com/teamwork/s3pp"
10 | )
11 |
12 | const (
13 | accessKeyID = ""
14 | secretAccessKey = ""
15 | region = "us-east-1"
16 | bucket = "mybucket"
17 | )
18 |
19 | func main() {
20 | http.HandleFunc("/", handler)
21 | http.ListenAndServe(":8080", nil)
22 | }
23 |
24 | func handler(w http.ResponseWriter, r *http.Request) {
25 | t, err := template.New("form").Parse(templ)
26 | if err != nil {
27 | panic(err)
28 | }
29 |
30 | form, err := s3pp.New(s3pp.Config{
31 | AWSCredentials: s3pp.AWSCredentials{
32 | AccessKeyID: accessKeyID,
33 | SecretAccessKey: secretAccessKey,
34 | },
35 | Bucket: bucket,
36 | Region: region,
37 | Expires: 10 * time.Minute,
38 | Key: s3pp.Match("key", uuid.New()),
39 | Conditions: []s3pp.Condition{
40 | s3pp.Match("acl", "public-read"),
41 | s3pp.Match("success_action_status", "201"),
42 | s3pp.ContentLengthRange(1, 55000),
43 | },
44 | })
45 | if err != nil {
46 | panic(err)
47 | }
48 |
49 | t.Execute(w, form)
50 | }
51 |
52 | const templ = `
53 |
54 |
55 |
56 |
57 | Form Upload Example
58 |
59 |
60 |
61 |
69 |
70 |
71 | `
72 |
--------------------------------------------------------------------------------
/s3pp.go:
--------------------------------------------------------------------------------
1 | // Package s3pp creates POST policies for uploading files directly to Amazon S3.
2 | // See: http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html
3 | package s3pp
4 |
5 | import (
6 | "crypto/hmac"
7 | "crypto/sha256"
8 | "encoding/base64"
9 | "encoding/hex"
10 | "encoding/json"
11 | "fmt"
12 | "time"
13 | )
14 |
15 | type Config struct {
16 | AWSCredentials AWSCredentials
17 | Bucket string
18 | Region string
19 | Expires time.Duration
20 | Key Condition
21 | Conditions []Condition
22 | }
23 |
24 | type AWSCredentials struct {
25 | AccessKeyID string
26 | SecretAccessKey string
27 | }
28 |
29 | type Form struct {
30 | Action string `json:"action"`
31 |
32 | // name -> value, generated from the Name and Value of the provided Conditions
33 | Fields map[string]string `json:"fields"`
34 | }
35 |
36 | // New encodes and signs the POST Policy using AWS Signature v4 and returns the required
37 | // fields to create a form for the POST request.
38 | func New(c Config) (*Form, error) {
39 | date := time.Now().UTC()
40 | signing := signingKey(c.AWSCredentials.SecretAccessKey, date, c.Region, "s3")
41 | scope := scope(c.AWSCredentials.AccessKeyID, date, c.Region, "s3")
42 | conditions := append(
43 | c.Conditions,
44 | c.Key,
45 | Match("bucket", c.Bucket),
46 | Match("x-amz-algorithm", "AWS4-HMAC-SHA256"),
47 | Match("x-amz-credential", scope),
48 | Match("x-amz-date", date.Format("20060102T150405Z")),
49 | )
50 |
51 | b, err := json.Marshal(map[string]interface{}{
52 | "expiration": date.Add(c.Expires),
53 | "conditions": conditions,
54 | })
55 | if err != nil {
56 | return nil, err
57 | }
58 |
59 | policy := base64.StdEncoding.EncodeToString(b)
60 | fields := make(map[string]string)
61 | for _, c := range conditions {
62 | fields[c.Name()] = c.Value()
63 | }
64 | fields["policy"] = policy
65 | fields["x-amz-signature"] = signPolicy(policy, signing)
66 | delete(fields, "content-length-range")
67 |
68 | return &Form{Action: bucketURL(c.Bucket), Fields: fields}, nil
69 | }
70 |
71 | func bucketURL(bucket string) string {
72 | return fmt.Sprintf("https://%s.s3.amazonaws.com/", bucket)
73 | }
74 |
75 | func scope(accessKeyID string, date time.Time, region, service string) string {
76 | return fmt.Sprintf(
77 | "%s/%s/%s/%s/aws4_request",
78 | accessKeyID,
79 | date.Format("20060102"),
80 | region,
81 | service,
82 | )
83 | }
84 |
85 | // signingKey derives a key from your AWS secret access key and the given scope.
86 | // see: http://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
87 | func signingKey(secret string, date time.Time, region, service string) []byte {
88 | kDate := sign(date.Format("20060102"), []byte("AWS4"+secret))
89 | kRegion := sign(region, kDate)
90 | kService := sign(service, kRegion)
91 | return sign("aws4_request", kService)
92 | }
93 |
94 | func signPolicy(policy string, key []byte) string {
95 | return hex.EncodeToString(sign(policy, key))
96 | }
97 |
98 | func sign(msg string, key []byte) []byte {
99 | mac := hmac.New(sha256.New, key)
100 | mac.Write([]byte(msg))
101 | return mac.Sum(nil)
102 | }
103 |
--------------------------------------------------------------------------------
/s3pp_test.go:
--------------------------------------------------------------------------------
1 | package s3pp
2 |
3 | import (
4 | "encoding/hex"
5 | "testing"
6 | "time"
7 | )
8 |
9 | // http://docs.aws.amazon.com/general/latest/gr/signature-v4-examples.html
10 | func TestSigningKey(t *testing.T) {
11 | secret := "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"
12 | date := time.Date(2012, 2, 15, 0, 0, 0, 0, time.UTC)
13 | region := "us-east-1"
14 | service := "iam"
15 |
16 | key := hex.EncodeToString(signingKey(secret, date, region, service))
17 | want := "f4780e2d9f65fa895f9c67b32ce1baf0b0d8a43505a000a1a9e090d414db404d"
18 | if key != want {
19 | t.Errorf("got %s, want %s", key, want)
20 | }
21 | }
22 |
23 | // http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html
24 | func TestSigningPolicy(t *testing.T) {
25 | secret := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
26 | date := time.Date(2013, 8, 6, 0, 0, 0, 0, time.UTC)
27 | region := "us-east-1"
28 | service := "s3"
29 | policy := "eyAiZXhwaXJhdGlvbiI6ICIyMDEzLTA4LTA3VDEyOjAwOjAwLjAwMFoiLA0KICAiY29uZGl0aW9ucyI6IFsNCiAgICB7ImJ1Y2tldCI6ICJleGFtcGxlYnVja2V0In0sDQogICAgWyJzdGFydHMtd2l0aCIsICIka2V5IiwgInVzZXIvdXNlcjEvIl0sDQogICAgeyJhY2wiOiAicHVibGljLXJlYWQifSwNCiAgICB7InN1Y2Nlc3NfYWN0aW9uX3JlZGlyZWN0IjogImh0dHA6Ly9leGFtcGxlYnVja2V0LnMzLmFtYXpvbmF3cy5jb20vc3VjY2Vzc2Z1bF91cGxvYWQuaHRtbCJ9LA0KICAgIFsic3RhcnRzLXdpdGgiLCAiJENvbnRlbnQtVHlwZSIsICJpbWFnZS8iXSwNCiAgICB7IngtYW16LW1ldGEtdXVpZCI6ICIxNDM2NTEyMzY1MTI3NCJ9LA0KICAgIFsic3RhcnRzLXdpdGgiLCAiJHgtYW16LW1ldGEtdGFnIiwgIiJdLA0KDQogICAgeyJ4LWFtei1jcmVkZW50aWFsIjogIkFLSUFJT1NGT0ROTjdFWEFNUExFLzIwMTMwODA2L3VzLWVhc3QtMS9zMy9hd3M0X3JlcXVlc3QifSwNCiAgICB7IngtYW16LWFsZ29yaXRobSI6ICJBV1M0LUhNQUMtU0hBMjU2In0sDQogICAgeyJ4LWFtei1kYXRlIjogIjIwMTMwODA2VDAwMDAwMFoiIH0NCiAgXQ0KfQ=="
30 |
31 | signed := signPolicy(policy, signingKey(secret, date, region, service))
32 | want := "21496b44de44ccb73d545f1a995c68214c9cb0d41c45a17a5daeec0b1a6db047"
33 | if signed != want {
34 | t.Errorf("got %s, want %s", signed, want)
35 | }
36 | }
37 |
38 | func TestScope(t *testing.T) {
39 | date := time.Date(2016, 1, 30, 0, 0, 0, 0, time.UTC)
40 | s := scope("ACCESSKEY", date, "us-east-1", "s3")
41 | want := "ACCESSKEY/20160130/us-east-1/s3/aws4_request"
42 | if s != want {
43 | t.Errorf("got %s, want %s", s, want)
44 | }
45 | }
46 |
47 | func TestConditions(t *testing.T) {
48 | cases := []struct {
49 | cond Condition
50 | name, value string
51 | }{
52 | {Any("key"), "key", ""},
53 | {StartsWith("key", "attachments/"), "key", "attachments/"},
54 | {Match("key", "file.zip"), "key", "file.zip"},
55 | {ContentLengthRange(1, 1000), "content-length-range", ""},
56 | }
57 | for _, c := range cases {
58 | if c.cond.Name() != c.name {
59 | t.Errorf("Name() == %q, want %q", c.cond.Name(), c.name)
60 | }
61 | if c.cond.Value() != c.value {
62 | t.Errorf("Value() == %q, want %q", c.cond.Value(), c.value)
63 | }
64 | }
65 | }
66 |
67 | func TestGeneratedConditonsAddedToFields(t *testing.T) {
68 | form, err := New(Config{Key: Any("key")})
69 | if err != nil {
70 | t.Fatalf("unexpected error: %v", err)
71 | }
72 | expected := []string{"x-amz-algorithm", "x-amz-credential", "x-amz-date"}
73 | for _, field := range expected {
74 | if _, ok := form.Fields[field]; !ok {
75 | t.Errorf("Fields missing expected key %q", field)
76 | }
77 | }
78 | }
79 |
80 | func TestContentLengthRangeExcludedFromFields(t *testing.T) {
81 | form, err := New(Config{
82 | Key: Any("key"),
83 | Conditions: []Condition{ContentLengthRange(0, 100)},
84 | })
85 | if err != nil {
86 | t.Fatalf("unexpected error: %v", err)
87 | }
88 |
89 | if _, ok := form.Fields["content-length-range"]; ok {
90 | t.Errorf("content-length-range shouldn't be included in the form fields")
91 | }
92 | }
93 |
--------------------------------------------------------------------------------