| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594 |
- // LoopFollow
- // NightscoutUtils.swift
- import CryptoKit
- import Foundation
- class NightscoutUtils {
- enum NightscoutError: Error, LocalizedError {
- case emptyAddress
- case invalidURL
- case networkError
- case siteNotFound
- case invalidToken
- case tokenRequired
- case unknown
- var errorDescription: String? {
- switch self {
- case .emptyAddress:
- return String(localized: "The address is empty.")
- case .invalidURL:
- return String(localized: "The URL is invalid.")
- case .networkError:
- return String(localized: "A network error occurred.")
- case .siteNotFound:
- return String(localized: "The site was not found.")
- case .invalidToken:
- return String(localized: "The token is invalid.")
- case .tokenRequired:
- return String(localized: "A token is required.")
- case .unknown:
- return String(localized: "An unknown error occurred.")
- }
- }
- }
- enum EventType: String {
- case cage = "Site Change"
- case carbsToday = "Carb Correction"
- case sage = "Sensor Start"
- case sgv
- case profile
- case treatments
- case deviceStatus
- case iage = "Insulin Change"
- case temporaryOverride = "Temporary Override"
- case temporaryOverrideCancel = "Temporary Override Cancel"
- case dbSize
- var endpoint: String {
- switch self {
- case .cage, .carbsToday, .sage, .treatments, .iage:
- return "/api/v1/treatments.json"
- case .sgv:
- return "/api/v1/entries.json"
- case .profile:
- return "/api/v1/profiles.json"
- case .deviceStatus:
- return "/api/v1/devicestatus.json"
- case .temporaryOverride, .temporaryOverrideCancel:
- return "/api/v2/notifications/loop"
- case .dbSize:
- return "/api/v2/properties/dbsize"
- }
- }
- }
- static func executeRequest<T: Decodable>(eventType: EventType, parameters: [String: String], completion: @escaping (Result<T, Error>) -> Void) {
- let baseURL = Storage.shared.url.value
- let token = Storage.shared.token.value
- guard let url = NightscoutUtils.constructURL(baseURL: baseURL, token: token, endpoint: eventType.endpoint, parameters: parameters) else {
- completion(.failure(NSError(domain: "NightscoutUtils", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct URL"])))
- return
- }
- var request = URLRequest(url: url)
- request.cachePolicy = URLRequest.CachePolicy.reloadIgnoringLocalCacheData
- let task = URLSession.shared.dataTask(with: request) { data, _, error in
- guard let data = data, error == nil else {
- completion(.failure(error!))
- return
- }
- let decoder = JSONDecoder()
- do {
- let decodedObject = try decoder.decode(T.self, from: data)
- DispatchQueue.main.async {
- completion(.success(decodedObject))
- }
- } catch let decodingError as DecodingError {
- let typeName = String(describing: T.self)
- switch decodingError {
- case let .typeMismatch(type, context):
- LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) typeMismatch: \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
- case let .valueNotFound(type, context):
- LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) valueNotFound: \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
- case let .keyNotFound(key, context):
- LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) keyNotFound: '\(key.stringValue)' at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
- case .dataCorrupted:
- LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) dataCorrupted", isDebug: true)
- @unknown default:
- LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) unknown error", isDebug: true)
- }
- completion(.failure(decodingError))
- } catch {
- LogManager.shared.log(category: .nightscout, message: "Decode \(T.self) general error: \(String(describing: type(of: error)))", isDebug: true)
- completion(.failure(error))
- }
- }
- task.resume()
- }
- static func executeDynamicRequest(eventType: EventType, parameters: [String: String], completion: @escaping (Result<Any, Error>) -> Void) {
- let baseURL = Storage.shared.url.value
- let token = Storage.shared.token.value
- guard let url = NightscoutUtils.constructURL(baseURL: baseURL, token: token, endpoint: eventType.endpoint, parameters: parameters) else {
- completion(.failure(NSError(domain: "NightscoutUtils", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct URL"])))
- return
- }
- var request = URLRequest(url: url)
- request.cachePolicy = URLRequest.CachePolicy.reloadIgnoringLocalCacheData
- let task = URLSession.shared.dataTask(with: request) { data, _, error in
- guard let data = data, error == nil else {
- completion(.failure(error!))
- return
- }
- do {
- if let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
- DispatchQueue.main.async {
- completion(.success(jsonObject))
- }
- } else if let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [Any] {
- DispatchQueue.main.async {
- completion(.success(jsonArray))
- }
- } else {
- completion(.failure(NSError(domain: "NightscoutUtils", code: -2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON Structure"])))
- }
- } catch {
- completion(.failure(error))
- }
- }
- task.resume()
- }
- static func createURLRequest(url: String, token: String?, path: String) -> URLRequest? {
- var requestURLString = "\(url)\(path)"
- if let token = token {
- let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? token
- requestURLString += "?token=\(encodedToken)"
- }
- guard let requestURL = URL(string: requestURLString) else {
- return nil
- }
- var request = URLRequest(url: requestURL)
- request.httpMethod = "GET"
- return request
- }
- // Strip whitespace/newlines/control characters that can sneak in via paste.
- // Neither a URL nor a token may legally contain them, and a stray one breaks
- // WebSocket connect (invalid percent-encoded query traps on iOS 26) or makes
- // URL parsing fall back to a lossy cleanup that mangles the address.
- static func sanitizeConnectionInput(_ input: String) -> String {
- input.unicodeScalars
- .filter { !CharacterSet.whitespacesAndNewlines.contains($0) && !CharacterSet.controlCharacters.contains($0) }
- .map(String.init)
- .joined()
- }
- static func constructURL(baseURL: String, token: String?, endpoint: String, parameters: [String: String]) -> URL? {
- var components = URLComponents(string: baseURL)
- components?.path = endpoint
- var queryItems = [URLQueryItem]()
- if let token = token, !token.isEmpty {
- queryItems.append(URLQueryItem(name: "token", value: token))
- }
- for (key, value) in parameters {
- queryItems.append(URLQueryItem(name: key, value: value))
- }
- // Assigning an empty array would still append a bare "?" to the URL.
- if !queryItems.isEmpty {
- components?.queryItems = queryItems
- }
- return components?.url
- }
- static func verifyURLAndToken(completion: @escaping (NightscoutError?, String?, Bool, Bool) -> Void) {
- let urlUser = Storage.shared.url.value
- let token = Storage.shared.token.value
- if urlUser.isEmpty {
- completion(.emptyAddress, nil, false, false)
- return
- }
- guard let _ = URL(string: urlUser), urlUser.hasPrefix("http://") || urlUser.hasPrefix("https://") else {
- completion(.invalidURL, nil, false, false)
- return
- }
- guard let request = createURLRequest(url: urlUser, token: token, path: "/api/v1/status.json") else {
- completion(.invalidURL, nil, false, false)
- return
- }
- let task = URLSession.shared.dataTask(with: request) { data, response, error in
- var nsWriteAuth = false
- var nsAdminAuth = false
- if let httpResponse = response as? HTTPURLResponse {
- switch httpResponse.statusCode {
- case 200:
- if let data = data {
- do {
- if let jsonResponse = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
- let authorized = jsonResponse["authorized"] as? [String: Any],
- let token = authorized["token"] as? String,
- let permissionGroups = authorized["permissionGroups"] as? [[String]]
- {
- if permissionGroups.contains(where: { $0.contains("*") }) {
- nsWriteAuth = true
- nsAdminAuth = true
- } else if permissionGroups.contains(where: { $0.contains("api:treatments:create") }) {
- nsWriteAuth = true
- }
- completion(nil, token, nsWriteAuth, nsAdminAuth)
- } else {
- completion(nil, nil, false, false)
- }
- } catch {
- completion(nil, nil, false, false)
- }
- } else {
- completion(nil, nil, false, false)
- }
- case 401:
- if token.isEmpty {
- completion(.tokenRequired, nil, false, false)
- } else {
- completion(.invalidToken, nil, false, false)
- }
- default:
- completion(.unknown, nil, false, false)
- }
- } else {
- if let urlError = error as? URLError {
- switch urlError.code {
- case .notConnectedToInternet, .networkConnectionLost, .timedOut, .dataNotAllowed:
- completion(.networkError, nil, false, false)
- default:
- // cannotFindHost, cannotConnectToHost, etc. — the address is wrong or the site is down.
- completion(.siteNotFound, nil, false, false)
- }
- } else {
- completion(.networkError, nil, false, false)
- }
- }
- }
- task.resume()
- }
- static func parseDate(_ rawString: String) -> Date? {
- var mutableDate = rawString
- if mutableDate.hasSuffix("Z") {
- mutableDate = String(mutableDate.dropLast())
- } else if let offsetRange = mutableDate.range(of: "[\\+\\-]\\d{2}:\\d{2}$",
- options: .regularExpression)
- {
- mutableDate.removeSubrange(offsetRange)
- }
- mutableDate = mutableDate.replacingOccurrences(
- of: "\\.\\d+",
- with: "",
- options: .regularExpression
- )
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
- dateFormatter.locale = Locale(identifier: "en_US")
- dateFormatter.timeZone = TimeZone(abbreviation: "UTC")
- let result = dateFormatter.date(from: mutableDate)
- if result == nil {
- print("Unable to parse string: '\(mutableDate)'")
- }
- return result
- }
- static func retrieveJWTToken() async throws -> String {
- let urlUser = Storage.shared.url.value
- let token = Storage.shared.token.value
- if urlUser.isEmpty {
- throw NightscoutError.emptyAddress
- }
- guard let request = createURLRequest(url: urlUser, token: token, path: "/api/v1/status.json"),
- urlUser.hasPrefix("http://") || urlUser.hasPrefix("https://")
- else {
- throw NightscoutError.invalidURL
- }
- let sessionConfig = URLSessionConfiguration.default
- sessionConfig.waitsForConnectivity = true
- sessionConfig.networkServiceType = .responsiveData
- let session = URLSession(configuration: sessionConfig)
- let (data, response) = try await session.data(for: request)
- guard let httpResponse = response as? HTTPURLResponse else {
- throw NightscoutError.networkError
- }
- switch httpResponse.statusCode {
- case 200:
- if let jsonResponse = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
- let authorized = jsonResponse["authorized"] as? [String: Any],
- let jwtToken = authorized["token"] as? String
- {
- return jwtToken
- } else {
- throw NightscoutError.invalidToken
- }
- case 401:
- throw token.isEmpty ? NightscoutError.tokenRequired : NightscoutError.invalidToken
- default:
- throw NightscoutError.unknown
- }
- }
- static func executePostRequest<T: Decodable>(eventType: EventType, body: [String: Any]) async throws -> T {
- let jwtToken = try await retrieveJWTToken()
- let baseURL = Storage.shared.url.value
- guard let url = URL(string: "\(baseURL)\(eventType.endpoint)") else {
- throw NightscoutError.invalidURL
- }
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- request.setValue("Bearer \(jwtToken)", forHTTPHeaderField: "Authorization")
- request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
- let sessionConfig = URLSessionConfiguration.default
- sessionConfig.waitsForConnectivity = true
- sessionConfig.networkServiceType = .responsiveData
- let session = URLSession(configuration: sessionConfig)
- let (data, response) = try await session.data(for: request)
- guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
- throw NightscoutError.networkError
- }
- let decoder = JSONDecoder()
- return try decoder.decode(T.self, from: data)
- }
- static func executePostRequest(eventType: EventType, body: [String: Any]) async throws -> String {
- let jwtToken = try await retrieveJWTToken()
- let baseURL = Storage.shared.url.value
- guard let url = URL(string: "\(baseURL)\(eventType.endpoint)") else {
- throw NightscoutError.invalidURL
- }
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- request.setValue("Bearer \(jwtToken)", forHTTPHeaderField: "Authorization")
- request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
- let sessionConfig = URLSessionConfiguration.default
- sessionConfig.waitsForConnectivity = true
- sessionConfig.networkServiceType = .responsiveData
- let session = URLSession(configuration: sessionConfig)
- let (data, response) = try await session.data(for: request)
- var responseString: String
- responseString = String(data: data, encoding: .utf8) ?? ""
- guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
- if responseString != "" {
- return responseString
- } else {
- throw NightscoutError.networkError
- }
- }
- return responseString
- }
- // MARK: - Token Provisioning
- /// Name of the Nightscout authorization subject LoopFollow creates when a
- /// user provisions a token from their API secret.
- static let provisionedSubjectName = "LoopFollow"
- private struct AuthSubject: Decodable {
- let id: String?
- let name: String?
- let accessToken: String?
- let roles: [String]?
- enum CodingKeys: String, CodingKey {
- case id = "_id"
- case name, accessToken, roles
- }
- }
- /// Creates (or reuses) a read-only Nightscout access token using the site's
- /// API secret. The secret only authorizes these requests and is never
- /// persisted. Returns the access token for a `readable` subject named
- /// `provisionedSubjectName`.
- ///
- /// The full API secret authenticates as Nightscout's `admin` role (the `*`
- /// permission), which includes `admin:api:subjects:create`.
- ///
- /// Nightscout serves the subjects list from an in-memory cache that doesn't
- /// refresh promptly after a write, so a freshly-created subject (and its
- /// token) can't be read back reliably right after creating it. Instead we
- /// derive the token locally: it's a pure function of the subject's `_id`
- /// (returned by the create call) and the API secret. See `accessToken(for:)`.
- static func provisionReadOnlyToken(url: String, secret: String) async throws -> String {
- let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmedURL.isEmpty else { throw NightscoutError.emptyAddress }
- guard let baseURL = URL(string: trimmedURL),
- trimmedURL.hasPrefix("http://") || trimmedURL.hasPrefix("https://")
- else { throw NightscoutError.invalidURL }
- let secretHash = sha1Hex(secret)
- // Reuse an existing subject if one is already visible (idempotent re-runs
- // once the site's cache has caught up).
- if let existing = try await fetchProvisionedToken(baseURL: baseURL, secretHash: secretHash) {
- return existing
- }
- let id = try await createReadOnlySubject(baseURL: baseURL, secretHash: secretHash)
- return accessToken(forName: provisionedSubjectName, id: id, secretHash: secretHash)
- }
- /// Returns `true` when `secret` authenticates as the site's API secret.
- /// Read-only: it probes an admin-gated endpoint with the hashed secret and
- /// creates nothing, so it's safe to call just to find out what the user pasted.
- static func verifyAPISecret(url: String, secret: String) async -> Bool {
- let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
- let trimmedSecret = secret.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmedSecret.isEmpty,
- let baseURL = URL(string: trimmedURL),
- trimmedURL.hasPrefix("http://") || trimmedURL.hasPrefix("https://")
- else { return false }
- let endpoint = baseURL.appendingPathComponent("api/v2/authorization/subjects")
- var request = URLRequest(url: endpoint)
- request.httpMethod = "GET"
- request.setValue(sha1Hex(trimmedSecret), forHTTPHeaderField: "api-secret")
- request.setValue("application/json", forHTTPHeaderField: "Accept")
- request.cachePolicy = .reloadIgnoringLocalCacheData
- do {
- let (_, response) = try await URLSession.shared.data(for: request)
- return (response as? HTTPURLResponse)?.statusCode == 200
- } catch {
- return false
- }
- }
- private static func fetchProvisionedToken(baseURL: URL, secretHash: String) async throws -> String? {
- let url = baseURL.appendingPathComponent("api/v2/authorization/subjects")
- var request = URLRequest(url: url)
- request.httpMethod = "GET"
- request.setValue(secretHash, forHTTPHeaderField: "api-secret")
- request.setValue("application/json", forHTTPHeaderField: "Accept")
- request.cachePolicy = .reloadIgnoringLocalCacheData
- let (data, response) = try await URLSession.shared.data(for: request)
- try validateProvisioningResponse(response)
- let subjects = try JSONDecoder().decode([AuthSubject].self, from: data)
- return subjects.first(where: { $0.name == provisionedSubjectName })?.accessToken
- }
- /// Creates the subject and returns its `_id`.
- private static func createReadOnlySubject(baseURL: URL, secretHash: String) async throws -> String {
- let url = baseURL.appendingPathComponent("api/v2/authorization/subjects")
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue(secretHash, forHTTPHeaderField: "api-secret")
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- request.httpBody = try JSONSerialization.data(withJSONObject: [
- "name": provisionedSubjectName,
- "roles": ["readable"],
- ])
- let (data, response) = try await URLSession.shared.data(for: request)
- try validateProvisioningResponse(response)
- // Nightscout returns the created subject wrapped in an array
- // (`[{…}]`) on current versions, but a bare object on some older ones,
- // so accept either shape.
- let subject = try decodeCreatedSubject(from: data)
- guard let id = subject.id, !id.isEmpty else { throw NightscoutError.unknown }
- return id
- }
- private static func decodeCreatedSubject(from data: Data) throws -> AuthSubject {
- let decoder = JSONDecoder()
- if let array = try? decoder.decode([AuthSubject].self, from: data) {
- guard let first = array.first else { throw NightscoutError.unknown }
- return first
- }
- return try decoder.decode(AuthSubject.self, from: data)
- }
- /// Reproduces Nightscout's subject-token derivation (`lib/authorization`):
- /// abbrev = name lowercased, non-`\w` characters removed, first 10 chars
- /// digest = sha1( sha1Hex(apiSecret) + subjectId )
- /// token = "\(abbrev)-\(digest[0..<16])"
- private static func accessToken(forName name: String, id: String, secretHash: String) -> String {
- let allowed = Set("abcdefghijklmnopqrstuvwxyz0123456789_")
- let abbrev = String(name.lowercased().filter { allowed.contains($0) }.prefix(10))
- let digest = sha1Hex(secretHash + id)
- return abbrev + "-" + String(digest.prefix(16))
- }
- private static func validateProvisioningResponse(_ response: URLResponse) throws {
- guard let http = response as? HTTPURLResponse else {
- throw NightscoutError.networkError
- }
- switch http.statusCode {
- case 200 ..< 300:
- return
- case 401, 403:
- // The API secret was missing or wrong.
- throw NightscoutError.invalidToken
- case 404:
- throw NightscoutError.siteNotFound
- default:
- throw NightscoutError.unknown
- }
- }
- private static func sha1Hex(_ string: String) -> String {
- Insecure.SHA1.hash(data: Data(string.utf8))
- .map { String(format: "%02x", $0) }
- .joined()
- }
- static func extractErrorReason(from responseString: String) -> String {
- // 1) Try to parse the entire string as JSON and return the "message"
- if let data = responseString.data(using: .utf8) {
- if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
- let message = json["message"] as? String
- {
- return message
- }
- }
- // 2) If not valid JSON (or no "message"), try to parse it as HTML <title>
- if let startRange = responseString.range(of: "<title>"),
- let endRange = responseString.range(of: "</title>")
- {
- let titleRange = startRange.upperBound ..< endRange.lowerBound
- let titleContent = responseString[titleRange].trimmingCharacters(in: .whitespacesAndNewlines)
- if !titleContent.isEmpty {
- return titleContent
- }
- }
- // 3) Fallback: just return the entire raw string
- return responseString
- }
- }
|