ReadKeep/readeck/UI/Utils/LogStore.swift
Ilyas Hallak ec432a037c feat: Implement OAuth 2.0 authentication with PKCE and automatic fallback
Add complete OAuth 2.0 Authorization Code Flow with PKCE as alternative
to API token authentication, with automatic server detection and graceful
fallback to classic login.

**OAuth Core (RFC 7636 PKCE):**
- PKCEGenerator: S256 challenge generation for secure code exchange
- OAuth DTOs: Client registration, token request/response models
- OAuthClient, OAuthToken, AuthenticationMethod domain models
- API.swift: registerOAuthClient() and exchangeOAuthToken() endpoints
- OAuthRepository + POAuthRepository protocol

**Browser Integration (ASWebAuthenticationSession):**
- OAuthSession: Wraps native authentication session
- OAuthFlowCoordinator: Orchestrates 5-phase OAuth flow
- readeck:// URL scheme for OAuth callback handling
- State verification for CSRF protection
- User cancellation handling

**Token Management:**
- KeychainHelper: OAuth token storage alongside API tokens
- TokenProvider: getOAuthToken(), setOAuthToken(), getAuthMethod()
- AuthenticationMethod enum to distinguish token types
- AuthRepository: loginWithOAuth(), getAuthenticationMethod()
- Endpoint persistence in both Keychain and Settings

**Server Feature Detection:**
- ServerInfo extended with features array and supportsOAuth flag
- GET /api/info endpoint integration (backward compatible)
- GetServerInfoUseCase with optional endpoint parameter

**User Profile Integration:**
- ProfileApiClient: Fetch user data via GET /api/profile
- UserProfileDto with username, email, provider information
- GetUserProfileUseCase: Extract username from profile
- Username saved and displayed for OAuth users (like classic auth)

**Automatic OAuth Flow (No User Selection):**
- OnboardingServerView: 2-phase flow (endpoint → auto-OAuth or classic)
- OAuth attempted automatically if server supports it
- Fallback to username/password on OAuth failure or unsupported
- SettingsServerViewModel: checkServerOAuthSupport(), loginWithOAuth()

**Cleanup & Refactoring:**
- Remove all #if os(iOS) && !APP_EXTENSION conditionals
- Remove LoginMethodSelectionView (no longer needed)
- Remove switchToClassicLogin() method
- Factories updated with OAuth dependencies

**Testing:**
- PKCEGeneratorTests: Verify RFC 7636 compliance
- ServerInfoTests: Feature detection and backward compatibility
- Mock implementations for all OAuth components

**Documentation:**
- docs/OAuth2-Implementation-Plan.md: Complete implementation guide
- openapi.json: Readeck API specification

**Scopes Requested:**
- bookmarks:read, bookmarks:write, profile:read

OAuth users now have full feature parity with classic authentication.
Server auto-detects OAuth support via /info endpoint. Seamless UX with
browser-based login and automatic fallback.
2025-12-19 21:56:40 +01:00

324 lines
11 KiB
Swift

//
// LogStore.swift
// readeck
//
// Created by Ilyas Hallak on 01.11.25.
//
import Foundation
import Compression
// MARK: - Log Entry
struct LogEntry: Identifiable, Codable {
let id: UUID
let timestamp: Date
let level: LogLevel
let category: LogCategory
let message: String
let file: String
let function: String
let line: Int
var fileName: String {
URL(fileURLWithPath: file).lastPathComponent.replacingOccurrences(of: ".swift", with: "")
}
var formattedTimestamp: String {
DateFormatter.logTimestamp.string(from: timestamp)
}
init(
id: UUID = UUID(),
timestamp: Date = Date(),
level: LogLevel,
category: LogCategory,
message: String,
file: String,
function: String,
line: Int
) {
self.id = id
self.timestamp = timestamp
self.level = level
self.category = category
self.message = message
self.file = file
self.function = function
self.line = line
}
}
// MARK: - Log Store
actor LogStore {
static let shared = LogStore()
private var entries: [LogEntry] = []
private let maxEntries: Int
private init(maxEntries: Int = 1000) {
self.maxEntries = maxEntries
}
func addEntry(_ entry: LogEntry) {
entries.append(entry)
// Keep only the most recent entries
if entries.count > maxEntries {
entries.removeFirst(entries.count - maxEntries)
}
}
func getEntries() -> [LogEntry] {
return entries
}
func getEntries(
level: LogLevel? = nil,
category: LogCategory? = nil,
searchText: String? = nil
) -> [LogEntry] {
var filtered = entries
if let level = level {
filtered = filtered.filter { $0.level == level }
}
if let category = category {
filtered = filtered.filter { $0.category == category }
}
if let searchText = searchText, !searchText.isEmpty {
filtered = filtered.filter {
$0.message.localizedCaseInsensitiveContains(searchText) ||
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
$0.function.localizedCaseInsensitiveContains(searchText)
}
}
return filtered
}
func clear() {
entries.removeAll()
}
func exportAsText() -> String {
var text = "Readeck Debug Logs\n"
text += "Generated: \(DateFormatter.exportTimestamp.string(from: Date()))\n"
text += "Total Entries: \(entries.count)\n"
text += String(repeating: "=", count: 80) + "\n\n"
for entry in entries {
text += "[\(entry.formattedTimestamp)] "
text += "[\(entry.level.emoji) \(levelName(for: entry.level))] "
text += "[\(entry.category.rawValue)] "
text += "\(entry.fileName):\(entry.line) "
text += "\(entry.function)\n"
text += " \(entry.message)\n\n"
}
return text
}
func exportAsZippedData() throws -> (data: Data, filename: String) {
// Generate log text
let logText = exportAsText()
guard let logData = logText.data(using: .utf8) else {
throw NSError(domain: "LogStore", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to convert logs to UTF-8"])
}
// Create filename with timestamp
let timestamp = DateFormatter.filenameTimestamp.string(from: Date())
let filename = "readeck-logs-\(timestamp).zip"
let logFilename = "readeck-logs-\(timestamp).txt"
// Create ZIP archive
let zipData = try createZipArchive(filename: logFilename, data: logData)
return (zipData, filename)
}
private func createZipArchive(filename: String, data: Data) throws -> Data {
// Create a simple ZIP file structure
// ZIP file format: https://en.wikipedia.org/wiki/ZIP_(file_format)
let crc32 = calculateCRC32(data: data)
let compressedData = try compressData(data)
var zipData = Data()
// Local file header
zipData.append(contentsOf: [0x50, 0x4B, 0x03, 0x04]) // Local file header signature
zipData.append(contentsOf: [0x14, 0x00]) // Version needed to extract (2.0)
zipData.append(contentsOf: [0x00, 0x00]) // General purpose bit flag
zipData.append(contentsOf: [0x08, 0x00]) // Compression method (deflate)
zipData.append(contentsOf: [0x00, 0x00]) // File last modification time
zipData.append(contentsOf: [0x00, 0x00]) // File last modification date
zipData.append(contentsOf: UInt32(crc32).littleEndianBytes) // CRC-32
zipData.append(contentsOf: UInt32(compressedData.count).littleEndianBytes) // Compressed size
zipData.append(contentsOf: UInt32(data.count).littleEndianBytes) // Uncompressed size
zipData.append(contentsOf: UInt16(filename.utf8.count).littleEndianBytes) // File name length
zipData.append(contentsOf: [0x00, 0x00]) // Extra field length
zipData.append(contentsOf: filename.utf8) // File name
zipData.append(compressedData) // Compressed data
let localHeaderSize = zipData.count
// Central directory header
let centralDirStart = zipData.count
zipData.append(contentsOf: [0x50, 0x4B, 0x01, 0x02]) // Central directory file header signature
zipData.append(contentsOf: [0x14, 0x00]) // Version made by
zipData.append(contentsOf: [0x14, 0x00]) // Version needed to extract
zipData.append(contentsOf: [0x00, 0x00]) // General purpose bit flag
zipData.append(contentsOf: [0x08, 0x00]) // Compression method
zipData.append(contentsOf: [0x00, 0x00]) // File last modification time
zipData.append(contentsOf: [0x00, 0x00]) // File last modification date
zipData.append(contentsOf: UInt32(crc32).littleEndianBytes) // CRC-32
zipData.append(contentsOf: UInt32(compressedData.count).littleEndianBytes) // Compressed size
zipData.append(contentsOf: UInt32(data.count).littleEndianBytes) // Uncompressed size
zipData.append(contentsOf: UInt16(filename.utf8.count).littleEndianBytes) // File name length
zipData.append(contentsOf: [0x00, 0x00]) // Extra field length
zipData.append(contentsOf: [0x00, 0x00]) // File comment length
zipData.append(contentsOf: [0x00, 0x00]) // Disk number start
zipData.append(contentsOf: [0x00, 0x00]) // Internal file attributes
zipData.append(contentsOf: [0x00, 0x00, 0x00, 0x00]) // External file attributes
zipData.append(contentsOf: [0x00, 0x00, 0x00, 0x00]) // Relative offset of local header
zipData.append(contentsOf: filename.utf8) // File name
let centralDirSize = zipData.count - centralDirStart
// End of central directory record
zipData.append(contentsOf: [0x50, 0x4B, 0x05, 0x06]) // End of central directory signature
zipData.append(contentsOf: [0x00, 0x00]) // Number of this disk
zipData.append(contentsOf: [0x00, 0x00]) // Disk where central directory starts
zipData.append(contentsOf: [0x01, 0x00]) // Number of central directory records on this disk
zipData.append(contentsOf: [0x01, 0x00]) // Total number of central directory records
zipData.append(contentsOf: UInt32(centralDirSize).littleEndianBytes) // Size of central directory
zipData.append(contentsOf: UInt32(centralDirStart).littleEndianBytes) // Offset of start of central directory
zipData.append(contentsOf: [0x00, 0x00]) // Comment length
return zipData
}
private func compressData(_ data: Data) throws -> Data {
var compressedData = Data()
let bufferSize = 4096
try data.withUnsafeBytes { (sourceBytes: UnsafeRawBufferPointer) in
guard let sourcePointer = sourceBytes.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
throw NSError(domain: "LogStore", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to get source pointer"])
}
let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
defer { destinationBuffer.deallocate() }
let streamPtr = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
defer { streamPtr.deallocate() }
var stream = streamPtr.pointee
var status = compression_stream_init(&stream, COMPRESSION_STREAM_ENCODE, COMPRESSION_ZLIB)
guard status == COMPRESSION_STATUS_OK else {
throw NSError(domain: "LogStore", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to initialize compression stream"])
}
defer { compression_stream_destroy(&stream) }
stream.src_ptr = sourcePointer
stream.src_size = data.count
stream.dst_ptr = destinationBuffer
stream.dst_size = bufferSize
while status == COMPRESSION_STATUS_OK {
status = compression_stream_process(&stream, Int32(COMPRESSION_STREAM_FINALIZE.rawValue))
switch status {
case COMPRESSION_STATUS_OK, COMPRESSION_STATUS_END:
let bytesWritten = bufferSize - stream.dst_size
compressedData.append(destinationBuffer, count: bytesWritten)
stream.dst_ptr = destinationBuffer
stream.dst_size = bufferSize
case COMPRESSION_STATUS_ERROR:
throw NSError(domain: "LogStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Compression failed"])
default:
break
}
}
}
return compressedData
}
private func calculateCRC32(data: Data) -> UInt32 {
var crc: UInt32 = 0xFFFFFFFF
for byte in data {
let index = Int((crc ^ UInt32(byte)) & 0xFF)
crc = (crc >> 8) ^ crc32Table[index]
}
return crc ^ 0xFFFFFFFF
}
// CRC-32 lookup table
private var crc32Table: [UInt32] {
return (0..<256).map { i -> UInt32 in
var crc = UInt32(i)
for _ in 0..<8 {
crc = (crc & 1 == 1) ? ((crc >> 1) ^ 0xEDB88320) : (crc >> 1)
}
return crc
}
}
private func levelName(for level: LogLevel) -> String {
switch level.rawValue {
case 0: return "DEBUG"
case 1: return "INFO"
case 2: return "NOTICE"
case 3: return "WARNING"
case 4: return "ERROR"
case 5: return "CRITICAL"
default: return "UNKNOWN"
}
}
}
// MARK: - DateFormatter Extension
extension DateFormatter {
static let exportTimestamp: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
static let filenameTimestamp: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmmss"
return formatter
}()
}
// MARK: - Helper Extensions for ZIP
extension UInt32 {
var littleEndianBytes: [UInt8] {
return [
UInt8(self & 0xFF),
UInt8((self >> 8) & 0xFF),
UInt8((self >> 16) & 0xFF),
UInt8((self >> 24) & 0xFF)
]
}
}
extension UInt16 {
var littleEndianBytes: [UInt8] {
return [
UInt8(self & 0xFF),
UInt8((self >> 8) & 0xFF)
]
}
}