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:
@@ -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<String, String>,
|
||||
val method: String = "PUT",
|
||||
val headers: Map<String, String> = emptyMap(),
|
||||
val key: String,
|
||||
@SerialName("expires_at") val expiresAt: String
|
||||
)
|
||||
|
||||
@@ -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<String, String>,
|
||||
headers: Map<String, String>,
|
||||
data: ByteArray,
|
||||
contentType: String,
|
||||
fileName: String,
|
||||
): ApiResult<Unit> {
|
||||
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<Int> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user