99 lines
3.6 KiB
Go
99 lines
3.6 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// NewUploadSigner extends the local download handler with signed PUT requests.
|
|
// No global CORS policy is installed; applications choose their allowed origins.
|
|
// URLs are reusable until expiry. Use random staging keys and authorize issuance.
|
|
func NewUploadSigner(store Store, baseURL string, secret []byte, policy UploadPolicy) (*Signer, error) {
|
|
if err := policy.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
s, err := NewSigner(store, baseURL, secret)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
policy.ContentTypes = append([]string(nil), policy.ContentTypes...)
|
|
s.uploadPolicy = &policy
|
|
return s, nil
|
|
}
|
|
func (s *Signer) uploadSignature(key, expiry, size, contentType string) string {
|
|
mac := hmac.New(sha256.New, s.secret)
|
|
io.WriteString(mac, "PUT\n"+key+"\n"+expiry+"\n"+size+"\n"+contentType)
|
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
}
|
|
func (s *Signer) PresignPut(ctx context.Context, key string, size int64, contentType string, ttl time.Duration) (UploadRequest, error) {
|
|
if s.uploadPolicy == nil {
|
|
return UploadRequest{}, ErrUnsupported
|
|
}
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
if err := CheckUpload(ctx, key, size, s.uploadPolicy.MaxBytes, contentType, ttl); err != nil {
|
|
return UploadRequest{}, err
|
|
}
|
|
if err := s.uploadPolicy.Check(size, contentType); err != nil {
|
|
return UploadRequest{}, err
|
|
}
|
|
expires := s.now().Add(ttl).Truncate(time.Second)
|
|
expiry, n := strconv.FormatInt(expires.Unix(), 10), strconv.FormatInt(size, 10)
|
|
u := *s.base
|
|
u.RawQuery = url.Values{"key": {key}, "expires": {expiry}, "size": {n}, "contentType": {contentType}, "signature": {s.uploadSignature(key, expiry, n, contentType)}}.Encode()
|
|
return UploadRequest{Method: http.MethodPut, URL: u.String(), Headers: http.Header{"Content-Length": {n}, "Content-Type": {contentType}}, ExpiresAt: expires}, nil
|
|
}
|
|
func (s *Signer) serveUpload(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
q, err := url.ParseQuery(r.URL.RawQuery)
|
|
valid := err == nil && len(q) == 5
|
|
for _, key := range []string{"key", "expires", "size", "contentType", "signature"} {
|
|
valid = valid && len(q[key]) == 1
|
|
}
|
|
if !valid {
|
|
http.Error(w, "invalid link", 403)
|
|
return
|
|
}
|
|
key, expiry, n, contentType := q.Get("key"), q.Get("expires"), q.Get("size"), q.Get("contentType")
|
|
expires, err := strconv.ParseInt(expiry, 10, 64)
|
|
if err != nil || expires <= s.now().Unix() || expires > s.now().Unix()+3600 || Check(r.Context(), key) != nil || !hmac.Equal([]byte(q.Get("signature")), []byte(s.uploadSignature(key, expiry, n, contentType))) {
|
|
http.Error(w, "invalid or expired link", 403)
|
|
return
|
|
}
|
|
size, err := strconv.ParseInt(n, 10, 64)
|
|
if err != nil || s.uploadPolicy.Check(size, contentType) != nil || r.ContentLength != size || len(r.Header.Values("Content-Type")) != 1 || r.Header.Get("Content-Type") != contentType {
|
|
http.Error(w, "upload constraints mismatch", 400)
|
|
return
|
|
}
|
|
body := http.MaxBytesReader(w, r.Body, size)
|
|
defer body.Close()
|
|
uploader, _ := NewUploader(s.store, *s.uploadPolicy)
|
|
_, err = uploader.Put(r.Context(), key, body, size, contentType)
|
|
if err != nil {
|
|
status := 500
|
|
var limit *http.MaxBytesError
|
|
switch {
|
|
case errors.Is(err, ErrTooLarge), errors.As(err, &limit):
|
|
status = 413
|
|
case errors.Is(err, ErrContentType):
|
|
status = 415
|
|
case errors.Is(err, ErrSizeMismatch):
|
|
status = 400
|
|
}
|
|
http.Error(w, "upload rejected", status)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
}
|
|
|
|
var _ UploadPresigner = (*Signer)(nil)
|