fix(uploads): switch from S3 POST policy to presigned PUT
Backblaze B2's S3-compatible endpoint does not implement the S3 POST Object operation. It returns HTTP 501 to every POST regardless of URL style — both path-style (https://s3.<region>.backblazeb2.com/<bucket>/) and virtual-hosted-style (https://<bucket>.s3.<region>.backblazeb2.com/). Yesterday's BucketLookupDNS fix produced virtual-hosted URLs, which is correct for AWS but doesn't help here — B2 rejects POST on either form. Verified with `curl -X POST https://...backblazeb2.com/honeyDueProd/` returning 501 directly, with no signature involved. Replace minio-go's PresignedPostPolicy with PresignHeader + http.MethodPut. The signed URL now points at a single PUT endpoint, with Content-Type and Content-Length signed via headers — B2/S3/MinIO all accept it. Drop the min/max content-length range (we sign exactly one length now); post-upload size verification still happens in VerifyAndClaim via HEAD. Response shape: - URL (was: signed POST endpoint) → now: signed PUT URL - Fields → renamed to Headers; client sends them as request headers, not multipart form parts - Method (new): always "PUT", emitted explicitly so clients don't have to hardcode Companion KMP/iOS commits switch the client paths from multipart POST to single PUT. Existing builds in the field will need to be rebuilt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,8 +5,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
@@ -31,15 +32,6 @@ func NewS3Backend(endpoint, keyID, appKey, bucket string, useSSL bool, region st
|
||||
Secure: useSSL,
|
||||
Region: region,
|
||||
}
|
||||
// B2's S3-compatible endpoint only implements POST Object on
|
||||
// virtual-hosted-style URLs (https://<bucket>.s3.<region>.backblazeb2.com/).
|
||||
// Path-style POST returns HTTP 501. minio-go's auto-detection falls back
|
||||
// to path-style for non-AWS endpoints, which breaks PresignedPostPolicy
|
||||
// against B2. Force DNS lookup for B2 only — leave MinIO dev (no DNS for
|
||||
// arbitrary buckets at minio:9000) on the path-style default.
|
||||
if strings.Contains(endpoint, "backblazeb2.com") {
|
||||
opts.BucketLookup = minio.BucketLookupDNS
|
||||
}
|
||||
|
||||
client, err := minio.New(endpoint, opts)
|
||||
if err != nil {
|
||||
@@ -115,46 +107,42 @@ func (b *S3Backend) ReadStream(key string) (io.ReadCloser, error) {
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// PresignedPostResult is the data a client needs to perform a direct multipart
|
||||
// POST to S3-compatible storage. The caller assembles a multipart/form-data
|
||||
// request with the fields below as form parts (in order) and the file last.
|
||||
type PresignedPostResult struct {
|
||||
URL string // e.g. https://s3.us-east-005.backblazeb2.com/honeyDueProd
|
||||
Fields map[string]string // policy, x-amz-*, key, Content-Type, etc.
|
||||
// PresignedPutResult is the data a client needs to upload a single object
|
||||
// to S3-compatible storage via a single PUT request. The client sends the
|
||||
// raw object bytes as the request body and includes Headers verbatim — the
|
||||
// signature binds these headers, so any deviation invalidates it.
|
||||
//
|
||||
// We use PUT (not POST) because Backblaze B2's S3-compatible endpoint does
|
||||
// not implement the S3 POST Object operation (returns HTTP 501 on both
|
||||
// path-style and virtual-hosted-style URLs). PUT works against AWS S3, B2,
|
||||
// and MinIO uniformly.
|
||||
type PresignedPutResult struct {
|
||||
URL string // signed PUT URL with query-string credentials
|
||||
Headers map[string]string // headers the client must send on the PUT
|
||||
}
|
||||
|
||||
// PresignedPost generates a POST policy that constrains uploads at the protocol
|
||||
// level: only the named key, only the named content-type, only sizes within
|
||||
// the requested range. S3 (and B2's S3-compatible endpoint) reject anything
|
||||
// that doesn't satisfy every condition before accepting the body.
|
||||
// PresignedPut generates a single-use PUT URL that uploads exactly the
|
||||
// claimed `contentLength` bytes with the claimed `contentType`. The size
|
||||
// and content-type are signed via headers — B2/S3 reject anything that
|
||||
// doesn't match.
|
||||
//
|
||||
// minBytes/maxBytes are inclusive. The returned URL + Fields can be sent
|
||||
// straight to the client.
|
||||
func (b *S3Backend) PresignedPost(ctx context.Context, key, contentType string, minBytes, maxBytes int64, ttl time.Duration) (*PresignedPostResult, error) {
|
||||
policy := minio.NewPostPolicy()
|
||||
if err := policy.SetBucket(b.bucket); err != nil {
|
||||
return nil, fmt.Errorf("set bucket: %w", err)
|
||||
}
|
||||
if err := policy.SetKey(key); err != nil {
|
||||
return nil, fmt.Errorf("set key: %w", err)
|
||||
}
|
||||
if err := policy.SetContentType(contentType); err != nil {
|
||||
return nil, fmt.Errorf("set content-type: %w", err)
|
||||
}
|
||||
if err := policy.SetContentLengthRange(minBytes, maxBytes); err != nil {
|
||||
return nil, fmt.Errorf("set content-length-range: %w", err)
|
||||
}
|
||||
if err := policy.SetExpires(time.Now().UTC().Add(ttl)); err != nil {
|
||||
return nil, fmt.Errorf("set expires: %w", err)
|
||||
}
|
||||
// Post-upload verification is the caller's job (see UploadService.VerifyAndClaim
|
||||
// which HEADs the object to confirm size + type before claiming).
|
||||
func (b *S3Backend) PresignedPut(ctx context.Context, key, contentType string, contentLength int64, ttl time.Duration) (*PresignedPutResult, error) {
|
||||
extra := http.Header{}
|
||||
extra.Set("Content-Type", contentType)
|
||||
extra.Set("Content-Length", strconv.FormatInt(contentLength, 10))
|
||||
|
||||
u, fields, err := b.client.PresignedPostPolicy(ctx, policy)
|
||||
u, err := b.client.PresignHeader(ctx, http.MethodPut, b.bucket, key, ttl, nil, extra)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("presign post policy: %w", err)
|
||||
return nil, fmt.Errorf("presign put: %w", err)
|
||||
}
|
||||
return &PresignedPostResult{
|
||||
URL: stripQuery(u),
|
||||
Fields: fields,
|
||||
return &PresignedPutResult{
|
||||
URL: u.String(),
|
||||
Headers: map[string]string{
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": strconv.FormatInt(contentLength, 10),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user