fdcf82757d
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>
253 lines
9.4 KiB
Swift
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) ?? ""
|
|
)
|
|
}
|
|
}
|
|
}
|