diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index fe30556..9ec88a5 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -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] { diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index c1b0eac..53307ca 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -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 } diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 13ca7e1..579d744 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -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(_ path: String, body: Body) async throws -> Response { guard let token = try? tokenStore.token() else { throw OutlineAPIError.tokenUnavailable diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineAttachment.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineAttachment.swift new file mode 100644 index 0000000..0563c06 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineAttachment.swift @@ -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] +} diff --git a/OutlineKit/Sources/OutlineKit/Networking/MultipartFormDataBuilder.swift b/OutlineKit/Sources/OutlineKit/Networking/MultipartFormDataBuilder.swift new file mode 100644 index 0000000..f6e7afc --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Networking/MultipartFormDataBuilder.swift @@ -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) } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/CreateAttachmentRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/CreateAttachmentRequest.swift new file mode 100644 index 0000000..8189b57 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/CreateAttachmentRequest.swift @@ -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 + } +} diff --git a/OutlineKit/Sources/OutlineKit/Requests/UpdateUserAvatarRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/UpdateUserAvatarRequest.swift new file mode 100644 index 0000000..f889ec9 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/UpdateUserAvatarRequest.swift @@ -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) + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index ffeb429..339efa8 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -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 {} diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 39cb57d..74a6aad 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -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/". + 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( diff --git a/OutlineKit/Tests/OutlineKitTests/MultipartFormDataBuilderTests.swift b/OutlineKit/Tests/OutlineKitTests/MultipartFormDataBuilderTests.swift new file mode 100644 index 0000000..dd5bbc3 --- /dev/null +++ b/OutlineKit/Tests/OutlineKitTests/MultipartFormDataBuilderTests.swift @@ -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) + } + } +}