diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 8b609930..7e2932b7 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -86,7 +86,7 @@ class NotificationService: UNNotificationServiceExtension { } else { downloadAndAttachMedia } - + os_log("handleNotification downloading %{PUBLIC}@", log: .default, type: .info, attachmentURLString) downloadHandler(attachmentURL) { attachment in if let attachment { os_log("handleNotification attaching %{PUBLIC}@", log: .default, type: .info, attachmentURLString) @@ -138,7 +138,8 @@ class NotificationService: UNNotificationServiceExtension { } private func downloadAndAttachMedia(url: URL, completion: @escaping (UNNotificationAttachment?) -> Void) { - let task = URLSession.shared.downloadTask(with: url) { localURL, response, error in + let client = HTTPClient(username: Preferences.username, password: Preferences.username) // lets not always send auth with this + client.downloadFile(url: url) { localURL, response, error in guard let localURL else { os_log("Error downloading media %{PUBLIC}@", log: .default, type: .error, error?.localizedDescription ?? "Unknown error") completion(nil) @@ -146,18 +147,19 @@ class NotificationService: UNNotificationServiceExtension { } self.attachFile(localURL: localURL, mimeType: response?.mimeType, completion: completion) } - task.resume() } func downloadAndAttachItemImage(attachmentURL: URL, completion: @escaping (UNNotificationAttachment?) -> Void) { guard let scheme = attachmentURL.scheme else { os_log("Could not find scheme %{PUBLIC}@", log: .default, type: .info) + completion(nil) return } let itemName = String(attachmentURL.absoluteString.dropFirst(scheme.count + 1)) - OpenHABItemCache.instance.getItem(name: itemName) { item in + let client = HTTPClient(username: Preferences.username, password: Preferences.username, alwaysSendBasicAuth: Preferences.alwaysSendCreds) + client.getItem(baseURLs: [Preferences.localUrl, Preferences.remoteUrl], itemName: itemName) { item, error in guard let item else { os_log("Could not find item %{PUBLIC}@", log: .default, type: .info, itemName) completion(nil) @@ -168,11 +170,9 @@ class NotificationService: UNNotificationServiceExtension { // Extract MIME type and base64 string let pattern = "^data:(.*?);base64,(.*)$" let regex = try NSRegularExpression(pattern: pattern, options: []) - if let match = regex.firstMatch(in: state, options: [], range: NSRange(location: 0, length: state.utf16.count)) { let mimeTypeRange = Range(match.range(at: 1), in: state) let base64Range = Range(match.range(at: 2), in: state) - if let mimeTypeRange, let base64Range { let mimeType = String(state[mimeTypeRange]) let base64String = String(state[base64Range]) @@ -196,10 +196,8 @@ class NotificationService: UNNotificationServiceExtension { } catch { os_log("Failed to parse data: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) } - completion(nil) - } else { - completion(nil) } + completion(nil) } } @@ -222,11 +220,11 @@ class NotificationService: UNNotificationServiceExtension { os_log("Unrecognized MIME type or file extension", log: .default, type: .error) attachment = nil } - completion(attachment) + return } catch { os_log("Failed to create UNNotificationAttachment: %{PUBLIC}@", log: .default, type: .error, error.localizedDescription) - completion(nil) } + completion(nil) } } diff --git a/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift new file mode 100644 index 00000000..0a1d49dc --- /dev/null +++ b/OpenHABCore/Sources/OpenHABCore/Util/HTTPClient.swift @@ -0,0 +1,202 @@ +// Copyright (c) 2010-2024 Contributors to the openHAB project +// +// See the NOTICE file(s) distributed with this work for additional +// information. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0 +// +// SPDX-License-Identifier: EPL-2.0 + +import Foundation +import os.log + +public class HTTPClient: NSObject, URLSessionDelegate { + // MARK: - Properties + + private var session: URLSession! + private let username: String + private let password: String + private let certManager: ClientCertificateManager + private let alwaysSendBasicAuth: Bool + + public init(username: String, password: String, alwaysSendBasicAuth: Bool = false) { + self.username = username + self.password = password + certManager = ClientCertificateManager() + self.alwaysSendBasicAuth = alwaysSendBasicAuth + super.init() + + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 60 + + session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + } + + // MARK: - URLSessionDelegate for Client Certificates + + public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate { + let serverDistinguishedNames = challenge.protectionSpace.distinguishedNames + let identity = certManager.evaluateTrust(distinguishedNames: serverDistinguishedNames ?? []) + + if let identity { + let credential = URLCredential(identity: identity, certificates: nil, persistence: .forSession) + completionHandler(.useCredential, credential) + } else { + completionHandler(.cancelAuthenticationChallenge, nil) + } + } else if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust { + let serverTrust = challenge.protectionSpace.serverTrust! + let credential = URLCredential(trust: serverTrust) + completionHandler(.useCredential, credential) + } else if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodHTTPBasic { + let credential = URLCredential(user: username, password: password, persistence: .forSession) + completionHandler(.useCredential, credential) + } else { + completionHandler(.performDefaultHandling, nil) + } + } + + public func doGet(baseURLs: [String], path: String?, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { + doRequest(baseURLs: baseURLs, path: path, method: "GET", completion: completion) + } + + public func doPost(baseURLs: [String], path: String?, body: String, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { + doRequest(baseURLs: baseURLs, path: path, method: "POST", body: body, completion: completion) + } + + public func doPut(baseURLs: [String], path: String?, body: String, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { + doRequest(baseURLs: baseURLs, path: path, method: "PUT", body: body, completion: completion) + } + + public func getItems(baseURLs: [String], completion: @escaping ([OpenHABItem]?, Error?) -> Void) { + doGet(baseURLs: baseURLs, path: "/rest/items") { data, _, error in + if let error { + completion(nil, error) + } else { + do { + var items = [OpenHABItem]() + if let data { + os_log("getItemsInternal Data: %{public}@", log: .networking, type: .debug, String(data: data, encoding: .utf8) ?? "") + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) + + // if we are hitting an item, then its OpenHABItem.CodingData] not [OpenHABItem.CodingData] + let codingDatas = try data.decoded(as: [OpenHABItem.CodingData].self, using: decoder) + for codingDatum in codingDatas where codingDatum.openHABItem.type != OpenHABItem.ItemType.group { + items.append(codingDatum.openHABItem) + } + os_log("Loaded items to cache: %{PUBLIC}d", log: .networking, type: .info, items.count) + } + completion(items, nil) + } catch { + os_log("getItemsInternal ERROR: %{PUBLIC}@", log: .networking, type: .info, String(describing: error)) + completion(nil, error) + } + } + } + } + + public func getItem(baseURLs: [String], itemName: String, completion: @escaping (OpenHABItem?, Error?) -> Void) { + os_log("getItem from URsL %{public}@ and item %{public}@", log: .networking, type: .info, baseURLs, itemName) + doGet(baseURLs: baseURLs, path: "/rest/items/\(itemName)") { data, _, error in + if let error { + completion(nil, error) + } else { + do { + if let data { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full) + let item = try data.decoded(as: OpenHABItem.CodingData.self, using: decoder) + completion(item.openHABItem, nil) + } else { + completion(nil, NSError(domain: "HTTPClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data for item"])) + } + } catch { + os_log("getItemsInternal ERROR: %{PUBLIC}@", log: .networking, type: .info, String(describing: error)) + completion(nil, error) + } + } + } + } + + public func downloadFile(url: URL, completionHandler: @escaping @Sendable (URL?, URLResponse?, (any Error)?) -> Void) { + let task = session.downloadTask(with: url, completionHandler: completionHandler) + task.resume() + } + + // MARK: - Private Methods + + // MARK: - Basic Authentication + + private func basicAuthHeader() -> String { + let authString = "\(username):\(password)" + let authData = authString.data(using: .utf8)! + return "Basic \(authData.base64EncodedString())" + } + + // Perform an HTTP request + private func performRequest(request: URLRequest, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { + var request = request + if alwaysSendBasicAuth { + request.setValue(basicAuthHeader(), forHTTPHeaderField: "Authorization") + } + let task = session.dataTask(with: request, completionHandler: completion) + task.resume() + } + + // General function to perform HTTP requests + private func doRequest(baseURLs: [String], path: String?, method: String, body: String? = nil, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { + var urls: [URL] = [] + for urlString in baseURLs { + if var urlComponent = URLComponents(string: urlString) { + if let path { + urlComponent.path = path + } + if let url = urlComponent.url { + urls.append(url) + } + } + } + + func sendRequest() { + guard !urls.isEmpty else { + os_log("All URLs processed and failed.", log: .networking, type: .error) + completion(nil, nil, NSError(domain: "HTTPClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "All URLs processed and failed."])) + return + } + + let url = urls.removeFirst() + var request = URLRequest(url: url) + request.httpMethod = method + if let body { + request.httpBody = body.data(using: .utf8)! + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") + } + + performRequest(request: request) { data, response, error in + if let error { + os_log("Error with URL %{public}@ : %{public}@", log: .networking, type: .error, url.absoluteString, error.localizedDescription) + // Try the next URL + sendRequest() + } else if let response = response as? HTTPURLResponse { + if (400 ... 599).contains(response.statusCode) { + os_log("HTTP error from URL %{public}@ : %{public}d", log: .networking, type: .error, url.absoluteString, response.statusCode) + // Try the next URL + sendRequest() + } else { + os_log("Response from URL %{public}@ : %{public}d", log: .networking, type: .info, url.absoluteString, response.statusCode) + if let data { + os_log("Data: %{public}@", log: .networking, type: .debug, String(data: data, encoding: .utf8) ?? "") + } + completion(data, response, nil) + } + } + } + } + sendRequest() + } +} diff --git a/OpenHABCore/Sources/OpenHABCore/Util/OSLogExtension.swift b/OpenHABCore/Sources/OpenHABCore/Util/OSLogExtension.swift index 11c171d4..9e85df96 100644 --- a/OpenHABCore/Sources/OpenHABCore/Util/OSLogExtension.swift +++ b/OpenHABCore/Sources/OpenHABCore/Util/OSLogExtension.swift @@ -44,4 +44,7 @@ public extension OSLog { /// Logs WkWebView events static let wkwebview = OSLog(subsystem: subsystem, category: "wkwebview") + + /// Log Networking events + static let networking = OSLog(subsystem: subsystem, category: "networking") } diff --git a/openHAB/AppDelegate.swift b/openHAB/AppDelegate.swift index 338a1447..9a040789 100644 --- a/openHAB/AppDelegate.swift +++ b/openHAB/AppDelegate.swift @@ -194,7 +194,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { var userInfo = response.notification.request.content.userInfo let actionIdentifier = response.actionIdentifier - os_log("Notification clicked: action %{PUBLIC}@ userInfo %{PUBLIC}@", log: .notifications, type: .info, actionIdentifier, userInfo) + os_log("Notification clicked: action %{public}@ userInfo %{public}@", log: .notifications, type: .info, actionIdentifier, userInfo) if actionIdentifier != UNNotificationDismissActionIdentifier { if actionIdentifier != UNNotificationDefaultActionIdentifier { userInfo["actionIdentifier"] = actionIdentifier diff --git a/openHAB/OpenHABRootViewController.swift b/openHAB/OpenHABRootViewController.swift index 0f888451..f390dc6b 100644 --- a/openHAB/OpenHABRootViewController.swift +++ b/openHAB/OpenHABRootViewController.swift @@ -244,12 +244,16 @@ class OpenHABRootViewController: UIViewController { if components.count == 2 { let itemName = String(components[0]) let itemCommand = String(components[1]) - OpenHABItemCache.instance.getItem(name: itemName) { item in - guard let item else { - os_log("Could not find item %{PUBLIC}@", log: .notifications, type: .info, itemName) - return + let client = HTTPClient(username: Preferences.username, password: Preferences.username) + client.doPost(baseURLs: [Preferences.localUrl, Preferences.remoteUrl], path: "/rest/items/\(itemName)", body: itemCommand) { data, _, error in + if let error { + os_log("Could not send data %{public}@", log: .default, type: .error, error.localizedDescription) + } else { + os_log("Request succeeded", log: .default, type: .info) + if let data { + os_log("Data: %{public}@", log: .default, type: .debug, String(data: data, encoding: .utf8) ?? "") + } } - OpenHABItemCache.instance.sendCommand(item, commandToSend: itemCommand) } } }