qstatus/qStatus/Services/MastodonClient.swift
Paweł Orzech 733334df9b
Deduplicate shared constants, MIME helper, and cache normalized URLs
- Extract OAuth UserDefaults keys into shared OAuthKeys enum
- Extract triplicated mimeTypeFor() into shared mimeTypeForImage()
- Cache normalized base URL at client init instead of per-request
2026-03-07 22:22:08 +01:00

223 lines
8.6 KiB
Swift

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
}
}