Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions download.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package attache

import (
"io"
"net/http"
"path/filepath"
"strings"
)

func (s Server) handleDownload(w http.ResponseWriter, r *http.Request) {
fullpath := strings.TrimPrefix(r.URL.RequestURI(), s.GetPrefixPath)
objectKey := filepath.Base(fullpath)
stream, err := s.Storage.Download(r.Context(), objectKey)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if stream == nil {
http.Error(w, fullpath, http.StatusNotFound)
return
}
io.Copy(w, stream)
Copy link
Collaborator Author

@choonkeat choonkeat Dec 19, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE that we didn't set Content-Type response header yet

  1. We can sniff again and set the Content-Type (oh noes, gotta read 512 bytes and juggle rewinding the io.Reader...)
  2. OR better, when we store the file into backend (e.g. s3, google cloud storage, ...) we should've put the sniffed content type as metadata (e.g. see PutObjectInput.Metadata. So that when we perform download, we can resupply the Content-Type without re-sniffing?

}
73 changes: 73 additions & 0 deletions download_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package attache

import (
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"strconv"
"testing"

"github.com/stretchr/testify/assert"

"golang.org/x/net/context"
)

func TestHandleDownload(t *testing.T) {
testCases := []struct {
givenURI string
givenFile string
givenContentType string
expectedStatus int
expectedFile string
}{
{
givenURI: "/%s",
givenFile: "testdata/transparent.gif",
givenContentType: "image/gif",
expectedStatus: http.StatusOK,
expectedFile: "testdata/transparent.gif",
},
{
givenURI: "/wrong.txt?%s",
givenFile: "testdata/transparent.gif",
givenContentType: "image/gif",
expectedStatus: http.StatusNotFound,
},
}

for i, tc := range testCases {
t.Run(strconv.Itoa(i), func(t *testing.T) {
file, err := os.Open(tc.givenFile)
if err != nil {
t.Fatal(err.Error())
}
defer file.Close()
store := newDummyStore()
store.Upload(context.Background(), file, tc.givenContentType)

r := httptest.NewRequest("GET", fmt.Sprintf(tc.givenURI, store.LastUniqueKey), nil)
w := httptest.NewRecorder()
s := Server{Storage: store}
s.ServeHTTP(w, r)

result := w.Result()
assert.Equal(t, tc.expectedStatus, result.StatusCode)
if result.StatusCode != http.StatusOK {
return
}

expectedBytes, err := ioutil.ReadFile(tc.expectedFile)
if err != nil {
t.Fatal(err.Error())
}
actualBytes, err := ioutil.ReadAll(result.Body)
if err != nil {
t.Fatal(err.Error())
}
defer result.Body.Close()
assert.Equal(t, expectedBytes, actualBytes)
})
}
}
6 changes: 6 additions & 0 deletions gcloudstore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@ func main() {

http.Handle(nodego.HTTPTrigger, attache.Server{
Storage: gcloudstore.NewStore("your-bucket-name"),
GetPrefixPath: "/execute?",
})

nodego.TakeOver()
}
```

NOTE:
- Google Cloud Function http endpoint matches request path strictly, e.g. `/attache` works but `/attache/thing.jpg` is 404.
- To workaround that, we need to specify the url as `/attache?thing.jpg` instead
- But internally our http server sees the prefix as `/execute` ¯\_(ツ)_/¯, so we have to configure `GetPrefixPath: "/execute?"`
20 changes: 18 additions & 2 deletions gcloudstore/gcloud.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package gcloudstore

import (
"bytes"
"fmt"
"io"
"math"
Expand Down Expand Up @@ -30,7 +29,7 @@ func NewStore(bucketName string) Store {
}

// Upload fulfills attache.Store interface
func (s Store) Upload(ctx context.Context, src *bytes.Reader, fileType string) (string, error) {
func (s Store) Upload(ctx context.Context, src io.ReadSeeker, fileType string) (string, error) {
fileName := filename(fileType)

client, err := storage.NewClient(ctx)
Expand All @@ -49,6 +48,23 @@ func (s Store) Upload(ctx context.Context, src *bytes.Reader, fileType string) (
return fileName, nil
}

// Download fulfills attache.Store interface
func (s Store) Download(ctx context.Context, filePath string) (io.ReadCloser, error) {
client, err := storage.NewClient(ctx)
if err != nil {
return nil, errors.Wrapf(err, "storage newclient")
}
body, err := client.Bucket(s.bucketName).Object(filePath).NewReader(ctx)
if err == storage.ErrBucketNotExist || err == storage.ErrObjectNotExist {
return nil, nil
}
if err != nil {
return nil, errors.Wrapf(err, "storage newreader")
}

return body, nil
}

func filename(fileType string) string {
// Sorts in Reverse Chrono Order
key := strconv.FormatInt((math.MaxInt64 - time.Now().UnixNano()), 10)
Expand Down
27 changes: 25 additions & 2 deletions s3store/s3.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package s3store

import (
"bytes"
"fmt"
"io"
"math/rand"
"os"
"strings"
Expand All @@ -11,9 +11,11 @@ import (
"golang.org/x/net/context"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/oklog/ulid"
"github.com/pkg/errors"
attache "github.com/winston/attache-lambda"
)

Expand All @@ -23,7 +25,7 @@ type Store struct {
}

// Upload fulfills attache.Store interface
func (s Store) Upload(ctx context.Context, file *bytes.Reader, fileType string) (string, error) {
func (s Store) Upload(ctx context.Context, file io.ReadSeeker, fileType string) (string, error) {
fileName := filename(fileType)
filePath := fmt.Sprintf("https://s3-%s.amazonaws.com/%s/%s", os.Getenv("AWS_REGION"), s.Bucket, fileName)

Expand All @@ -39,6 +41,27 @@ func (s Store) Upload(ctx context.Context, file *bytes.Reader, fileType string)
return filePath, err
}

// Download fulfills attache.Store interface
func (s Store) Download(ctx context.Context, filePath string) (io.ReadCloser, error) {
svc := s3.New(session.New())
result, err := svc.GetObjectWithContext(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.Bucket),
Key: &filePath,
})
if e, ok := err.(awserr.Error); ok {
if e.Code() == s3.ErrCodeNoSuchKey ||
e.Code() == s3.ErrCodeNoSuchBucket ||
e.Code() == s3.ErrCodeNoSuchUpload {
return nil, nil
}
}
if err != nil {
return nil, errors.Wrapf(err, "get object: %#v", filePath)
}

return result.Body, nil
}

func filename(fileType string) string {
current := time.Now()
entropy := rand.New(rand.NewSource(current.UnixNano()))
Expand Down
6 changes: 5 additions & 1 deletion server.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ type uploadMeta struct {

// Server handles upload and download
type Server struct {
Storage Store
Storage Store
GetPrefixPath string // e.g. `/execute?` we strip away this prefix before we extract `filePath`
}

func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
Expand All @@ -33,6 +34,9 @@ func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
json.NewEncoder(w).Encode(result)

case "GET":
s.handleDownload(w, r)

case "OPTIONS":
w.Header().Set("Access-Control-Allow-Methods", "POST, PUT, PATCH, OPTIONS")

Expand Down
5 changes: 3 additions & 2 deletions store.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package attache

import (
"bytes"
"io"

"golang.org/x/net/context"
)

type Store interface {
Upload(ctx context.Context, file *bytes.Reader, fileType string) (filePath string, err error)
Upload(ctx context.Context, file io.ReadSeeker, fileType string) (filePath string, err error)
Download(ctx context.Context, filePath string) (io.ReadCloser, error) // should return `nil, nil` for when `filePath` is not found
}
17 changes: 15 additions & 2 deletions store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package attache

import (
"bytes"
"io"
"io/ioutil"

"golang.org/x/net/context"
Expand All @@ -13,7 +14,8 @@ import (
)

type dummyStore struct {
hash map[string][]byte // default is `nil`
hash map[string][]byte // default is `nil`
LastUniqueKey string
}

func newDummyStore() *dummyStore {
Expand All @@ -23,16 +25,27 @@ func newDummyStore() *dummyStore {
}

// Upload fulfills attache.Store interface
func (s *dummyStore) Upload(ctx context.Context, file *bytes.Reader, fileType string) (string, error) {
func (s *dummyStore) Upload(ctx context.Context, file io.ReadSeeker, fileType string) (string, error) {
data, err := ioutil.ReadAll(file)
if err != nil {
return "", err
}

uniqueKey := uuid.NewV4().String()
s.LastUniqueKey = uniqueKey
s.hash[uniqueKey] = data
return uniqueKey, nil
}

// Download fulfills attache.Store interface
func (s *dummyStore) Download(ctx context.Context, filePath string) (io.ReadCloser, error) {
data, ok := s.hash[filePath]
if !ok {
return nil, nil
}

return ioutil.NopCloser(bytes.NewReader(data)), nil
}

// compile-time check that we implement attache.Store interface
var _ Store = newDummyStore()