fix(uploads): switch from S3 multipart POST to presigned PUT
Android UI Tests / ui-tests (push) Has been cancelled
Android UI Tests / ui-tests (push) Has been cancelled
Backblaze B2's S3-compatible endpoint does not implement the S3 POST
Object operation — every POST returns HTTP 501 regardless of URL form
(path-style or virtual-hosted-style). The previous multipart-POST flow
has been failing for every task-completion image upload.
Server-side companion change (honeyDueAPI master @7cc5448) replaces
PresignedPostPolicy with PresignHeader/PUT and renames the response
field from "fields" to "headers". This commit aligns both clients.
PresignUploadResponse model: field renamed `fields` → `headers`,
added `method` (default "PUT"). Both new fields have defaults so a
build talking to a stale server still decodes — albeit with empty
headers, which would then 403 at signature time. The server is
already on the new shape in prod.
iOS PresignedUploader.swift: dropped the ~70-line multipart body
builder and S3 form-field ordering logic. Replaced with a single PUT
request that applies server-supplied headers verbatim (skipping
Content-Length, which URLSession sets automatically and refuses to
override).
Android UploadApi.kt: same shape change. `postToStorage` →
`putToStorage`. Single Ktor `client.put()` with headers passthrough.
`uploadOne`'s `fileName` parameter kept for source compatibility but
marked @Suppress("UNUSED_PARAMETER") since PUT doesn't need it.
Verified end-to-end against api.myhoneydue.com:
presign → PUT 12 bytes → HTTP 200 in 0.6s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,15 +4,18 @@ import ComposeApp
|
||||
/// Three-step direct-to-B2 image upload.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. POST /api/uploads/presign → server returns a B2 POST policy + form
|
||||
/// fields scoped to a single object key with a content-length-range
|
||||
/// condition that B2 enforces at the protocol level.
|
||||
/// 2. Multipart POST the bytes directly to B2, no API server in the data
|
||||
/// path. B2 rejects the upload if the bytes don't match the policy.
|
||||
/// 1. POST /api/uploads/presign → server returns a signed PUT URL plus
|
||||
/// the headers (Content-Type, Content-Length) the client must send.
|
||||
/// The signature binds those headers — B2 rejects the upload if the
|
||||
/// bytes/headers don't match exactly.
|
||||
/// 2. PUT the bytes directly to B2, no API server in the data path.
|
||||
/// 3. Caller passes the returned `uploadId` to /api/task-completions/ or
|
||||
/// /api/documents/ via `upload_ids[]`. The server HEADs the object,
|
||||
/// confirms the size, and creates the linked entity rows.
|
||||
///
|
||||
/// We use PUT (not POST) because B2's S3-compatible endpoint does not
|
||||
/// implement the S3 POST Object form upload — every POST returns HTTP 501.
|
||||
///
|
||||
/// All errors map to `PresignedUploaderError` — the Swift call site can
|
||||
/// translate to user-facing copy without parsing nested HTTP details.
|
||||
enum PresignedUploaderError: Error, LocalizedError {
|
||||
@@ -33,7 +36,7 @@ enum PresignedUploaderError: Error, LocalizedError {
|
||||
default: return "Couldn't start upload (server returned \(status))."
|
||||
}
|
||||
case .uploadFailed(let status, _):
|
||||
return "Upload failed (B2 returned \(status))."
|
||||
return "Upload failed (storage returned \(status))."
|
||||
case .sessionError(let err):
|
||||
return err.localizedDescription
|
||||
}
|
||||
@@ -95,13 +98,12 @@ final class PresignedUploader {
|
||||
contentLength: Int64(data.count)
|
||||
)
|
||||
|
||||
// Step 2: direct POST to B2
|
||||
try await postToStorage(
|
||||
// Step 2: direct PUT to B2
|
||||
try await putToStorage(
|
||||
uploadURL: presigned.uploadUrl,
|
||||
fields: presigned.fields,
|
||||
headers: presigned.headers,
|
||||
data: data,
|
||||
contentType: contentType,
|
||||
fileName: fileName
|
||||
contentType: contentType
|
||||
)
|
||||
|
||||
return Int32(presigned.id)
|
||||
@@ -146,7 +148,8 @@ final class PresignedUploader {
|
||||
private struct PresignResponse: Decodable {
|
||||
let id: Int
|
||||
let upload_url: String
|
||||
let fields: [String: String]
|
||||
let method: String?
|
||||
let headers: [String: String]
|
||||
let key: String
|
||||
let expires_at: String
|
||||
|
||||
@@ -196,64 +199,39 @@ final class PresignedUploader {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 2: POST to B2
|
||||
// MARK: - Step 2: PUT to B2
|
||||
//
|
||||
// The presign response includes the exact headers (Content-Type +
|
||||
// Content-Length) that were signed. Send them verbatim — any deviation
|
||||
// invalidates the signature and B2 will reject the upload.
|
||||
//
|
||||
// Content-Length is set automatically by URLSession from httpBody.count,
|
||||
// so we don't manually echo it back; we still send Content-Type because
|
||||
// URLSession will otherwise default it to application/x-www-form-urlencoded.
|
||||
|
||||
private func postToStorage(
|
||||
private func putToStorage(
|
||||
uploadURL: String,
|
||||
fields: [String: String],
|
||||
headers: [String: String],
|
||||
data: Data,
|
||||
contentType: String,
|
||||
fileName: String
|
||||
contentType: String
|
||||
) async throws {
|
||||
guard let url = URL(string: uploadURL) else {
|
||||
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url")
|
||||
}
|
||||
|
||||
// Build a multipart/form-data body with all policy fields followed
|
||||
// by a single "file" part (S3 POST policy mandates the file part
|
||||
// come last).
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var body = Data()
|
||||
let crlf = "\r\n"
|
||||
let appendString: (String) -> Void = { s in
|
||||
body.append(s.data(using: .utf8) ?? Data())
|
||||
}
|
||||
|
||||
// Stable order: ensure "key" and "Content-Type" appear before the
|
||||
// file part so the policy signature validates. Unspecified order
|
||||
// for the rest — S3 accepts any.
|
||||
let orderedKeys = ["key", "Content-Type", "policy", "x-amz-algorithm",
|
||||
"x-amz-credential", "x-amz-date", "x-amz-signature",
|
||||
"x-amz-meta-uid"]
|
||||
var emitted = Set<String>()
|
||||
for k in orderedKeys {
|
||||
if let v = fields[k] {
|
||||
appendString("--\(boundary)\(crlf)")
|
||||
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
|
||||
appendString(v)
|
||||
appendString(crlf)
|
||||
emitted.insert(k)
|
||||
}
|
||||
}
|
||||
for (k, v) in fields where !emitted.contains(k) {
|
||||
appendString("--\(boundary)\(crlf)")
|
||||
appendString("Content-Disposition: form-data; name=\"\(k)\"\(crlf)\(crlf)")
|
||||
appendString(v)
|
||||
appendString(crlf)
|
||||
}
|
||||
|
||||
// file part — must be last
|
||||
appendString("--\(boundary)\(crlf)")
|
||||
appendString("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\(crlf)")
|
||||
appendString("Content-Type: \(contentType)\(crlf)\(crlf)")
|
||||
body.append(data)
|
||||
appendString(crlf)
|
||||
appendString("--\(boundary)--\(crlf)")
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = body
|
||||
req.httpMethod = "PUT"
|
||||
req.httpBody = data
|
||||
|
||||
// Apply server-supplied headers verbatim. Skip Content-Length —
|
||||
// URLSession sets it automatically and will refuse to override it.
|
||||
for (k, v) in headers where k.lowercased() != "content-length" {
|
||||
req.setValue(v, forHTTPHeaderField: k)
|
||||
}
|
||||
// Defensive: ensure Content-Type is set even if the server omits it.
|
||||
if req.value(forHTTPHeaderField: "Content-Type") == nil {
|
||||
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
let (respBody, response): (Data, URLResponse)
|
||||
do {
|
||||
|
||||
Reference in New Issue
Block a user