Add post validation checks

This commit is contained in:
Paweł Orzech 2026-03-03 19:37:18 +01:00
parent dfe4485fe9
commit 79f24ea1a4
No known key found for this signature in database
9 changed files with 381 additions and 124 deletions

View file

@ -4,6 +4,9 @@ import SwiftUI
@MainActor @MainActor
final class AppDelegate: NSObject, NSApplicationDelegate { final class AppDelegate: NSObject, NSApplicationDelegate {
private static let pendingMastodonAccountKey = "qstatus.pending-mastodon-account"
private static let pendingMastodonErrorKey = "qstatus.pending-mastodon-account-error"
let appState = AppState() let appState = AppState()
let accountStore = AccountStore() let accountStore = AccountStore()
var floatingPanel: FloatingPanel? var floatingPanel: FloatingPanel?
@ -82,12 +85,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func performPost() { func performPost() {
guard appState.canPost else { return } guard appState.canPost else { return }
appState.isSubmitting = true
appState.statusMessage = nil
let selectedAccounts = accountStore.accounts.filter { let selectedAccounts = accountStore.accounts.filter {
appState.selectedAccountIDs.contains($0.id) appState.selectedAccountIDs.contains($0.id)
} }
do {
try InputValidator.validatePost(
text: appState.inputText,
images: appState.attachedImages,
accounts: selectedAccounts
)
} catch {
appState.statusMessage = StatusMessage(text: error.localizedDescription, isError: true)
return
}
appState.isSubmitting = true
appState.statusMessage = nil
Task { @MainActor in Task { @MainActor in
let results = await PostingManager.post( let results = await PostingManager.post(
@ -189,7 +203,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
guard let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { return } guard let code = components.queryItems?.first(where: { $0.name == "code" })?.value else { return }
Task { Task {
guard let accountData = UserDefaults.standard.data(forKey: "qstatus.pending-mastodon-account"), guard let accountData = UserDefaults.standard.data(forKey: Self.pendingMastodonAccountKey),
var account = try? JSONDecoder().decode(Account.self, from: accountData), var account = try? JSONDecoder().decode(Account.self, from: accountData),
let clientID = account.mastodonClientID, let clientID = account.mastodonClientID,
let clientSecret = account.mastodonClientSecret let clientSecret = account.mastodonClientSecret
@ -212,9 +226,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
account.displayName = "@\(username)@\(account.instanceURL.replacingOccurrences(of: "https://", with: ""))" account.displayName = "@\(username)@\(account.instanceURL.replacingOccurrences(of: "https://", with: ""))"
try accountStore.addAccount(account, token: token) try accountStore.addAccount(account, token: token)
UserDefaults.standard.removeObject(forKey: "qstatus.pending-mastodon-account") UserDefaults.standard.removeObject(forKey: Self.pendingMastodonAccountKey)
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonErrorKey)
} catch { } catch {
print("Mastodon auth error: \(error)") UserDefaults.standard.set(
error.localizedDescription,
forKey: Self.pendingMastodonErrorKey
)
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonAccountKey)
} }
} }
} }
@ -225,13 +244,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let siteURL = components.queryItems?.first(where: { $0.name == "site_url" })?.value let siteURL = components.queryItems?.first(where: { $0.name == "site_url" })?.value
else { return } else { return }
let credentials = "\(userLogin):\(password)" Task {
let account = Account( do {
serviceType: .wordpress, let normalizedSiteURL = try URLNormalizer.normalizeBaseURL(
displayName: "\(userLogin)@\(siteURL.replacingOccurrences(of: "https://", with: ""))", siteURL,
instanceURL: siteURL, fieldName: "WordPress site URL"
username: userLogin )
) let isValid = try await WordPressClient.verifyCredentials(
try? accountStore.addAccount(account, token: credentials) siteURL: normalizedSiteURL,
username: userLogin,
password: password
)
guard isValid else { return }
let credentials = "\(userLogin):\(password)"
let account = Account(
serviceType: .wordpress,
displayName: "\(userLogin)@\(normalizedSiteURL.replacingOccurrences(of: "https://", with: ""))",
instanceURL: normalizedSiteURL,
username: userLogin
)
try accountStore.addAccount(account, token: credentials)
} catch {
print("WordPress auth callback error: \(error.localizedDescription)")
}
}
} }
} }

View file

@ -12,6 +12,14 @@ final class AccountStore {
} }
func addAccount(_ account: Account, token: String) throws { func addAccount(_ account: Account, token: String) throws {
if accounts.contains(where: {
$0.serviceType == account.serviceType
&& $0.instanceURL.caseInsensitiveCompare(account.instanceURL) == .orderedSame
&& $0.username.caseInsensitiveCompare(account.username) == .orderedSame
}) {
throw PostingError(message: "This account is already added.")
}
try KeychainManager.saveToken(token, forAccount: account.keychainKey) try KeychainManager.saveToken(token, forAccount: account.keychainKey)
accounts.append(account) accounts.append(account)
saveAccounts() saveAccounts()

View file

@ -3,18 +3,23 @@ import Foundation
struct MastodonClient: PostingService { struct MastodonClient: PostingService {
let account: Account let account: Account
private let token: String private let token: String
private let baseURL: String private let rawBaseURL: String
init(account: Account, token: String) { init(account: Account, token: String) {
self.account = account self.account = account
self.token = token self.token = token
self.baseURL = account.instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) self.rawBaseURL = account.instanceURL
} }
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String { func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String {
let url = URL(string: "\(baseURL)/api/v2/media")! let url = try URLNormalizer.endpointURL(
baseURL: rawBaseURL,
path: "/api/v2/media",
fieldName: "Mastodon instance URL"
)
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
var form = MultipartFormData() var form = MultipartFormData()
@ -26,11 +31,14 @@ struct MastodonClient: PostingService {
request.setValue(form.contentType, forHTTPHeaderField: "Content-Type") request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")
request.httpBody = form.finalized request.httpBody = form.finalized
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await NetworkSupport.data(
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 for: request,
context: "Mastodon media upload"
)
let statusCode = response.statusCode
guard (200...202).contains(statusCode) else { guard (200...202).contains(statusCode) else {
let body = String(data: data, encoding: .utf8) ?? "Unknown error" let body = NetworkSupport.responseBody(data)
throw PostingError(message: "Mastodon media upload failed (\(statusCode)): \(body)") throw PostingError(message: "Mastodon media upload failed (\(statusCode)): \(body)")
} }
@ -44,9 +52,14 @@ struct MastodonClient: PostingService {
} }
func createPost(text: String, mediaIDs: [String]) async throws -> URL { func createPost(text: String, mediaIDs: [String]) async throws -> URL {
let url = URL(string: "\(baseURL)/api/v1/statuses")! let url = try URLNormalizer.endpointURL(
baseURL: rawBaseURL,
path: "/api/v1/statuses",
fieldName: "Mastodon instance URL"
)
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key") request.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key")
@ -58,11 +71,14 @@ struct MastodonClient: PostingService {
request.httpBody = try JSONSerialization.data(withJSONObject: body) request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await NetworkSupport.data(
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 for: request,
context: "Mastodon post"
)
let statusCode = response.statusCode
guard statusCode == 200 else { guard statusCode == 200 else {
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error" let errorBody = NetworkSupport.responseBody(data)
throw PostingError(message: "Mastodon post failed (\(statusCode)): \(errorBody)") throw PostingError(message: "Mastodon post failed (\(statusCode)): \(errorBody)")
} }
@ -79,12 +95,14 @@ struct MastodonClient: PostingService {
// MARK: - OAuth Registration // MARK: - OAuth Registration
static func registerApp(instanceURL: String) async throws -> (clientID: String, clientSecret: String) { static func registerApp(instanceURL: String) async throws -> (clientID: String, clientSecret: String) {
let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let url = try URLNormalizer.endpointURL(
guard let url = URL(string: "\(base)/api/v1/apps") else { baseURL: instanceURL,
throw PostingError(message: "Invalid instance URL: \(instanceURL)") path: "/api/v1/apps",
} fieldName: "Mastodon instance URL"
)
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: String] = [ let body: [String: String] = [
@ -94,17 +112,16 @@ struct MastodonClient: PostingService {
] ]
request.httpBody = try JSONSerialization.data(withJSONObject: body) request.httpBody = try JSONSerialization.data(withJSONObject: body)
let data: Data let (data, response) = try await NetworkSupport.data(
let response: URLResponse for: request,
do { context: "Mastodon app registration"
(data, response) = try await URLSession.shared.data(for: request) )
} catch { let statusCode = response.statusCode
throw PostingError(message: "Cannot connect to \(instanceURL). Check the URL and try again.")
}
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
guard statusCode == 200 else { guard statusCode == 200 else {
throw PostingError(message: "Failed to register app on \(instanceURL)") throw PostingError(
message: "Failed to register app on \(instanceURL) (\(statusCode)): \(NetworkSupport.responseBody(data))"
)
} }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
@ -117,42 +134,57 @@ struct MastodonClient: PostingService {
return (clientID, clientSecret) return (clientID, clientSecret)
} }
static func authorizeURL(instanceURL: String, clientID: String) -> URL { static func authorizeURL(instanceURL: String, clientID: String) throws -> URL {
let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let base = (try? URLNormalizer.normalizeBaseURL(instanceURL, fieldName: "Mastodon instance URL"))
var components = URLComponents(string: "\(base)/oauth/authorize")! ?? instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard var components = URLComponents(string: "\(base)/oauth/authorize") else {
throw PostingError(message: "Invalid Mastodon authorization URL.")
}
components.queryItems = [ components.queryItems = [
URLQueryItem(name: "client_id", value: clientID), URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "scope", value: "write:statuses write:media"), URLQueryItem(name: "scope", value: "write:statuses write:media"),
URLQueryItem(name: "redirect_uri", value: "qstatus://mastodon-callback"), URLQueryItem(name: "redirect_uri", value: "qstatus://mastodon-callback"),
URLQueryItem(name: "response_type", value: "code"), URLQueryItem(name: "response_type", value: "code"),
] ]
return components.url! guard let authURL = components.url else {
throw PostingError(message: "Failed to build Mastodon authorization URL.")
}
return authURL
} }
static func exchangeCode( static func exchangeCode(
instanceURL: String, clientID: String, clientSecret: String, code: String instanceURL: String, clientID: String, clientSecret: String, code: String
) async throws -> String { ) async throws -> String {
let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let base = try URLNormalizer.normalizeBaseURL(instanceURL, fieldName: "Mastodon instance URL")
let url = URL(string: "\(base)/oauth/token")! guard let url = URL(string: "\(base)/oauth/token") else {
throw PostingError(message: "Invalid Mastodon token endpoint URL.")
}
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let params = [ var components = URLComponents()
"grant_type=authorization_code", components.queryItems = [
"client_id=\(clientID)", URLQueryItem(name: "grant_type", value: "authorization_code"),
"client_secret=\(clientSecret)", URLQueryItem(name: "client_id", value: clientID),
"redirect_uri=qstatus://mastodon-callback", URLQueryItem(name: "client_secret", value: clientSecret),
"code=\(code)", URLQueryItem(name: "redirect_uri", value: "qstatus://mastodon-callback"),
].joined(separator: "&") URLQueryItem(name: "code", value: code),
]
let params = components.percentEncodedQuery ?? ""
request.httpBody = params.data(using: .utf8) request.httpBody = params.data(using: .utf8)
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await NetworkSupport.data(
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 for: request,
context: "Mastodon token exchange"
)
let statusCode = response.statusCode
guard statusCode == 200 else { guard statusCode == 200 else {
throw PostingError(message: "Token exchange failed (\(statusCode))") throw PostingError(
message: "Token exchange failed (\(statusCode)): \(NetworkSupport.responseBody(data))"
)
} }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
@ -165,16 +197,25 @@ struct MastodonClient: PostingService {
} }
static func verifyCredentials(instanceURL: String, token: String) async throws -> String { static func verifyCredentials(instanceURL: String, token: String) async throws -> String {
let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let url = try URLNormalizer.endpointURL(
let url = URL(string: "\(base)/api/v1/accounts/verify_credentials")! baseURL: instanceURL,
path: "/api/v1/accounts/verify_credentials",
fieldName: "Mastodon instance URL"
)
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await NetworkSupport.data(
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 for: request,
context: "Mastodon credential verification"
)
let statusCode = response.statusCode
guard statusCode == 200 else { guard statusCode == 200 else {
throw PostingError(message: "Credential verification failed") throw PostingError(
message: "Credential verification failed (\(statusCode)): \(NetworkSupport.responseBody(data))"
)
} }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],

View file

@ -13,9 +13,12 @@ struct MicroblogClient: PostingService {
} }
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String { func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String {
let url = URL(string: Self.mediaURL)! guard let url = URL(string: Self.mediaURL) else {
throw PostingError(message: "Invalid Micro.blog media URL.")
}
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
var form = MultipartFormData() var form = MultipartFormData()
@ -24,15 +27,17 @@ struct MicroblogClient: PostingService {
request.setValue(form.contentType, forHTTPHeaderField: "Content-Type") request.setValue(form.contentType, forHTTPHeaderField: "Content-Type")
request.httpBody = form.finalized request.httpBody = form.finalized
let (_, response) = try await URLSession.shared.data(for: request) let (_, httpResponse) = try await NetworkSupport.data(
let httpResponse = response as? HTTPURLResponse for: request,
let statusCode = httpResponse?.statusCode ?? 0 context: "Micro.blog media upload"
)
let statusCode = httpResponse.statusCode
guard (200...202).contains(statusCode) else { guard (200...202).contains(statusCode) else {
throw PostingError(message: "Micro.blog media upload failed (\(statusCode))") throw PostingError(message: "Micro.blog media upload failed (\(statusCode))")
} }
guard let location = httpResponse?.value(forHTTPHeaderField: "Location") else { guard let location = httpResponse.value(forHTTPHeaderField: "Location") else {
throw PostingError(message: "No Location header in Micro.blog media response") throw PostingError(message: "No Location header in Micro.blog media response")
} }
@ -41,15 +46,23 @@ struct MicroblogClient: PostingService {
func createPost(text: String, mediaIDs: [String]) async throws -> URL { func createPost(text: String, mediaIDs: [String]) async throws -> URL {
// mediaIDs are image URLs for Micro.blog // mediaIDs are image URLs for Micro.blog
let url = URL(string: Self.micropubURL)! guard let url = URL(string: Self.micropubURL) else {
throw PostingError(message: "Invalid Micro.blog post URL.")
}
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
if mediaIDs.isEmpty { if mediaIDs.isEmpty {
// Simple form-encoded post // Simple form-encoded post
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let params = "h=entry&content=\(text.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" var components = URLComponents()
components.queryItems = [
URLQueryItem(name: "h", value: "entry"),
URLQueryItem(name: "content", value: text),
]
let params = components.percentEncodedQuery ?? "h=entry&content="
request.httpBody = params.data(using: .utf8) request.httpBody = params.data(using: .utf8)
} else { } else {
// JSON with HTML content including images // JSON with HTML content including images
@ -69,16 +82,18 @@ struct MicroblogClient: PostingService {
request.httpBody = try JSONSerialization.data(withJSONObject: body) request.httpBody = try JSONSerialization.data(withJSONObject: body)
} }
let (data, response) = try await URLSession.shared.data(for: request) let (data, httpResponse) = try await NetworkSupport.data(
let httpResponse = response as? HTTPURLResponse for: request,
let statusCode = httpResponse?.statusCode ?? 0 context: "Micro.blog post"
)
let statusCode = httpResponse.statusCode
guard (200...202).contains(statusCode) else { guard (200...202).contains(statusCode) else {
let body = String(data: data, encoding: .utf8) ?? "" let body = NetworkSupport.responseBody(data)
throw PostingError(message: "Micro.blog post failed (\(statusCode)): \(body)") throw PostingError(message: "Micro.blog post failed (\(statusCode)): \(body)")
} }
if let location = httpResponse?.value(forHTTPHeaderField: "Location"), if let location = httpResponse.value(forHTTPHeaderField: "Location"),
let postURL = URL(string: location) { let postURL = URL(string: location) {
return postURL return postURL
} }
@ -90,22 +105,25 @@ struct MicroblogClient: PostingService {
return postURL return postURL
} }
return URL(string: "https://micro.blog")! guard let fallbackURL = URL(string: "https://micro.blog") else {
throw PostingError(message: "Unable to build fallback Micro.blog URL.")
}
return fallbackURL
} }
static func verifyToken(_ token: String) async throws -> String { static func verifyToken(_ token: String) async throws -> String {
let url = URL(string: "https://micro.blog/micropub?q=config")! guard let url = URL(string: "https://micro.blog/micropub?q=config") else {
throw PostingError(message: "Invalid Micro.blog config URL.")
}
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let data: Data let (data, response) = try await NetworkSupport.data(
let response: URLResponse for: request,
do { context: "Micro.blog token verification"
(data, response) = try await URLSession.shared.data(for: request) )
} catch { let statusCode = response.statusCode
throw PostingError(message: "Cannot connect to Micro.blog. Check your internet connection.")
}
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
guard statusCode == 200 else { guard statusCode == 200 else {
throw PostingError(message: "Invalid Micro.blog token (HTTP \(statusCode)). Generate a new one at micro.blog/account/apps") throw PostingError(message: "Invalid Micro.blog token (HTTP \(statusCode)). Generate a new one at micro.blog/account/apps")

View file

@ -3,12 +3,12 @@ import Foundation
struct WordPressClient: PostingService { struct WordPressClient: PostingService {
let account: Account let account: Account
private let token: String private let token: String
private let baseURL: String private let rawBaseURL: String
init(account: Account, token: String) { init(account: Account, token: String) {
self.account = account self.account = account
self.token = token // format: "username:application_password" self.token = token // format: "username:application_password"
self.baseURL = account.instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) self.rawBaseURL = account.instanceURL
} }
private var authHeader: String { private var authHeader: String {
@ -16,19 +16,27 @@ struct WordPressClient: PostingService {
} }
func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String { func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String {
let url = URL(string: "\(baseURL)/wp-json/wp/v2/media")! let url = try URLNormalizer.endpointURL(
baseURL: rawBaseURL,
path: "/wp-json/wp/v2/media",
fieldName: "WordPress site URL"
)
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue(authHeader, forHTTPHeaderField: "Authorization") request.setValue(authHeader, forHTTPHeaderField: "Authorization")
request.setValue(mimeTypeFor(filename), forHTTPHeaderField: "Content-Type") request.setValue(mimeTypeFor(filename), forHTTPHeaderField: "Content-Type")
request.setValue("attachment; filename=\"\(filename)\"", forHTTPHeaderField: "Content-Disposition") request.setValue("attachment; filename=\"\(filename)\"", forHTTPHeaderField: "Content-Disposition")
request.httpBody = imageData request.httpBody = imageData
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await NetworkSupport.data(
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 for: request,
context: "WordPress media upload"
)
let statusCode = response.statusCode
guard (200...201).contains(statusCode) else { guard (200...201).contains(statusCode) else {
let body = String(data: data, encoding: .utf8) ?? "Unknown error" let body = NetworkSupport.responseBody(data)
throw PostingError(message: "WordPress media upload failed (\(statusCode)): \(body)") throw PostingError(message: "WordPress media upload failed (\(statusCode)): \(body)")
} }
@ -51,15 +59,23 @@ struct WordPressClient: PostingService {
} }
private func updateMediaAltText(mediaID: Int, altText: String) async throws { private func updateMediaAltText(mediaID: Int, altText: String) async throws {
let url = URL(string: "\(baseURL)/wp-json/wp/v2/media/\(mediaID)")! let url = try URLNormalizer.endpointURL(
baseURL: rawBaseURL,
path: "/wp-json/wp/v2/media/\(mediaID)",
fieldName: "WordPress site URL"
)
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue(authHeader, forHTTPHeaderField: "Authorization") request.setValue(authHeader, forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: ["alt_text": altText]) request.httpBody = try JSONSerialization.data(withJSONObject: ["alt_text": altText])
let (_, response) = try await URLSession.shared.data(for: request) let (_, response) = try await NetworkSupport.data(
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 for: request,
context: "WordPress media alt-text update"
)
let statusCode = response.statusCode
guard statusCode == 200 else { guard statusCode == 200 else {
throw PostingError(message: "Failed to update alt text (\(statusCode))") throw PostingError(message: "Failed to update alt text (\(statusCode))")
} }
@ -67,9 +83,14 @@ struct WordPressClient: PostingService {
func createPost(text: String, mediaIDs: [String]) async throws -> URL { func createPost(text: String, mediaIDs: [String]) async throws -> URL {
// mediaIDs are source_url strings for WordPress // mediaIDs are source_url strings for WordPress
let url = URL(string: "\(baseURL)/wp-json/wp/v2/posts")! let url = try URLNormalizer.endpointURL(
baseURL: rawBaseURL,
path: "/wp-json/wp/v2/posts",
fieldName: "WordPress site URL"
)
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.timeoutInterval = 30
request.setValue(authHeader, forHTTPHeaderField: "Authorization") request.setValue(authHeader, forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
@ -87,11 +108,14 @@ struct WordPressClient: PostingService {
request.httpBody = try JSONSerialization.data(withJSONObject: body) request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await NetworkSupport.data(
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 for: request,
context: "WordPress post"
)
let statusCode = response.statusCode
guard (200...201).contains(statusCode) else { guard (200...201).contains(statusCode) else {
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error" let errorBody = NetworkSupport.responseBody(data)
throw PostingError(message: "WordPress post failed (\(statusCode)): \(errorBody)") throw PostingError(message: "WordPress post failed (\(statusCode)): \(errorBody)")
} }
@ -108,13 +132,20 @@ struct WordPressClient: PostingService {
// MARK: - Auth Discovery // MARK: - Auth Discovery
static func discoverAuthEndpoint(siteURL: String) async throws -> String { static func discoverAuthEndpoint(siteURL: String) async throws -> String {
let base = siteURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let url = try URLNormalizer.endpointURL(
let url = URL(string: "\(base)/wp-json/")! baseURL: siteURL,
path: "/wp-json/",
fieldName: "WordPress site URL"
)
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "GET" request.httpMethod = "GET"
request.timeoutInterval = 30
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await NetworkSupport.data(
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 for: request,
context: "WordPress API discovery"
)
let statusCode = response.statusCode
guard statusCode == 200 else { guard statusCode == 200 else {
throw PostingError(message: "Cannot reach WordPress API at \(siteURL)") throw PostingError(message: "Cannot reach WordPress API at \(siteURL)")
@ -133,33 +164,34 @@ struct WordPressClient: PostingService {
} }
static func buildAuthURL(authEndpoint: String) -> URL { static func buildAuthURL(authEndpoint: String) -> URL {
var components = URLComponents(string: authEndpoint)! guard var components = URLComponents(string: authEndpoint) else {
return URL(fileURLWithPath: "/")
}
components.queryItems = [ components.queryItems = [
URLQueryItem(name: "app_name", value: "qStatus"), URLQueryItem(name: "app_name", value: "qStatus"),
URLQueryItem(name: "app_id", value: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"), URLQueryItem(name: "app_id", value: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
URLQueryItem(name: "success_url", value: "qstatus://wordpress-callback"), URLQueryItem(name: "success_url", value: "qstatus://wordpress-callback"),
URLQueryItem(name: "reject_url", value: "qstatus://wordpress-rejected"), URLQueryItem(name: "reject_url", value: "qstatus://wordpress-rejected"),
] ]
return components.url! return components.url ?? URL(fileURLWithPath: "/")
} }
static func verifyCredentials(siteURL: String, username: String, password: String) async throws -> Bool { static func verifyCredentials(siteURL: String, username: String, password: String) async throws -> Bool {
let base = siteURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let url = try URLNormalizer.endpointURL(
guard let url = URL(string: "\(base)/wp-json/wp/v2/users/me") else { baseURL: siteURL,
throw PostingError(message: "Invalid site URL: \(siteURL)") path: "/wp-json/wp/v2/users/me",
} fieldName: "WordPress site URL"
)
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.timeoutInterval = 30
let creds = "\(username):\(password)" let creds = "\(username):\(password)"
request.setValue("Basic \(Data(creds.utf8).base64EncodedString())", forHTTPHeaderField: "Authorization") request.setValue("Basic \(Data(creds.utf8).base64EncodedString())", forHTTPHeaderField: "Authorization")
let (data, response): (Data, URLResponse) let (data, response) = try await NetworkSupport.data(
do { for: request,
(data, response) = try await URLSession.shared.data(for: request) context: "WordPress credential verification"
} catch { )
throw PostingError(message: "Cannot connect to \(siteURL). Check the URL and try again.") let statusCode = response.statusCode
}
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
switch statusCode { switch statusCode {
case 200: case 200:
return true return true
@ -168,7 +200,7 @@ struct WordPressClient: PostingService {
case 404: case 404:
throw PostingError(message: "WordPress REST API not found at \(siteURL). Check the URL.") throw PostingError(message: "WordPress REST API not found at \(siteURL). Check the URL.")
default: default:
let body = String(data: data, encoding: .utf8) ?? "" let body = NetworkSupport.responseBody(data)
throw PostingError(message: "Unexpected response (\(statusCode)): \(body)") throw PostingError(message: "Unexpected response (\(statusCode)): \(body)")
} }
} }

View file

@ -0,0 +1,37 @@
import Foundation
enum InputValidator {
private static let mastodonCharacterLimit = 500
private static let maxImages = 4
private static let maxImageBytes = 15 * 1024 * 1024
static func validatePost(text: String, images: [ImageAttachment], accounts: [Account]) throws {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw PostingError(message: "Post cannot be empty.")
}
guard !accounts.isEmpty else {
throw PostingError(message: "Select at least one account.")
}
guard images.count <= maxImages else {
throw PostingError(message: "You can attach up to \(maxImages) images.")
}
for image in images {
guard !image.data.isEmpty else {
throw PostingError(message: "One of the selected images is empty or unreadable.")
}
guard image.data.count <= maxImageBytes else {
throw PostingError(message: "Image \(image.filename) is too large. Max size is 15 MB.")
}
}
if accounts.contains(where: { $0.serviceType == .mastodon }) && text.count > mastodonCharacterLimit {
throw PostingError(
message: "Mastodon posts are limited to \(mastodonCharacterLimit) characters."
)
}
}
}

View file

@ -0,0 +1,40 @@
import Foundation
enum NetworkSupport {
static func data(for request: URLRequest, context: String) async throws -> (Data, HTTPURLResponse) {
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw PostingError(message: "\(context): invalid server response.")
}
return (data, http)
} catch let error as PostingError {
throw error
} catch let error as URLError {
throw PostingError(message: "\(context): \(networkMessage(for: error.code))")
} catch {
throw PostingError(message: "\(context): \(error.localizedDescription)")
}
}
static func responseBody(_ data: Data) -> String {
let body = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return body.isEmpty ? "Empty response body" : body
}
private static func networkMessage(for code: URLError.Code) -> String {
switch code {
case .notConnectedToInternet:
return "No internet connection."
case .timedOut:
return "Request timed out."
case .cannotFindHost, .cannotConnectToHost:
return "Cannot connect to host."
case .secureConnectionFailed:
return "TLS/SSL connection failed."
default:
return "Network error (\(code.rawValue))."
}
}
}

View file

@ -0,0 +1,42 @@
import Foundation
enum URLNormalizer {
static func normalizeBaseURL(_ input: String, fieldName: String = "URL") throws -> String {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw PostingError(message: "\(fieldName) cannot be empty.")
}
let prefixed = if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://") {
trimmed
} else {
"https://\(trimmed)"
}
guard var components = URLComponents(string: prefixed),
let scheme = components.scheme?.lowercased(),
(scheme == "https" || scheme == "http"),
components.host != nil
else {
throw PostingError(message: "Invalid \(fieldName). Please provide a full domain, e.g. example.com.")
}
components.path = ""
components.query = nil
components.fragment = nil
guard let normalized = components.url?.absoluteString else {
throw PostingError(message: "Invalid \(fieldName).")
}
return normalized.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
}
static func endpointURL(baseURL: String, path: String, fieldName: String = "URL") throws -> URL {
let normalizedBase = try normalizeBaseURL(baseURL, fieldName: fieldName)
guard let url = URL(string: normalizedBase + path) else {
throw PostingError(message: "Failed to build API URL from \(fieldName).")
}
return url
}
}

View file

@ -73,6 +73,9 @@ struct SettingsView: View {
} }
struct AddAccountView: View { struct AddAccountView: View {
private static let pendingMastodonAccountKey = "qstatus.pending-mastodon-account"
private static let pendingMastodonErrorKey = "qstatus.pending-mastodon-account-error"
let accountStore: AccountStore let accountStore: AccountStore
@Binding var isPresented: Bool @Binding var isPresented: Bool
@State private var selectedService: ServiceType = .mastodon @State private var selectedService: ServiceType = .mastodon
@ -130,7 +133,8 @@ struct AddAccountView: View {
Button(waitingForOAuth ? "Cancel Auth" : "Cancel") { Button(waitingForOAuth ? "Cancel Auth" : "Cancel") {
if waitingForOAuth { if waitingForOAuth {
waitingForOAuth = false waitingForOAuth = false
UserDefaults.standard.removeObject(forKey: "qstatus.pending-mastodon-account") UserDefaults.standard.removeObject(forKey: Self.pendingMastodonAccountKey)
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonErrorKey)
} }
isPresented = false isPresented = false
} }
@ -253,10 +257,7 @@ struct AddAccountView: View {
} }
private func addMastodonAccount() async throws { private func addMastodonAccount() async throws {
var url = instanceURL.trimmingCharacters(in: .whitespaces) let url = try URLNormalizer.normalizeBaseURL(instanceURL, fieldName: "Instance URL")
if !url.hasPrefix("https://") && !url.hasPrefix("http://") {
url = "https://\(url)"
}
let (clientID, clientSecret) = try await MastodonClient.registerApp(instanceURL: url) let (clientID, clientSecret) = try await MastodonClient.registerApp(instanceURL: url)
@ -270,9 +271,10 @@ struct AddAccountView: View {
account.mastodonClientSecret = clientSecret account.mastodonClientSecret = clientSecret
let accountData = try JSONEncoder().encode(account) let accountData = try JSONEncoder().encode(account)
UserDefaults.standard.set(accountData, forKey: "qstatus.pending-mastodon-account") UserDefaults.standard.removeObject(forKey: Self.pendingMastodonErrorKey)
UserDefaults.standard.set(accountData, forKey: Self.pendingMastodonAccountKey)
let authURL = MastodonClient.authorizeURL(instanceURL: url, clientID: clientID) let authURL = try MastodonClient.authorizeURL(instanceURL: url, clientID: clientID)
NSWorkspace.shared.open(authURL) NSWorkspace.shared.open(authURL)
waitingForOAuth = true waitingForOAuth = true
@ -280,7 +282,11 @@ struct AddAccountView: View {
// Poll for the account to be added by the URL callback handler // Poll for the account to be added by the URL callback handler
for _ in 0..<120 { // wait up to 2 minutes for _ in 0..<120 { // wait up to 2 minutes
try? await Task.sleep(for: .seconds(1)) try? await Task.sleep(for: .seconds(1))
if UserDefaults.standard.data(forKey: "qstatus.pending-mastodon-account") == nil { if let oauthError = UserDefaults.standard.string(forKey: Self.pendingMastodonErrorKey) {
UserDefaults.standard.removeObject(forKey: Self.pendingMastodonErrorKey)
throw PostingError(message: oauthError)
}
if UserDefaults.standard.data(forKey: Self.pendingMastodonAccountKey) == nil {
// Callback was handled, account was added // Callback was handled, account was added
waitingForOAuth = false waitingForOAuth = false
isPresented = false isPresented = false
@ -293,10 +299,7 @@ struct AddAccountView: View {
} }
private func addWordPressAccount() async throws { private func addWordPressAccount() async throws {
var url = instanceURL.trimmingCharacters(in: .whitespaces) let url = try URLNormalizer.normalizeBaseURL(instanceURL, fieldName: "Site URL")
if !url.hasPrefix("https://") && !url.hasPrefix("http://") {
url = "https://\(url)"
}
let user = username.trimmingCharacters(in: .whitespaces) let user = username.trimmingCharacters(in: .whitespaces)
let pass = token.trimmingCharacters(in: .whitespaces) let pass = token.trimmingCharacters(in: .whitespaces)