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 { let url = URL(string: Self.mediaURL)! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") var form = MultipartFormData() form.addFile(name: "file", filename: filename, mimeType: mimeTypeFor(filename), data: imageData) request.setValue(form.contentType, forHTTPHeaderField: "Content-Type") request.httpBody = form.finalized let (_, response) = try await URLSession.shared.data(for: request) let httpResponse = response as? HTTPURLResponse let statusCode = httpResponse?.statusCode ?? 0 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 let url = URL(string: Self.micropubURL)! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") if mediaIDs.isEmpty { // Simple form-encoded post request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") let params = "h=entry&content=\(text.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" 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, response) = try await URLSession.shared.data(for: request) let httpResponse = response as? HTTPURLResponse let statusCode = httpResponse?.statusCode ?? 0 guard (200...202).contains(statusCode) else { let body = String(data: data, encoding: .utf8) ?? "" 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 } return URL(string: "https://micro.blog")! } static func verifyToken(_ token: String) async throws -> String { let url = URL(string: "https://micro.blog/micropub?q=config")! var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let data: Data let response: URLResponse do { (data, response) = try await URLSession.shared.data(for: request) } catch { throw PostingError(message: "Cannot connect to Micro.blog. Check your internet connection.") } let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 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" } private func mimeTypeFor(_ filename: String) -> String { let ext = (filename as NSString).pathExtension.lowercased() switch ext { case "png": return "image/png" case "gif": return "image/gif" case "webp": return "image/webp" default: return "image/jpeg" } } }