Files
go-omni/storage/signedUpload.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)