feat: initial version
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/apex/log"
|
||||
|
||||
"gitlab.com/unboundsoftware/s3uploader/storage"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
*http.ServeMux
|
||||
store storage.Storage
|
||||
returnUrl string
|
||||
logger log.Interface
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (s *Server) HandlePut(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.Method {
|
||||
case http.MethodOptions:
|
||||
s.cors(w)
|
||||
case http.MethodPut:
|
||||
path := strings.TrimPrefix(req.URL.Path, "/put")
|
||||
s.logger.Infof("uploading to %s", path)
|
||||
s.upload(path, w, req)
|
||||
default:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("This endpoint requires PUT"))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) HandleUpload(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.Method {
|
||||
case http.MethodOptions:
|
||||
s.cors(w)
|
||||
case http.MethodPut:
|
||||
prefix, err := randomString(64)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("An error occurred"))
|
||||
return
|
||||
}
|
||||
now := s.now()
|
||||
day := now.Format("20060102")
|
||||
prefixHash := hash(prefix)
|
||||
dayHash := hash(now.Format(time.RFC3339Nano))
|
||||
s.logger.Infof("uploading to %s/%s%s", day, prefixHash, dayHash)
|
||||
s.upload(fmt.Sprintf("%s/%s%s", day, prefixHash, dayHash), w, req)
|
||||
default:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("This endpoint requires PUT"))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) HandleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
func (s *Server) upload(path string, w http.ResponseWriter, req *http.Request) {
|
||||
err := s.store.Store(path, req.Body)
|
||||
if err != nil {
|
||||
s.logger.WithError(err).Error("error storing object in bucket")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("error storing object in bucket"))
|
||||
return
|
||||
}
|
||||
w.Header().Add("Access-Control-Expose-Headers", "X-File-URL")
|
||||
fileUrl := filepath.Join(s.returnUrl, path)
|
||||
w.Header().Add("X-File-URL", fileUrl)
|
||||
s.logger.Infof("success - file is available at %s", fileUrl)
|
||||
_, _ = w.Write([]byte("success"))
|
||||
}
|
||||
|
||||
func (s *Server) cors(w http.ResponseWriter) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "PUT,OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Requested-With")
|
||||
w.Header().Set("Access-Control-Max-Age", "1728000")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func New(store storage.Storage, url string, logger log.Interface) http.Handler {
|
||||
mux := &Server{
|
||||
ServeMux: http.NewServeMux(),
|
||||
store: store,
|
||||
returnUrl: url,
|
||||
logger: logger,
|
||||
now: time.Now,
|
||||
}
|
||||
mux.HandleFunc("/put/", mux.HandlePut)
|
||||
mux.HandleFunc("/upload", mux.HandleUpload)
|
||||
mux.HandleFunc("/health", mux.HandleHealth)
|
||||
return mux
|
||||
}
|
||||
|
||||
func hash(s string) string {
|
||||
hasher := sha1.New()
|
||||
hasher.Write([]byte(s))
|
||||
return fmt.Sprintf("%x", hasher.Sum(nil))
|
||||
}
|
||||
|
||||
func randomString(n int) (string, error) {
|
||||
const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
ret := make([]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ret[i] = letters[num.Int64()]
|
||||
}
|
||||
|
||||
return string(ret), nil
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
mocks "gitlab.com/unboundsoftware/apex-mocks"
|
||||
)
|
||||
|
||||
func TestServer(t *testing.T) {
|
||||
type args struct {
|
||||
store func(t *testing.T) StoreFunc
|
||||
url string
|
||||
random io.Reader
|
||||
}
|
||||
type req struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
req req
|
||||
wantStatus int
|
||||
wantResp string
|
||||
wantHeaders map[string]string
|
||||
}{
|
||||
{
|
||||
name: "unhandled path",
|
||||
args: args{},
|
||||
req: req{
|
||||
method: http.MethodPut,
|
||||
path: "/missing",
|
||||
body: "xyz",
|
||||
},
|
||||
wantStatus: 404,
|
||||
wantResp: "404 page not found\n",
|
||||
},
|
||||
{
|
||||
name: "health",
|
||||
args: args{},
|
||||
req: req{
|
||||
method: http.MethodPut,
|
||||
path: "/health",
|
||||
body: "",
|
||||
},
|
||||
wantStatus: 200,
|
||||
wantResp: "OK",
|
||||
},
|
||||
{
|
||||
name: "GET on /put",
|
||||
args: args{},
|
||||
req: req{
|
||||
method: http.MethodGet,
|
||||
path: "/put/some/file",
|
||||
body: "abc",
|
||||
},
|
||||
wantStatus: 400,
|
||||
wantResp: "This endpoint requires PUT",
|
||||
},
|
||||
{
|
||||
name: "OPTIONS on /put",
|
||||
args: args{},
|
||||
req: req{
|
||||
method: http.MethodOptions,
|
||||
path: "/put/some/file",
|
||||
body: "abc",
|
||||
},
|
||||
wantStatus: 204,
|
||||
wantResp: "",
|
||||
wantHeaders: map[string]string{
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "PUT,OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Requested-With",
|
||||
"Access-Control-Max-Age": "1728000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PUT on /put - error",
|
||||
args: args{
|
||||
store: func(t *testing.T) StoreFunc {
|
||||
return func(path string, content io.Reader) error {
|
||||
assert.Equal(t, "/some/file", path)
|
||||
temp, err := ioutil.ReadAll(content)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "abc", string(temp))
|
||||
return fmt.Errorf("error")
|
||||
}
|
||||
},
|
||||
},
|
||||
req: req{
|
||||
method: http.MethodPut,
|
||||
path: "/put/some/file",
|
||||
body: "abc",
|
||||
},
|
||||
wantStatus: 500,
|
||||
wantResp: "error storing object in bucket",
|
||||
},
|
||||
{
|
||||
name: "PUT on /put - success",
|
||||
args: args{
|
||||
store: func(t *testing.T) StoreFunc {
|
||||
return func(path string, content io.Reader) error {
|
||||
return nil
|
||||
}
|
||||
},
|
||||
url: "https://example.org",
|
||||
},
|
||||
req: req{
|
||||
method: http.MethodPut,
|
||||
path: "/put/some/file",
|
||||
body: "abc",
|
||||
},
|
||||
wantStatus: 200,
|
||||
wantResp: "success",
|
||||
wantHeaders: map[string]string{
|
||||
"Access-Control-Expose-Headers": "X-File-URL",
|
||||
"X-File-URL": "https:/example.org/some/file",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GET on /upload",
|
||||
args: args{},
|
||||
req: req{
|
||||
method: http.MethodGet,
|
||||
path: "/upload",
|
||||
body: "abc",
|
||||
},
|
||||
wantStatus: 400,
|
||||
wantResp: "This endpoint requires PUT",
|
||||
},
|
||||
{
|
||||
name: "OPTIONS on /upload",
|
||||
args: args{},
|
||||
req: req{
|
||||
method: http.MethodOptions,
|
||||
path: "/upload",
|
||||
body: "abc",
|
||||
},
|
||||
wantStatus: 204,
|
||||
wantResp: "",
|
||||
wantHeaders: map[string]string{
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "PUT,OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Requested-With",
|
||||
"Access-Control-Max-Age": "1728000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PUT on /upload - error with random",
|
||||
args: args{
|
||||
random: strings.NewReader("too short"),
|
||||
},
|
||||
req: req{
|
||||
method: http.MethodPut,
|
||||
path: "/upload",
|
||||
body: "abc",
|
||||
},
|
||||
wantStatus: 500,
|
||||
wantResp: "An error occurred",
|
||||
},
|
||||
{
|
||||
name: "PUT on /upload - error",
|
||||
args: args{
|
||||
store: func(t *testing.T) StoreFunc {
|
||||
return func(path string, content io.Reader) error {
|
||||
assert.Equal(t, fmt.Sprintf("%s/588b41ebf261820104615b83201c729bd16016d6e43649b28b0ef77d54ca5aaf8da0ce74ae3f20a4", time.Now().Format("20060102")), path)
|
||||
temp, err := ioutil.ReadAll(content)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "abc", string(temp))
|
||||
return fmt.Errorf("error")
|
||||
}
|
||||
},
|
||||
},
|
||||
req: req{
|
||||
method: http.MethodPut,
|
||||
path: "/upload",
|
||||
body: "abc",
|
||||
},
|
||||
wantStatus: 500,
|
||||
wantResp: "error storing object in bucket",
|
||||
},
|
||||
{
|
||||
name: "PUT on /upload - success",
|
||||
args: args{
|
||||
store: func(t *testing.T) StoreFunc {
|
||||
return func(path string, content io.Reader) error {
|
||||
return nil
|
||||
}
|
||||
},
|
||||
url: "https://example.org",
|
||||
},
|
||||
req: req{
|
||||
method: http.MethodPut,
|
||||
path: "/upload",
|
||||
body: "abc",
|
||||
},
|
||||
wantStatus: 200,
|
||||
wantResp: "success",
|
||||
wantHeaders: map[string]string{
|
||||
"Access-Control-Expose-Headers": "X-File-URL",
|
||||
"X-File-URL": "https:/example.org/20211125/588b41ebf261820104615b83201c729bd16016d6e43649b28b0ef77d54ca5aaf8da0ce74ae3f20a4",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.args.random != nil {
|
||||
rand.Reader = tt.args.random
|
||||
} else {
|
||||
rand.Reader = strings.NewReader("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
||||
}
|
||||
logger := mocks.New()
|
||||
var store StoreFunc
|
||||
if tt.args.store != nil {
|
||||
store = tt.args.store(t)
|
||||
}
|
||||
server := New(store, tt.args.url, logger.Logger)
|
||||
server.(*Server).now = func() time.Time {
|
||||
return time.Date(2021, 11, 25, 7, 43, 12, 0, time.UTC)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
u, err := url.Parse(fmt.Sprintf("https://example.org%s", tt.req.path))
|
||||
require.NoError(t, err)
|
||||
server.ServeHTTP(recorder, &http.Request{
|
||||
Method: tt.req.method,
|
||||
URL: u,
|
||||
Body: io.NopCloser(strings.NewReader(tt.req.body)),
|
||||
})
|
||||
assert.Equal(t, tt.wantStatus, recorder.Code, "StatusCode")
|
||||
assert.Equal(t, tt.wantResp, recorder.Body.String(), "Body")
|
||||
for k, v := range tt.wantHeaders {
|
||||
assert.Equal(t, v, recorder.Header().Get(k), "Header")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type StoreFunc func(path string, content io.Reader) error
|
||||
|
||||
func (f StoreFunc) Store(path string, content io.Reader) error {
|
||||
return f(path, content)
|
||||
}
|
||||
Reference in New Issue
Block a user