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)