Files
honeyDueKMP/iosApp/iosApp/Helpers/PresignedUploader.swift
T
Trey t fdcf82757d
Android UI Tests / ui-tests (push) Has been cancelled
fix(uploads): switch from S3 multipart POST to presigned PUT
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>
2026-05-06 15:48:54 -05:00

253 lines
9.4 KiB
Swift

import Foundation
import ComposeApp
/// Three-step direct-to-B2 image upload.
///
/// Flow:
/// 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 {
case notAuthenticated
case presignFailed(status: Int, body: String)
case uploadFailed(status: Int, body: String)
case sessionError(Error)
var errorDescription: String? {
switch self {
case .notAuthenticated:
return "You're not signed in."
case .presignFailed(let status, _):
switch status {
case 413: return "That photo is too large after resizing. Try a different one."
case 422: return "That image format isn't supported."
case 429: return "You're uploading too many photos. Try again in a few minutes."
default: return "Couldn't start upload (server returned \(status))."
}
case .uploadFailed(let status, _):
return "Upload failed (storage returned \(status))."
case .sessionError(let err):
return err.localizedDescription
}
}
}
/// Category passed to the presign endpoint. Matches the Go server's
/// `UploadCategory` constants in `internal/models/pending_upload.go`.
enum UploadCategory: String {
case completion = "completion"
case documentImage = "document_image"
case documentFile = "document_file"
}
/// Presigned-URL upload helper. Stateless instantiate freely.
///
/// Concurrency: each `upload(...)` call runs to completion sequentially.
/// For multiple images the caller can run several uploads in parallel via
/// `withTaskGroup`; the server's per-user concurrency cap (10 in-flight
/// presigns) is enforced server-side.
final class PresignedUploader {
/// API base URL read from KMP's ApiConfig so iOS and Android stay
/// in sync (LOCAL vs DEV vs PROD without divergent constants).
private let apiBaseURL: String
/// Bearer token. Read once at init; if the user re-auths mid-session,
/// the caller should construct a fresh PresignedUploader.
private let authToken: String
private let session: URLSession
init?(session: URLSession = .shared) {
// ApiConfig.shared.getBaseUrl() resolves Environment (LOCAL/DEV/PROD).
// DataManager.shared.authToken is a StateFlow<String?> read the
// current value via .value (SKIE-exposed property).
let baseUrl = ApiConfig.shared.getBaseUrl()
guard let token = DataManager.shared.authToken.value as String? else {
return nil
}
self.apiBaseURL = baseUrl
self.authToken = token
self.session = session
}
/// Upload `data` to B2 in the named category. Returns the
/// pending_uploads.id the caller passes via `upload_ids[]` to attach
/// the object to a real entity.
func upload(
data: Data,
category: UploadCategory,
contentType: String = "image/jpeg",
fileName: String = "image.jpg"
) async throws -> Int32 {
// Step 1: presign
let presigned = try await requestPresign(
category: category,
contentType: contentType,
contentLength: Int64(data.count)
)
// Step 2: direct PUT to B2
try await putToStorage(
uploadURL: presigned.uploadUrl,
headers: presigned.headers,
data: data,
contentType: contentType
)
return Int32(presigned.id)
}
/// Upload several images in parallel, returning their upload_ids in
/// input order. Stops at the first failure and surfaces it.
func uploadAll(
items: [(Data, String)],
category: UploadCategory,
contentType: String = "image/jpeg"
) async throws -> [Int32] {
try await withThrowingTaskGroup(of: (Int, Int32).self) { group in
for (idx, item) in items.enumerated() {
let (data, name) = item
group.addTask { [self] in
let id = try await upload(
data: data,
category: category,
contentType: contentType,
fileName: name
)
return (idx, id)
}
}
var pairs: [(Int, Int32)] = []
for try await pair in group {
pairs.append(pair)
}
return pairs.sorted { $0.0 < $1.0 }.map { $0.1 }
}
}
// MARK: - Step 1: presign
private struct PresignBody: Encodable {
let category: String
let content_type: String
let content_length: Int64
}
private struct PresignResponse: Decodable {
let id: Int
let upload_url: String
let method: String?
let headers: [String: String]
let key: String
let expires_at: String
// Map snake_case to nicer Swift names at the call site.
var uploadUrl: String { upload_url }
}
private func requestPresign(
category: UploadCategory,
contentType: String,
contentLength: Int64
) async throws -> PresignResponse {
guard var url = URL(string: apiBaseURL) else {
throw PresignedUploaderError.presignFailed(status: 0, body: "invalid base url")
}
url.appendPathComponent("uploads/presign/")
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Token \(authToken)", forHTTPHeaderField: "Authorization")
req.httpBody = try JSONEncoder().encode(PresignBody(
category: category.rawValue,
content_type: contentType,
content_length: contentLength
))
let (body, response): (Data, URLResponse)
do {
(body, response) = try await session.data(for: req)
} catch {
throw PresignedUploaderError.sessionError(error)
}
guard let http = response as? HTTPURLResponse else {
throw PresignedUploaderError.presignFailed(status: 0, body: "no response")
}
guard (200..<300).contains(http.statusCode) else {
throw PresignedUploaderError.presignFailed(
status: http.statusCode,
body: String(data: body, encoding: .utf8) ?? ""
)
}
do {
return try JSONDecoder().decode(PresignResponse.self, from: body)
} catch {
throw PresignedUploaderError.presignFailed(status: http.statusCode, body: "decode failed: \(error)")
}
}
// 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 putToStorage(
uploadURL: String,
headers: [String: String],
data: Data,
contentType: String
) async throws {
guard let url = URL(string: uploadURL) else {
throw PresignedUploaderError.uploadFailed(status: 0, body: "invalid upload url")
}
var req = URLRequest(url: url)
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 {
(respBody, response) = try await session.data(for: req)
} catch {
throw PresignedUploaderError.sessionError(error)
}
guard let http = response as? HTTPURLResponse else {
throw PresignedUploaderError.uploadFailed(status: 0, body: "no response")
}
guard (200..<300).contains(http.statusCode) else {
throw PresignedUploaderError.uploadFailed(
status: http.statusCode,
body: String(data: respBody, encoding: .utf8) ?? ""
)
}
}
}