feat(profile): avatar upload/remove backend (attachments + users.update)

Adds the presigned-upload plumbing Outline's own web client uses for
any file upload, not just avatars: attachments.create requests an
upload target (server-assigned key, ACL, a short-lived signed form),
then a direct multipart POST to that target (uploadUrl/form) actually
uploads the bytes — confirmed against a live server's own request/
response shapes pulled from network capture. MultipartFormDataBuilder
is a pure, fully-tested function (no network) building that S3-style
presigned-POST body.

uploadAttachmentFile deliberately sends no Bearer/CSRF header — the
presigned form's `sig` field is what authorizes that specific request,
same as an S3 presigned POST; flagged as best-effort pending live
confirmation, cheap to add if the server turns out to also want one.

UpdateUserAvatarRequest (users.update, avatar only) needed a custom
encode(to:) — Swift's synthesized Encodable omits nil Optional keys
via encodeIfPresent, but removing an avatar needs a literal
"avatarUrl": null in the body, not the key missing. Confirmed the
request shape (id + avatarUrl) from the captured request's
Content-Length matching that shape and no shorter alternative.

6 new tests (multipart body structure/field ordering, explicit-null
encoding, create/upload/update round trip) — 63/63 passing.
This commit is contained in:
2026-08-15 16:19:33 +01:00
parent 2e6de4baf4
commit 373c681c10
10 changed files with 369 additions and 0 deletions
@@ -335,6 +335,18 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
try await live.currentUser()
}
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
try await live.createAttachment(request)
}
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
try await live.uploadAttachmentFile(result, fileData: fileData)
}
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
try await live.updateUserAvatar(request)
}
// MARK: - Sync management (Settings surface)
public func pendingOperations() async -> [PendingOperationSummary] {
@@ -69,4 +69,12 @@ public protocol OutlineAPIClient: Sendable {
func deleteStar(id: String) async throws
func currentUser() async throws -> OutlineUser
/// Two-step presigned upload: this requests where/how to upload,
/// `uploadAttachmentFile` performs the actual multipart POST to that
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
/// `users.update`, avatar only. See `UpdateUserAvatarRequest`.
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser
}
@@ -229,6 +229,59 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("users.info", body: EmptyParams())
}
public func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult {
try await post("attachments.create", body: request)
}
/// No Bearer/CSRF header attached here, deliberately the presigned
/// `form.sig` field (short-lived, scoped to this exact upload key) is
/// what authorizes this specific request, the same way an S3 presigned
/// POST works. Best-effort against a live server: if it turns out the
/// self-hosted local-storage backend also wants a bearer token here,
/// that's a one-line addition once confirmed, not a design change.
public func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws {
let (body, contentType) = MultipartFormDataBuilder.build(
fields: result.form,
fileFieldName: "file",
fileName: result.attachment.name ?? "avatar",
fileData: fileData,
fileContentType: result.form["Content-Type"] ?? "application/octet-stream"
)
// `uploadUrl` is a host-relative path (e.g. "/api/files.create") on
// a self-hosted local-storage backend, not an absolute S3 URL
// resolving against `baseURL` handles both: `URL(string:relativeTo:)`
// replaces the whole path for a leading-slash relative string per
// RFC 3986, same resolution already used for user/team avatar URLs.
guard let uploadURL = URL(string: result.uploadUrl, relativeTo: baseURL)?.absoluteURL else {
throw OutlineAPIError.transport(URLError(.badURL))
}
var request = URLRequest(url: uploadURL)
request.httpMethod = "POST"
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
request.httpBody = body
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await httpClient.send(request)
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.transport(error)
}
guard (200...299).contains(response.statusCode) else {
let errorEnvelope = try? decoder.decode(OutlineErrorEnvelope.self, from: data)
throw OutlineAPIError.server(status: response.statusCode, message: errorEnvelope?.message ?? errorEnvelope?.error)
}
}
public func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser {
try await post("users.update", body: request)
}
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable
@@ -0,0 +1,26 @@
import Foundation
/// One uploaded file created via a two-step presigned upload
/// (`attachments.create` for the upload target, then a direct POST to
/// `uploadUrl`/`form`). Confirmed live against a self-hosted instance's
/// local-storage backend; `size` comes back as a string there, not a
/// number same "don't trust the vendored spec's implied types" lesson as
/// everywhere else this codebase has hit it.
public struct OutlineAttachment: Decodable, Identifiable, Sendable {
public let id: String
public let documentId: String?
public let contentType: String?
public let name: String?
public let url: String?
public let size: String?
}
/// `attachments.create`'s response everything needed to perform the
/// actual upload. `form` fields (Content-Type, key, acl, sig, `_csrf`, )
/// must all be included as their own multipart parts, in the same request
/// as the file itself, POSTed to `uploadUrl`.
public struct CreateAttachmentResult: Decodable, Sendable {
public let attachment: OutlineAttachment
public let uploadUrl: String
public let form: [String: String]
}
@@ -0,0 +1,40 @@
import Foundation
/// Builds the multipart/form-data body for Outline's presigned-upload
/// targets (`attachments.create`'s `uploadUrl`/`form`) an S3-style
/// presigned POST: every `form` field has to ride along as its own part in
/// the same request as the file, not as query params or headers.
enum MultipartFormDataBuilder {
static func build(
fields: [String: String],
fileFieldName: String,
fileName: String,
fileData: Data,
fileContentType: String,
boundary: String = "Boundary-\(UUID().uuidString)"
) -> (body: Data, contentType: String) {
var body = Data()
for (key, value) in fields {
body.append("--\(boundary)\r\n".utf8Data)
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".utf8Data)
body.append(value.utf8Data)
body.append("\r\n".utf8Data)
}
body.append("--\(boundary)\r\n".utf8Data)
body.append(
"Content-Disposition: form-data; name=\"\(fileFieldName)\"; filename=\"\(fileName)\"\r\n".utf8Data
)
body.append("Content-Type: \(fileContentType)\r\n\r\n".utf8Data)
body.append(fileData)
body.append("\r\n".utf8Data)
body.append("--\(boundary)--\r\n".utf8Data)
return (body, "multipart/form-data; boundary=\(boundary)")
}
}
private extension String {
var utf8Data: Data { Data(utf8) }
}
@@ -0,0 +1,19 @@
import Foundation
/// Requests an upload target for a new file not the upload itself, see
/// `OutlineAPIClient.uploadAttachmentFile`. `documentId: nil` is what a
/// user-avatar upload sends (confirmed live); a real value scopes the
/// attachment to a document instead (e.g. an inline image embed).
public struct CreateAttachmentRequest: Encodable, Sendable {
public let name: String
public let contentType: String
public let size: Int
public let documentId: String?
public init(name: String, contentType: String, size: Int, documentId: String? = nil) {
self.name = name
self.contentType = contentType
self.size = size
self.documentId = documentId
}
}
@@ -0,0 +1,28 @@
import Foundation
/// `users.update`, scoped to just the avatar. Custom `encode(to:)` because
/// Swift's synthesized `Encodable` uses `encodeIfPresent` for `Optional`
/// properties, which *omits* the key entirely when the value is `nil`
/// removing the avatar needs a literal `"avatarUrl": null` in the request
/// body, not the key missing. `id` is required: confirmed live (the
/// captured request body's length only matches `{"id": "...", "avatarUrl":
/// ...}`, not a shorter shape without it).
public struct UpdateUserAvatarRequest: Encodable, Sendable {
public let id: String
public let avatarUrl: String?
public init(id: String, avatarUrl: String?) {
self.id = id
self.avatarUrl = avatarUrl
}
private enum CodingKeys: String, CodingKey {
case id, avatarUrl
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(avatarUrl, forKey: .avatarUrl)
}
}
@@ -89,6 +89,9 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() }
func deleteStar(id: String) async throws { throw NotStubbed() }
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { throw NotStubbed() }
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
}
private struct StubTransportError: Error {}
@@ -993,6 +993,138 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list")
}
func testUpdateUserAvatarSendsExplicitNullWhenRemoving() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"avatarUrl": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserAvatar(UpdateUserAvatarRequest(id: "user-1", avatarUrl: nil))
XCTAssertNil(user.avatarUrl)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
// The whole point of UpdateUserAvatarRequest's custom encode(to:)
// Swift's synthesized Encodable would have omitted the key entirely
// for a nil Optional instead of sending a literal null.
XCTAssertTrue(sentJSON.contains("\"avatarUrl\":null"), "expected an explicit null, got: \(sentJSON)")
}
func testUpdateUserAvatarSendsTheNewURLWhenSet() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"avatarUrl": "/api/attachments.redirect?id=abc"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserAvatar(
UpdateUserAvatarRequest(id: "user-1", avatarUrl: "/api/attachments.redirect?id=abc")
)
XCTAssertEqual(user.avatarUrl, "/api/attachments.redirect?id=abc")
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
XCTAssertTrue(sentJSON.contains("\"id\":\"user-1\""))
}
func testCreateAttachmentDecodesUploadTargetAndFormFields() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"attachment": {
"id": "attach-1",
"documentId": null,
"contentType": "image/jpeg",
"name": "avatar.jpg",
"url": "/api/attachments.redirect?id=attach-1",
"size": "59304"
},
"uploadUrl": "/api/files.create",
"form": {
"Content-Type": "image/jpeg",
"key": "uploads/user-1/attach-1/avatar.jpg",
"acl": "public-read"
}
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let result = try await client.createAttachment(
CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: 59304)
)
XCTAssertEqual(result.attachment.id, "attach-1")
XCTAssertEqual(result.attachment.size, "59304")
XCTAssertEqual(result.uploadUrl, "/api/files.create")
XCTAssertEqual(result.form["acl"], "public-read")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.create")
}
func testUploadAttachmentFilePostsToTheResolvedRelativeURL() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let uploadTarget = CreateAttachmentResult(
attachment: OutlineAttachment(
id: "attach-1",
documentId: nil,
contentType: "image/jpeg",
name: "avatar.jpg",
url: "/api/attachments.redirect?id=attach-1",
size: "3"
),
uploadUrl: "/api/files.create",
form: ["Content-Type": "image/jpeg", "key": "uploads/attach-1"]
)
try await client.uploadAttachmentFile(uploadTarget, fileData: Data("abc".utf8))
// Resolved against baseURL's host, not appended onto "/api/<baseURL-relative-path>".
XCTAssertEqual(httpClient.lastRequest?.url?.absoluteString, "https://outline.example.com/api/files.create")
XCTAssertNil(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"))
let contentType = httpClient.lastRequest?.value(forHTTPHeaderField: "Content-Type")
XCTAssertTrue(contentType?.hasPrefix("multipart/form-data; boundary=") ?? false)
}
func testMissingTokenThrowsTokenUnavailable() async throws {
let httpClient = MockHTTPClient()
let client = LiveOutlineAPIClient(
@@ -0,0 +1,48 @@
import XCTest
@testable import OutlineKit
final class MultipartFormDataBuilderTests: XCTestCase {
func testBuildIncludesEveryFieldAndTheFile() throws {
let fileData = Data("fake-jpeg-bytes".utf8)
let (body, contentType) = MultipartFormDataBuilder.build(
fields: ["key": "uploads/abc", "acl": "public-read", "Content-Type": "image/jpeg"],
fileFieldName: "file",
fileName: "avatar.jpg",
fileData: fileData,
fileContentType: "image/jpeg",
boundary: "TestBoundary"
)
let bodyString = String(decoding: body, as: UTF8.self)
XCTAssertEqual(contentType, "multipart/form-data; boundary=TestBoundary")
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"key\""))
XCTAssertTrue(bodyString.contains("uploads/abc"))
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"acl\""))
XCTAssertTrue(bodyString.contains("public-read"))
XCTAssertTrue(bodyString.contains("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.jpg\""))
XCTAssertTrue(bodyString.contains("fake-jpeg-bytes"))
XCTAssertTrue(bodyString.hasPrefix("--TestBoundary\r\n"))
XCTAssertTrue(bodyString.hasSuffix("--TestBoundary--\r\n"))
}
func testFileFieldComesAfterAllFormFields() throws {
let (body, _) = MultipartFormDataBuilder.build(
fields: ["a": "1", "b": "2"],
fileFieldName: "file",
fileName: "x.jpg",
fileData: Data("bytes".utf8),
fileContentType: "image/jpeg",
boundary: "B"
)
let bodyString = String(decoding: body, as: UTF8.self)
let fieldsRange = bodyString.range(of: "name=\"a\"")
let fileRange = bodyString.range(of: "name=\"file\"")
XCTAssertNotNil(fieldsRange)
XCTAssertNotNil(fileRange)
if let fieldsRange, let fileRange {
XCTAssertTrue(fieldsRange.lowerBound < fileRange.lowerBound)
}
}
}