import Foundation struct MastodonClient: PostingService { let account: Account private let token: String private let baseURL: String init(account: Account, token: String) throws { self.account = account self.token = token self.baseURL = try URLNormalizer.normalizeBaseURL( account.instanceURL, fieldName: "Mastodon instance URL" ) } func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String { let url = try URLNormalizer.endpointURL(base: baseURL, path: "/api/v2/media") 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) if let altText, !altText.isEmpty { form.addField(name: "description", value: altText) } request.setValue(form.contentType, forHTTPHeaderField: "Content-Type") request.httpBody = form.finalized let (data, response) = try await NetworkSupport.data( for: request, context: "Mastodon media upload" ) let statusCode = response.statusCode guard (200...202).contains(statusCode) else { let body = NetworkSupport.responseBody(data) throw PostingError(message: "Mastodon media upload failed (\(statusCode)): \(body)") } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let mediaID = json["id"] as? String else { throw PostingError(message: "Invalid response from Mastodon media upload") } return mediaID } func createPost(text: String, mediaIDs: [String]) async throws -> URL { let url = try URLNormalizer.endpointURL(base: baseURL, path: "/api/v1/statuses") var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 30 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key") var body: [String: Any] = ["status": text, "visibility": "public"] if !mediaIDs.isEmpty { body["media_ids"] = mediaIDs } request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await NetworkSupport.data( for: request, context: "Mastodon post" ) let statusCode = response.statusCode guard statusCode == 200 else { let errorBody = NetworkSupport.responseBody(data) throw PostingError(message: "Mastodon post failed (\(statusCode)): \(errorBody)") } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let urlString = json["url"] as? String, let postURL = URL(string: urlString) else { throw PostingError(message: "Invalid response from Mastodon") } return postURL } // MARK: - OAuth Registration static func registerApp(instanceURL: String) async throws -> (clientID: String, clientSecret: String) { let url = try URLNormalizer.endpointURL( baseURL: instanceURL, path: "/api/v1/apps", fieldName: "Mastodon instance URL" ) var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 30 request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: String] = [ "client_name": "qStatus", "redirect_uris": "qstatus://mastodon-callback", "scopes": "write:statuses write:media", ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await NetworkSupport.data( for: request, context: "Mastodon app registration" ) let statusCode = response.statusCode guard statusCode == 200 else { throw PostingError( message: "Failed to register app on \(instanceURL) (\(statusCode)): \(NetworkSupport.responseBody(data))" ) } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let clientID = json["client_id"] as? String, let clientSecret = json["client_secret"] as? String else { throw PostingError(message: "Invalid registration response") } return (clientID, clientSecret) } static func authorizeURL(instanceURL: String, clientID: String) throws -> URL { let base = (try? URLNormalizer.normalizeBaseURL(instanceURL, fieldName: "Mastodon instance URL")) ?? instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard var components = URLComponents(string: "\(base)/oauth/authorize") else { throw PostingError(message: "Invalid Mastodon authorization URL.") } components.queryItems = [ URLQueryItem(name: "client_id", value: clientID), URLQueryItem(name: "scope", value: "write:statuses write:media"), URLQueryItem(name: "redirect_uri", value: "qstatus://mastodon-callback"), URLQueryItem(name: "response_type", value: "code"), ] guard let authURL = components.url else { throw PostingError(message: "Failed to build Mastodon authorization URL.") } return authURL } static func exchangeCode( instanceURL: String, clientID: String, clientSecret: String, code: String ) async throws -> String { let base = try URLNormalizer.normalizeBaseURL(instanceURL, fieldName: "Mastodon instance URL") guard let url = URL(string: "\(base)/oauth/token") else { throw PostingError(message: "Invalid Mastodon token endpoint URL.") } var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 30 request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") var components = URLComponents() components.queryItems = [ URLQueryItem(name: "grant_type", value: "authorization_code"), URLQueryItem(name: "client_id", value: clientID), URLQueryItem(name: "client_secret", value: clientSecret), URLQueryItem(name: "redirect_uri", value: "qstatus://mastodon-callback"), URLQueryItem(name: "code", value: code), ] let params = components.percentEncodedQuery ?? "" request.httpBody = params.data(using: .utf8) let (data, response) = try await NetworkSupport.data( for: request, context: "Mastodon token exchange" ) let statusCode = response.statusCode guard statusCode == 200 else { throw PostingError( message: "Token exchange failed (\(statusCode)): \(NetworkSupport.responseBody(data))" ) } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let token = json["access_token"] as? String else { throw PostingError(message: "Invalid token response") } return token } static func verifyCredentials(instanceURL: String, token: String) async throws -> String { let url = try URLNormalizer.endpointURL( baseURL: instanceURL, path: "/api/v1/accounts/verify_credentials", fieldName: "Mastodon instance 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: "Mastodon credential verification" ) let statusCode = response.statusCode guard statusCode == 200 else { throw PostingError( message: "Credential verification failed (\(statusCode)): \(NetworkSupport.responseBody(data))" ) } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let username = json["username"] as? String else { throw PostingError(message: "Invalid credentials response") } return username } }