From fdcf82757d5861d33d37dbabed9bcccd0311b961 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 6 May 2026 15:48:37 -0500 Subject: [PATCH] fix(uploads): switch from S3 multipart POST to presigned PUT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../com/tt/honeyDue/models/TaskCompletion.kt | 13 ++- .../com/tt/honeyDue/network/UploadApi.kt | 71 ++++++------- iosApp/iosApp/Helpers/PresignedUploader.swift | 100 +++++++----------- 3 files changed, 81 insertions(+), 103 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskCompletion.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskCompletion.kt index 3983a92..ae505dc 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskCompletion.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/TaskCompletion.kt @@ -34,15 +34,20 @@ data class PresignUploadRequest( /** * Presigned upload session — response from POST /api/uploads/presign. * - * The client uses [uploadUrl] + [fields] to perform a multipart/form-data - * POST directly to B2, then passes [id] back in the upload_ids[] field of - * the next /api/task-completions/ or /api/documents/ create call. + * The client makes one PUT request to [uploadUrl] with the raw object + * bytes as the body and [headers] as the request headers. On success, + * pass [id] back in the upload_ids[] field of the next + * /api/task-completions/ or /api/documents/ create call. + * + * PUT (not POST) because B2's S3-compatible endpoint does not implement + * the S3 POST Object form upload (returns HTTP 501). */ @Serializable data class PresignUploadResponse( val id: Int, @SerialName("upload_url") val uploadUrl: String, - val fields: Map, + val method: String = "PUT", + val headers: Map = emptyMap(), val key: String, @SerialName("expires_at") val expiresAt: String ) diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/UploadApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/UploadApi.kt index 84ab6fe..97290dd 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/UploadApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/UploadApi.kt @@ -5,7 +5,6 @@ import com.tt.honeyDue.models.PresignUploadResponse import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* -import io.ktor.client.request.forms.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.utils.io.core.* @@ -14,17 +13,16 @@ import io.ktor.utils.io.core.* * Three-step direct-to-B2 upload helper. * * Step 1: [presign] — call POST /api/uploads/presign on our API. Returns a - * B2 POST policy plus form fields the client needs to perform the - * direct upload. - * Step 2: [postToStorage] — multipart/form-data POST straight to B2. - * Bytes never traverse our API server. + * signed PUT URL plus the headers the client must send. + * Step 2: [putToStorage] — single PUT straight to B2. Bytes never traverse + * our API server. * Step 3: caller invokes the relevant entity-creation endpoint * (POST /api/task-completions/, POST /api/documents/) with the * returned upload_id in the `upload_ids` field. * - * iOS uses its own native equivalent (PresignedUploader.swift) for memory - * reasons — Swift can stream a multipart body without buffering. Android - * uses this Kotlin path which works fine for ≤10 MB images. + * iOS uses its own native equivalent (PresignedUploader.swift). Both paths + * use PUT because B2's S3-compatible endpoint does not implement the S3 + * POST Object form upload (returns HTTP 501 for any POST). */ class UploadApi(private val client: HttpClient = ApiClient.httpClient) { private val baseUrl = ApiClient.getBaseUrl() @@ -61,38 +59,36 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) { } /** - * Step 2 — POST `data` directly to B2 using the signed policy fields. + * Step 2 — PUT `data` directly to B2 using the signed URL + headers. * - * The S3 POST policy spec requires every signed field to appear before - * the file part, and `key` + `Content-Type` must match the policy - * exactly. Ktor's MultiPartFormDataContent preserves insertion order - * for the appended parts. + * The presign signature binds the headers exactly, so we send them + * verbatim. Content-Length is filled in automatically by Ktor from + * the body size, but we still pass through Content-Type which Ktor + * would otherwise default to application/octet-stream. */ - suspend fun postToStorage( + suspend fun putToStorage( uploadUrl: String, - fields: Map, + headers: Map, data: ByteArray, contentType: String, - fileName: String, ): ApiResult { return try { - val parts = formData { - // Stable order: signed fields first, then file. We rely on - // Ktor preserving the order in which append() is called. - fields.forEach { (k, v) -> append(k, v) } - append( - key = "file", - value = data, - headers = Headers.build { - append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"") - append(HttpHeaders.ContentType, contentType) - }, - ) + val response = client.put(uploadUrl) { + // Apply server-supplied headers verbatim. Skip Content-Length + // — Ktor sets it automatically from the body and will refuse + // a manual override on most engines. + headers.forEach { (k, v) -> + if (!k.equals("Content-Length", ignoreCase = true)) { + header(k, v) + } + } + // Defensive: ensure Content-Type is set even if the server + // omits it. The signed value (if present) takes precedence. + if (!headers.keys.any { it.equals("Content-Type", ignoreCase = true) }) { + header(HttpHeaders.ContentType, contentType) + } + setBody(data) } - val response = client.submitFormWithBinaryData( - url = uploadUrl, - formData = parts, - ) if (response.status.isSuccess()) { ApiResult.Success(Unit) } else { @@ -124,7 +120,7 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) { category: String, contentType: String, data: ByteArray, - fileName: String, + @Suppress("UNUSED_PARAMETER") fileName: String, ): ApiResult { val presignResult = presign(token, category, contentType, data.size.toLong()) val presigned = (presignResult as? ApiResult.Success)?.data @@ -133,16 +129,15 @@ class UploadApi(private val client: HttpClient = ApiClient.httpClient) { (presignResult as? ApiResult.Error)?.code, ) - val postResult = postToStorage( + val putResult = putToStorage( uploadUrl = presigned.uploadUrl, - fields = presigned.fields, + headers = presigned.headers, data = data, contentType = contentType, - fileName = fileName, ) - return when (postResult) { + return when (putResult) { is ApiResult.Success -> ApiResult.Success(presigned.id) - is ApiResult.Error -> postResult + is ApiResult.Error -> putResult else -> ApiResult.Error("Upload failed in unknown state") } } diff --git a/iosApp/iosApp/Helpers/PresignedUploader.swift b/iosApp/iosApp/Helpers/PresignedUploader.swift index 2f673ac..48d493b 100644 --- a/iosApp/iosApp/Helpers/PresignedUploader.swift +++ b/iosApp/iosApp/Helpers/PresignedUploader.swift @@ -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() - 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 {