import Foundation struct MastodonClient: PostingService { let account: Account private let token: String private let baseURL: String init(account: Account, token: String) { self.account = account self.token = token self.baseURL = account.instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String { let url = URL(string: "\(baseURL)/api/v2/media")! 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) 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 URLSession.shared.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 guard (200...202).contains(statusCode) else { let body = String(data: data, encoding: .utf8) ?? "Unknown error" 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 = URL(string: "\(baseURL)/api/v1/statuses")! var request = URLRequest(url: url) request.httpMethod = "POST" 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 URLSession.shared.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 guard statusCode == 200 else { let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error" 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 base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let url = URL(string: "\(base)/api/v1/apps")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: String] = [ "client_name": "qStatus", "redirect_uris": "qstatus://mastodon-callback", "scopes": "write:statuses write:media", "website": "https://github.com/nicedishy/qstatus", ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await URLSession.shared.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 guard statusCode == 200 else { throw PostingError(message: "Failed to register app on \(instanceURL)") } 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) -> URL { let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) var components = URLComponents(string: "\(base)/oauth/authorize")! 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"), ] return components.url! } static func exchangeCode( instanceURL: String, clientID: String, clientSecret: String, code: String ) async throws -> String { let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let url = URL(string: "\(base)/oauth/token")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") let params = [ "grant_type=authorization_code", "client_id=\(clientID)", "client_secret=\(clientSecret)", "redirect_uri=qstatus://mastodon-callback", "code=\(code)", ].joined(separator: "&") request.httpBody = params.data(using: .utf8) let (data, response) = try await URLSession.shared.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 guard statusCode == 200 else { throw PostingError(message: "Token exchange failed (\(statusCode))") } 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 base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let url = URL(string: "\(base)/api/v1/accounts/verify_credentials")! var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let (data, response) = try await URLSession.shared.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 guard statusCode == 200 else { throw PostingError(message: "Credential verification failed") } 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 } 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" } } }