- Add KeychainService for encrypted token storage (auth, refresh, health JWT, API key) - Remove hardcoded email/password from HealthAPIService, store in Keychain - Move all tokens from UserDefaults to Keychain - API key sent via X-API-Key header instead of URL query parameter - Replace force unwrap URL(string:)! with guard let + throws - Fix force unwrap Calendar.date() in HealthKitService - Mark HealthKitService @MainActor for thread-safe @Published - Use withTaskGroup for parallel habit log fetching in TrackerView - Check notification permission before scheduling reminders - Add input validation (title max 200 chars) - Add privacy policy and terms links in Settings - Update CLAUDE.md with security section Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
94 lines
4.3 KiB
Swift
94 lines
4.3 KiB
Swift
import Foundation
|
|
|
|
class HealthAPIService {
|
|
static let shared = HealthAPIService()
|
|
let baseURL = "https://health.digital-home.site"
|
|
|
|
private var cachedToken: String? {
|
|
get { KeychainService.load(key: KeychainService.healthTokenKey) }
|
|
set {
|
|
if let v = newValue { KeychainService.save(key: KeychainService.healthTokenKey, value: v) }
|
|
else { KeychainService.delete(key: KeychainService.healthTokenKey) }
|
|
}
|
|
}
|
|
|
|
func ensureToken() async throws -> String {
|
|
if let t = cachedToken { return t }
|
|
return try await refreshToken()
|
|
}
|
|
|
|
func refreshToken() async throws -> String {
|
|
// Use credentials from Keychain (set during first login or onboarding)
|
|
guard let email = KeychainService.load(key: "health_email"),
|
|
let password = KeychainService.load(key: "health_password") else {
|
|
throw APIError.unauthorized
|
|
}
|
|
var req = URLRequest(url: URL(string: "\(baseURL)/api/auth/login")!)
|
|
req.httpMethod = "POST"
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
req.httpBody = try? JSONEncoder().encode(["email": email, "password": password])
|
|
req.timeoutInterval = 15
|
|
let (data, response) = try await URLSession.shared.data(for: req)
|
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
|
throw APIError.unauthorized
|
|
}
|
|
struct LoginResp: Decodable { let token: String }
|
|
let resp = try JSONDecoder().decode(LoginResp.self, from: data)
|
|
cachedToken = resp.token
|
|
return resp.token
|
|
}
|
|
|
|
/// Call once during setup to store health credentials securely
|
|
static func configureCredentials(email: String, password: String) {
|
|
KeychainService.save(key: "health_email", value: email)
|
|
KeychainService.save(key: "health_password", value: password)
|
|
}
|
|
|
|
private func fetch<T: Decodable>(_ path: String) async throws -> T {
|
|
let token = try await ensureToken()
|
|
guard let url = URL(string: "\(baseURL)\(path)") else { throw APIError.networkError("Неверный URL") }
|
|
var req = URLRequest(url: url)
|
|
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
req.timeoutInterval = 15
|
|
let (data, response) = try await URLSession.shared.data(for: req)
|
|
guard let http = response as? HTTPURLResponse else { throw APIError.networkError("Нет ответа") }
|
|
if http.statusCode == 401 {
|
|
cachedToken = nil
|
|
let newToken = try await refreshToken()
|
|
guard let retryURL = URL(string: "\(baseURL)\(path)") else { throw APIError.networkError("Неверный URL") }
|
|
var req2 = URLRequest(url: retryURL)
|
|
req2.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
|
|
req2.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
req2.timeoutInterval = 15
|
|
let (data2, _) = try await URLSession.shared.data(for: req2)
|
|
return try JSONDecoder().decode(T.self, from: data2)
|
|
}
|
|
if http.statusCode >= 400 {
|
|
throw APIError.serverError(http.statusCode, String(data: data, encoding: .utf8) ?? "")
|
|
}
|
|
return try JSONDecoder().decode(T.self, from: data)
|
|
}
|
|
|
|
func getLatest() async throws -> LatestHealthResponse {
|
|
return try await fetch("/api/health/latest")
|
|
}
|
|
|
|
func getReadiness() async throws -> ReadinessResponse {
|
|
return try await fetch("/api/health/readiness")
|
|
}
|
|
|
|
func getHeatmap(days: Int = 30) async throws -> [HeatmapEntry] {
|
|
let token = try await ensureToken()
|
|
guard let url = URL(string: "\(baseURL)/api/health/heatmap?days=\(days)") else { return [] }
|
|
var req = URLRequest(url: url)
|
|
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
req.timeoutInterval = 15
|
|
let (data, _) = try await URLSession.shared.data(for: req)
|
|
if let entries = try? JSONDecoder().decode([HeatmapEntry].self, from: data) { return entries }
|
|
struct HeatmapResponse: Decodable { let data: [HeatmapEntry] }
|
|
let wrapped = try JSONDecoder().decode(HeatmapResponse.self, from: data)
|
|
return wrapped.data
|
|
}
|
|
}
|