Add post validation checks
This commit is contained in:
parent
dfe4485fe9
commit
79f24ea1a4
9 changed files with 381 additions and 124 deletions
|
|
@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
qStatus/Utilities/InputValidation.swift
Normal file
37
qStatus/Utilities/InputValidation.swift
Normal 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."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
qStatus/Utilities/NetworkSupport.swift
Normal file
40
qStatus/Utilities/NetworkSupport.swift
Normal 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))."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
qStatus/Utilities/URLNormalizer.swift
Normal file
42
qStatus/Utilities/URLNormalizer.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue