qstatus/qStatus/Services/WordPressClient.swift
Paweł Orzech c27437b33c
Initial implementation of qStatus macOS menubar app
Native macOS app for posting to Mastodon, WordPress (self-hosted), and Micro.blog.
Features: global hotkey (Ctrl+Option+Cmd+T), multiple accounts with selection,
image attachments (up to 4, drag-and-drop), floating panel UI, Keychain storage.
2026-02-27 23:40:51 +01:00

166 lines
6.8 KiB
Swift

import Foundation
struct WordPressClient: PostingService {
let account: Account
private let token: String
private let baseURL: String
init(account: Account, token: String) {
self.account = account
self.token = token // format: "username:application_password"
self.baseURL = account.instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
}
private var authHeader: String {
"Basic \(Data(token.utf8).base64EncodedString())"
}
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String {
let url = URL(string: "\(baseURL)/wp-json/wp/v2/media")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
request.setValue(mimeTypeFor(filename), forHTTPHeaderField: "Content-Type")
request.setValue("attachment; filename=\"\(filename)\"", forHTTPHeaderField: "Content-Disposition")
request.httpBody = imageData
let (data, response) = try await URLSession.shared.data(for: request)
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
guard (200...201).contains(statusCode) else {
let body = String(data: data, encoding: .utf8) ?? "Unknown error"
throw PostingError(message: "WordPress media upload failed (\(statusCode)): \(body)")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let mediaID = json["id"] as? Int
else {
throw PostingError(message: "Invalid response from WordPress media upload")
}
// Update alt text if provided
if let altText, !altText.isEmpty {
try await updateMediaAltText(mediaID: mediaID, altText: altText)
}
guard let sourceURL = json["source_url"] as? String else {
throw PostingError(message: "No source_url in WordPress media response")
}
return sourceURL
}
private func updateMediaAltText(mediaID: Int, altText: String) async throws {
let url = URL(string: "\(baseURL)/wp-json/wp/v2/media/\(mediaID)")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: ["alt_text": altText])
let (_, response) = try await URLSession.shared.data(for: request)
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
guard statusCode == 200 else {
throw PostingError(message: "Failed to update alt text (\(statusCode))")
}
}
func createPost(text: String, mediaIDs: [String]) async throws -> URL {
// mediaIDs are source_url strings for WordPress
let url = URL(string: "\(baseURL)/wp-json/wp/v2/posts")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
var content = text
// Append images as HTML
for imageURL in mediaIDs {
content += "\n<img src=\"\(imageURL)\" />"
}
let body: [String: Any] = [
"content": content,
"status": "publish",
"format": "status",
]
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 (200...201).contains(statusCode) else {
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
throw PostingError(message: "WordPress post failed (\(statusCode)): \(errorBody)")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let link = json["link"] as? String,
let postURL = URL(string: link)
else {
throw PostingError(message: "Invalid response from WordPress")
}
return postURL
}
// MARK: - Auth Discovery
static func discoverAuthEndpoint(siteURL: String) async throws -> String {
let base = siteURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let url = URL(string: "\(base)/wp-json/")!
var request = URLRequest(url: url)
request.httpMethod = "GET"
let (data, response) = try await URLSession.shared.data(for: request)
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
guard statusCode == 200 else {
throw PostingError(message: "Cannot reach WordPress API at \(siteURL)")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let auth = json["authentication"] as? [String: Any],
let appPasswords = auth["application-passwords"] as? [String: Any],
let endpoints = appPasswords["endpoints"] as? [String: Any],
let authURL = endpoints["authorization"] as? String
else {
throw PostingError(message: "Application Passwords not available on this site. Ensure WordPress 5.6+.")
}
return authURL
}
static func buildAuthURL(authEndpoint: String) -> URL {
var components = URLComponents(string: authEndpoint)!
components.queryItems = [
URLQueryItem(name: "app_name", value: "qStatus"),
URLQueryItem(name: "app_id", value: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
URLQueryItem(name: "success_url", value: "qstatus://wordpress-callback"),
URLQueryItem(name: "reject_url", value: "qstatus://wordpress-rejected"),
]
return components.url!
}
static func verifyCredentials(siteURL: String, username: String, password: String) async throws -> Bool {
let base = siteURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let url = URL(string: "\(base)/wp-json/wp/v2/users/me")!
var request = URLRequest(url: url)
let creds = "\(username):\(password)"
request.setValue("Basic \(Data(creds.utf8).base64EncodedString())", forHTTPHeaderField: "Authorization")
let (_, response) = try await URLSession.shared.data(for: request)
return (response as? HTTPURLResponse)?.statusCode == 200
}
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"
}
}
}