fix(uploads): switch from S3 POST policy to presigned PUT
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled

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:
Trey t
2026-05-06 15:41:48 -05:00
parent 5d8559b495
commit 7cc5448a7c
3 changed files with 60 additions and 67 deletions
+9 -14
View File
@@ -164,18 +164,12 @@ func (s *UploadService) Presign(
ext := extensionForContentType(contentType)
key := fmt.Sprintf("uploads/%s/%d/%s%s", subdir, userID, uuid.New().String(), ext)
// Sign the POST policy. The slack window lets the client encode once;
// the server still rejects anything materially different from claimed.
minB := contentLength - UploadPresignSlackBytes
if minB < 0 {
minB = 0
}
maxB := contentLength + UploadPresignSlackBytes
if maxB > UploadMaxBytes {
maxB = UploadMaxBytes
}
post, err := s.s3.PresignedPost(ctx, key, contentType, minB, maxB, UploadPresignTTL)
// Sign a single-use PUT URL that binds Content-Type and Content-Length.
// B2 does not implement S3's POST Object form upload (returns 501), so
// we use PUT — which works uniformly against AWS S3, B2, and MinIO.
// Size mismatch is caught later in VerifyAndClaim via HEAD; we don't
// need a min/max range here because the signature pins exactly one size.
put, err := s.s3.PresignedPut(ctx, key, contentType, contentLength, UploadPresignTTL)
if err != nil {
return nil, apperrors.Internal(fmt.Errorf("presign upload: %w", err))
}
@@ -195,8 +189,9 @@ func (s *UploadService) Presign(
return &responses.PresignUploadResponse{
ID: row.ID,
URL: post.URL,
Fields: post.Fields,
URL: put.URL,
Headers: put.Headers,
Method: "PUT",
Key: key,
ExpiresAt: expiresAt.Format(time.RFC3339),
}, nil