import Foundation struct MicroblogClient: PostingService { let account: Account private let token: String private static let micropubURL = "https://micro.blog/micropub" private static let mediaURL = "https://micro.blog/micropub/media" init(account: Account, token: String) { self.account = account self.token = token } func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String { guard let url = URL(string: Self.mediaURL) else { throw PostingError(message: "Invalid Micro.blog media URL.") } var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 30 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") var form = MultipartFormData() form.addFile(name: "file", filename: filename, mimeType: mimeTypeForImage(filename), data: imageData) request.setValue(form.contentType, forHTTPHeaderField: "Content-Type") request.httpBody = form.finalized let (_, httpResponse) = try await NetworkSupport.data( for: request, context: "Micro.blog media upload" ) let statusCode = httpResponse.statusCode guard (200...202).contains(statusCode) else { throw PostingError(message: "Micro.blog media upload failed (\(statusCode))") } guard let location = httpResponse.value(forHTTPHeaderField: "Location") else { throw PostingError(message: "No Location header in Micro.blog media response") } return location } func createPost(text: String, mediaIDs: [String]) async throws -> URL { // mediaIDs are image URLs for Micro.blog guard let url = URL(string: Self.micropubURL) else { throw PostingError(message: "Invalid Micro.blog post URL.") } var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 30 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") if mediaIDs.isEmpty { // Simple form-encoded post request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") var components = URLComponents() components.queryItems = [ URLQueryItem(name: "h", value: "entry"), URLQueryItem(name: "content", value: text), ] let params = components.percentEncodedQuery ?? "h=entry&content=" request.httpBody = params.data(using: .utf8) } else { // JSON with HTML content including images request.setValue("application/json", forHTTPHeaderField: "Content-Type") var htmlContent = "

\(text)

" for imageURL in mediaIDs { htmlContent += "" } let body: [String: Any] = [ "type": ["h-entry"], "properties": [ "content": [["html": htmlContent]], ], ] request.httpBody = try JSONSerialization.data(withJSONObject: body) } let (data, httpResponse) = try await NetworkSupport.data( for: request, context: "Micro.blog post" ) let statusCode = httpResponse.statusCode guard (200...202).contains(statusCode) else { let body = NetworkSupport.responseBody(data) throw PostingError(message: "Micro.blog post failed (\(statusCode)): \(body)") } if let location = httpResponse.value(forHTTPHeaderField: "Location"), let postURL = URL(string: location) { return postURL } // Try to parse URL from response body if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let urlString = json["url"] as? String, let postURL = URL(string: urlString) { return postURL } guard let fallbackURL = URL(string: "https://micro.blog") else { throw PostingError(message: "Unable to build fallback Micro.blog URL.") } return fallbackURL } static func verifyToken(_ token: String) async throws -> String { guard let url = URL(string: "https://micro.blog/micropub?q=config") else { throw PostingError(message: "Invalid Micro.blog config URL.") } var request = URLRequest(url: url) request.timeoutInterval = 30 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let (data, response) = try await NetworkSupport.data( for: request, context: "Micro.blog token verification" ) let statusCode = response.statusCode guard statusCode == 200 else { throw PostingError(message: "Invalid Micro.blog token (HTTP \(statusCode)). Generate a new one at micro.blog/account/apps") } // Try to get destination info if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let destinations = json["destination"] as? [[String: Any]], let first = destinations.first, let name = first["name"] as? String { return name } return "Micro.blog" } }