fix: security hardening — Keychain, no hardcoded creds, safe URLs

- 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>
This commit is contained in:
2026-04-06 14:11:10 +03:00
parent 28fca1de89
commit 44c759c190
10 changed files with 167 additions and 58 deletions

View File

@@ -183,7 +183,19 @@ struct SettingsView: View {
}
.padding(.horizontal)
Text("Pulse v1.1 • Made with ❤️").font(.caption).foregroundColor(Color(hex: "8888aa"))
// Legal
HStack(spacing: 16) {
if let url = URL(string: "https://pulse.digital-home.site/privacy") {
Link("Политика конфиденциальности", destination: url)
.font(.caption).foregroundColor(Theme.textSecondary)
}
if let url = URL(string: "https://pulse.digital-home.site/terms") {
Link("Условия", destination: url)
.font(.caption).foregroundColor(Theme.textSecondary)
}
}
Text("Pulse v1.1").font(.caption).foregroundColor(Color(hex: "8888aa"))
.padding(.bottom, 20)
}
}

View File

@@ -66,6 +66,7 @@ struct AddTaskView: View {
TextField("Что нужно сделать?", text: $title, axis: .vertical)
.lineLimit(1...3).foregroundColor(.white).padding(14)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
.onChange(of: title) { if title.count > 200 { title = String(title.prefix(200)) } }
}
// Description
VStack(alignment: .leading, spacing: 8) {

View File

@@ -127,12 +127,22 @@ struct HabitListView: View {
func loadHabits(refresh: Bool = false) async {
if !refresh { isLoading = true }
var loaded = (try? await APIService.shared.getHabits(token: authManager.token, includeArchived: true)) ?? []
// Enrich with completedToday
let today = todayStr()
for i in loaded.indices where loaded[i].isArchived != true {
let logs = (try? await APIService.shared.getHabitLogs(token: authManager.token, habitId: loaded[i].id, days: 1)) ?? []
loaded[i].completedToday = logs.contains { $0.dateOnly == today }
// Fetch all logs in parallel, then update array
let activeIndices = loaded.indices.filter { loaded[$0].isArchived != true }
let logResults = await withTaskGroup(of: (Int, Bool).self) { group in
for i in activeIndices {
let habitId = loaded[i].id
group.addTask {
let logs = (try? await APIService.shared.getHabitLogs(token: self.authManager.token, habitId: habitId, days: 1)) ?? []
return (i, logs.contains { $0.dateOnly == today })
}
}
var results: [(Int, Bool)] = []
for await result in group { results.append(result) }
return results
}
for (i, completed) in logResults { loaded[i].completedToday = completed }
habits = loaded
isLoading = false
}