├── .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 | [![Build Status](https://travis-ci.com/Teamwork/s3pp.svg?branch=master)](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 |
31 | {{range $name, $value := .Fields}} 32 | 33 | {{end}} 34 | 35 | 36 | 37 |
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 |
62 | {{range $name, $value := .Fields}} 63 | 64 | {{end}} 65 | 66 | 67 | 68 |
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 | --------------------------------------------------------------------------------