diff --git a/qStatus/App/AppDelegate.swift b/qStatus/App/AppDelegate.swift index 86f2540..7c6a9c2 100644 --- a/qStatus/App/AppDelegate.swift +++ b/qStatus/App/AppDelegate.swift @@ -4,6 +4,9 @@ import SwiftUI @MainActor 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 accountStore = AccountStore() var floatingPanel: FloatingPanel? @@ -82,12 +85,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func performPost() { guard appState.canPost else { return } - appState.isSubmitting = true - appState.statusMessage = nil let selectedAccounts = accountStore.accounts.filter { 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 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 } 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), let clientID = account.mastodonClientID, let clientSecret = account.mastodonClientSecret @@ -212,9 +226,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { account.displayName = "@\(username)@\(account.instanceURL.replacingOccurrences(of: "https://", with: ""))" 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 { - 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 else { return } - let credentials = "\(userLogin):\(password)" - let account = Account( - serviceType: .wordpress, - displayName: "\(userLogin)@\(siteURL.replacingOccurrences(of: "https://", with: ""))", - instanceURL: siteURL, - username: userLogin - ) - try? accountStore.addAccount(account, token: credentials) + Task { + do { + let normalizedSiteURL = try URLNormalizer.normalizeBaseURL( + siteURL, + fieldName: "WordPress site URL" + ) + let isValid = try await WordPressClient.verifyCredentials( + 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)") + } + } } } diff --git a/qStatus/Services/AccountStore.swift b/qStatus/Services/AccountStore.swift index c7b0d7b..664b397 100644 --- a/qStatus/Services/AccountStore.swift +++ b/qStatus/Services/AccountStore.swift @@ -12,6 +12,14 @@ final class AccountStore { } 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) accounts.append(account) saveAccounts() diff --git a/qStatus/Services/MastodonClient.swift b/qStatus/Services/MastodonClient.swift index dc1ac93..c0fa645 100644 --- a/qStatus/Services/MastodonClient.swift +++ b/qStatus/Services/MastodonClient.swift @@ -3,18 +3,23 @@ import Foundation struct MastodonClient: PostingService { let account: Account private let token: String - private let baseURL: String + private let rawBaseURL: String init(account: Account, token: String) { self.account = account 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 { - 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) request.httpMethod = "POST" + request.timeoutInterval = 30 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") var form = MultipartFormData() @@ -26,11 +31,14 @@ struct MastodonClient: PostingService { request.setValue(form.contentType, forHTTPHeaderField: "Content-Type") request.httpBody = form.finalized - let (data, response) = try await URLSession.shared.data(for: request) - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + let (data, response) = try await NetworkSupport.data( + for: request, + context: "Mastodon media upload" + ) + let statusCode = response.statusCode guard (200...202).contains(statusCode) else { - let body = String(data: data, encoding: .utf8) ?? "Unknown error" + let body = NetworkSupport.responseBody(data) 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 { - 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) request.httpMethod = "POST" + request.timeoutInterval = 30 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(UUID().uuidString, forHTTPHeaderField: "Idempotency-Key") @@ -58,11 +71,14 @@ struct MastodonClient: PostingService { request.httpBody = try JSONSerialization.data(withJSONObject: body) - let (data, response) = try await URLSession.shared.data(for: request) - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + let (data, response) = try await NetworkSupport.data( + for: request, + context: "Mastodon post" + ) + let statusCode = response.statusCode 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)") } @@ -79,12 +95,14 @@ struct MastodonClient: PostingService { // MARK: - OAuth Registration static func registerApp(instanceURL: String) async throws -> (clientID: String, clientSecret: String) { - let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - guard let url = URL(string: "\(base)/api/v1/apps") else { - throw PostingError(message: "Invalid instance URL: \(instanceURL)") - } + let url = try URLNormalizer.endpointURL( + baseURL: instanceURL, + path: "/api/v1/apps", + fieldName: "Mastodon instance URL" + ) var request = URLRequest(url: url) request.httpMethod = "POST" + request.timeoutInterval = 30 request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: String] = [ @@ -94,17 +112,16 @@ struct MastodonClient: PostingService { ] request.httpBody = try JSONSerialization.data(withJSONObject: body) - let data: Data - let response: URLResponse - do { - (data, response) = try await URLSession.shared.data(for: request) - } catch { - throw PostingError(message: "Cannot connect to \(instanceURL). Check the URL and try again.") - } - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + let (data, response) = try await NetworkSupport.data( + for: request, + context: "Mastodon app registration" + ) + let statusCode = response.statusCode guard statusCode == 200 else { - throw PostingError(message: "Failed to register app on \(instanceURL)") + throw PostingError( + message: "Failed to register app on \(instanceURL) (\(statusCode)): \(NetworkSupport.responseBody(data))" + ) } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -117,42 +134,57 @@ struct MastodonClient: PostingService { return (clientID, clientSecret) } - static func authorizeURL(instanceURL: String, clientID: String) -> URL { - let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - var components = URLComponents(string: "\(base)/oauth/authorize")! + static func authorizeURL(instanceURL: String, clientID: String) throws -> URL { + let base = (try? URLNormalizer.normalizeBaseURL(instanceURL, fieldName: "Mastodon instance URL")) + ?? instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard var components = URLComponents(string: "\(base)/oauth/authorize") else { + throw PostingError(message: "Invalid Mastodon authorization URL.") + } components.queryItems = [ URLQueryItem(name: "client_id", value: clientID), URLQueryItem(name: "scope", value: "write:statuses write:media"), URLQueryItem(name: "redirect_uri", value: "qstatus://mastodon-callback"), URLQueryItem(name: "response_type", value: "code"), ] - return components.url! + guard let authURL = components.url else { + throw PostingError(message: "Failed to build Mastodon authorization URL.") + } + return authURL } static func exchangeCode( instanceURL: String, clientID: String, clientSecret: String, code: String ) async throws -> String { - let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let url = URL(string: "\(base)/oauth/token")! + let base = try URLNormalizer.normalizeBaseURL(instanceURL, fieldName: "Mastodon instance URL") + guard let url = URL(string: "\(base)/oauth/token") else { + throw PostingError(message: "Invalid Mastodon token endpoint URL.") + } var request = URLRequest(url: url) request.httpMethod = "POST" + request.timeoutInterval = 30 request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - let params = [ - "grant_type=authorization_code", - "client_id=\(clientID)", - "client_secret=\(clientSecret)", - "redirect_uri=qstatus://mastodon-callback", - "code=\(code)", - ].joined(separator: "&") - + var components = URLComponents() + components.queryItems = [ + URLQueryItem(name: "grant_type", value: "authorization_code"), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "client_secret", value: clientSecret), + URLQueryItem(name: "redirect_uri", value: "qstatus://mastodon-callback"), + URLQueryItem(name: "code", value: code), + ] + let params = components.percentEncodedQuery ?? "" request.httpBody = params.data(using: .utf8) - let (data, response) = try await URLSession.shared.data(for: request) - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + let (data, response) = try await NetworkSupport.data( + for: request, + context: "Mastodon token exchange" + ) + let statusCode = response.statusCode guard statusCode == 200 else { - throw PostingError(message: "Token exchange failed (\(statusCode))") + throw PostingError( + message: "Token exchange failed (\(statusCode)): \(NetworkSupport.responseBody(data))" + ) } 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 { - let base = instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let url = URL(string: "\(base)/api/v1/accounts/verify_credentials")! + let url = try URLNormalizer.endpointURL( + baseURL: instanceURL, + path: "/api/v1/accounts/verify_credentials", + fieldName: "Mastodon instance URL" + ) var request = URLRequest(url: url) + request.timeoutInterval = 30 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - let (data, response) = try await URLSession.shared.data(for: request) - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + let (data, response) = try await NetworkSupport.data( + for: request, + context: "Mastodon credential verification" + ) + let statusCode = response.statusCode guard statusCode == 200 else { - throw PostingError(message: "Credential verification failed") + throw PostingError( + message: "Credential verification failed (\(statusCode)): \(NetworkSupport.responseBody(data))" + ) } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], diff --git a/qStatus/Services/MicroblogClient.swift b/qStatus/Services/MicroblogClient.swift index 1bbb754..cc4e154 100644 --- a/qStatus/Services/MicroblogClient.swift +++ b/qStatus/Services/MicroblogClient.swift @@ -13,9 +13,12 @@ struct MicroblogClient: PostingService { } 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) request.httpMethod = "POST" + request.timeoutInterval = 30 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") var form = MultipartFormData() @@ -24,15 +27,17 @@ struct MicroblogClient: PostingService { request.setValue(form.contentType, forHTTPHeaderField: "Content-Type") request.httpBody = form.finalized - let (_, response) = try await URLSession.shared.data(for: request) - let httpResponse = response as? HTTPURLResponse - let statusCode = httpResponse?.statusCode ?? 0 + let (_, httpResponse) = try await NetworkSupport.data( + for: request, + context: "Micro.blog media upload" + ) + let statusCode = httpResponse.statusCode guard (200...202).contains(statusCode) else { 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") } @@ -41,15 +46,23 @@ struct MicroblogClient: PostingService { func createPost(text: String, mediaIDs: [String]) async throws -> URL { // 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) request.httpMethod = "POST" + request.timeoutInterval = 30 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") if mediaIDs.isEmpty { // Simple form-encoded post 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) } else { // JSON with HTML content including images @@ -69,16 +82,18 @@ struct MicroblogClient: PostingService { request.httpBody = try JSONSerialization.data(withJSONObject: body) } - let (data, response) = try await URLSession.shared.data(for: request) - let httpResponse = response as? HTTPURLResponse - let statusCode = httpResponse?.statusCode ?? 0 + let (data, httpResponse) = try await NetworkSupport.data( + for: request, + context: "Micro.blog post" + ) + let statusCode = httpResponse.statusCode 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)") } - if let location = httpResponse?.value(forHTTPHeaderField: "Location"), + if let location = httpResponse.value(forHTTPHeaderField: "Location"), let postURL = URL(string: location) { return postURL } @@ -90,22 +105,25 @@ struct MicroblogClient: PostingService { 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 { - 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) + request.timeoutInterval = 30 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - let data: Data - let response: URLResponse - do { - (data, response) = try await URLSession.shared.data(for: request) - } catch { - throw PostingError(message: "Cannot connect to Micro.blog. Check your internet connection.") - } - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + let (data, response) = try await NetworkSupport.data( + for: request, + context: "Micro.blog token verification" + ) + let statusCode = response.statusCode guard statusCode == 200 else { throw PostingError(message: "Invalid Micro.blog token (HTTP \(statusCode)). Generate a new one at micro.blog/account/apps") diff --git a/qStatus/Services/WordPressClient.swift b/qStatus/Services/WordPressClient.swift index 742e23b..eb7c482 100644 --- a/qStatus/Services/WordPressClient.swift +++ b/qStatus/Services/WordPressClient.swift @@ -3,12 +3,12 @@ import Foundation struct WordPressClient: PostingService { let account: Account private let token: String - private let baseURL: String + private let rawBaseURL: String init(account: Account, token: String) { self.account = account self.token = token // format: "username:application_password" - self.baseURL = account.instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + self.rawBaseURL = account.instanceURL } private var authHeader: String { @@ -16,19 +16,27 @@ struct WordPressClient: PostingService { } 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) request.httpMethod = "POST" + request.timeoutInterval = 30 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 + let (data, response) = try await NetworkSupport.data( + for: request, + context: "WordPress media upload" + ) + let statusCode = response.statusCode 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)") } @@ -51,15 +59,23 @@ struct WordPressClient: PostingService { } 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) request.httpMethod = "POST" + request.timeoutInterval = 30 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 + let (_, response) = try await NetworkSupport.data( + for: request, + context: "WordPress media alt-text update" + ) + let statusCode = response.statusCode guard statusCode == 200 else { 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 { // 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) request.httpMethod = "POST" + request.timeoutInterval = 30 request.setValue(authHeader, forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -87,11 +108,14 @@ struct WordPressClient: PostingService { request.httpBody = try JSONSerialization.data(withJSONObject: body) - let (data, response) = try await URLSession.shared.data(for: request) - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + let (data, response) = try await NetworkSupport.data( + for: request, + context: "WordPress post" + ) + let statusCode = response.statusCode 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)") } @@ -108,13 +132,20 @@ struct WordPressClient: PostingService { // 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/")! + let url = try URLNormalizer.endpointURL( + baseURL: siteURL, + path: "/wp-json/", + fieldName: "WordPress site URL" + ) var request = URLRequest(url: url) request.httpMethod = "GET" + request.timeoutInterval = 30 - let (data, response) = try await URLSession.shared.data(for: request) - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + let (data, response) = try await NetworkSupport.data( + for: request, + context: "WordPress API discovery" + ) + let statusCode = response.statusCode guard statusCode == 200 else { throw PostingError(message: "Cannot reach WordPress API at \(siteURL)") @@ -133,33 +164,34 @@ struct WordPressClient: PostingService { } static func buildAuthURL(authEndpoint: String) -> URL { - var components = URLComponents(string: authEndpoint)! + guard var components = URLComponents(string: authEndpoint) else { + return URL(fileURLWithPath: "/") + } 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! + return components.url ?? URL(fileURLWithPath: "/") } static func verifyCredentials(siteURL: String, username: String, password: String) async throws -> Bool { - let base = siteURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - guard let url = URL(string: "\(base)/wp-json/wp/v2/users/me") else { - throw PostingError(message: "Invalid site URL: \(siteURL)") - } + let url = try URLNormalizer.endpointURL( + baseURL: siteURL, + path: "/wp-json/wp/v2/users/me", + fieldName: "WordPress site URL" + ) var request = URLRequest(url: url) + request.timeoutInterval = 30 let creds = "\(username):\(password)" request.setValue("Basic \(Data(creds.utf8).base64EncodedString())", forHTTPHeaderField: "Authorization") - let (data, response): (Data, URLResponse) - do { - (data, response) = try await URLSession.shared.data(for: request) - } catch { - throw PostingError(message: "Cannot connect to \(siteURL). Check the URL and try again.") - } - - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + let (data, response) = try await NetworkSupport.data( + for: request, + context: "WordPress credential verification" + ) + let statusCode = response.statusCode switch statusCode { case 200: return true @@ -168,7 +200,7 @@ struct WordPressClient: PostingService { case 404: throw PostingError(message: "WordPress REST API not found at \(siteURL). Check the URL.") default: - let body = String(data: data, encoding: .utf8) ?? "" + let body = NetworkSupport.responseBody(data) throw PostingError(message: "Unexpected response (\(statusCode)): \(body)") } } diff --git a/qStatus/Utilities/InputValidation.swift b/qStatus/Utilities/InputValidation.swift new file mode 100644 index 0000000..8889808 --- /dev/null +++ b/qStatus/Utilities/InputValidation.swift @@ -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." + ) + } + } +} diff --git a/qStatus/Utilities/NetworkSupport.swift b/qStatus/Utilities/NetworkSupport.swift new file mode 100644 index 0000000..ea4f3de --- /dev/null +++ b/qStatus/Utilities/NetworkSupport.swift @@ -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))." + } + } +} diff --git a/qStatus/Utilities/URLNormalizer.swift b/qStatus/Utilities/URLNormalizer.swift new file mode 100644 index 0000000..418cda9 --- /dev/null +++ b/qStatus/Utilities/URLNormalizer.swift @@ -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 + } +} diff --git a/qStatus/Views/SettingsView.swift b/qStatus/Views/SettingsView.swift index 637978b..4a2c46b 100644 --- a/qStatus/Views/SettingsView.swift +++ b/qStatus/Views/SettingsView.swift @@ -73,6 +73,9 @@ struct SettingsView: View { } struct AddAccountView: View { + private static let pendingMastodonAccountKey = "qstatus.pending-mastodon-account" + private static let pendingMastodonErrorKey = "qstatus.pending-mastodon-account-error" + let accountStore: AccountStore @Binding var isPresented: Bool @State private var selectedService: ServiceType = .mastodon @@ -130,7 +133,8 @@ struct AddAccountView: View { Button(waitingForOAuth ? "Cancel Auth" : "Cancel") { if waitingForOAuth { waitingForOAuth = false - UserDefaults.standard.removeObject(forKey: "qstatus.pending-mastodon-account") + UserDefaults.standard.removeObject(forKey: Self.pendingMastodonAccountKey) + UserDefaults.standard.removeObject(forKey: Self.pendingMastodonErrorKey) } isPresented = false } @@ -253,10 +257,7 @@ struct AddAccountView: View { } private func addMastodonAccount() async throws { - var url = instanceURL.trimmingCharacters(in: .whitespaces) - if !url.hasPrefix("https://") && !url.hasPrefix("http://") { - url = "https://\(url)" - } + let url = try URLNormalizer.normalizeBaseURL(instanceURL, fieldName: "Instance URL") let (clientID, clientSecret) = try await MastodonClient.registerApp(instanceURL: url) @@ -270,9 +271,10 @@ struct AddAccountView: View { account.mastodonClientSecret = clientSecret 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) waitingForOAuth = true @@ -280,7 +282,11 @@ struct AddAccountView: View { // Poll for the account to be added by the URL callback handler for _ in 0..<120 { // wait up to 2 minutes 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 waitingForOAuth = false isPresented = false @@ -293,10 +299,7 @@ struct AddAccountView: View { } private func addWordPressAccount() async throws { - var url = instanceURL.trimmingCharacters(in: .whitespaces) - if !url.hasPrefix("https://") && !url.hasPrefix("http://") { - url = "https://\(url)" - } + let url = try URLNormalizer.normalizeBaseURL(instanceURL, fieldName: "Site URL") let user = username.trimmingCharacters(in: .whitespaces) let pass = token.trimmingCharacters(in: .whitespaces)