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.
324 lines
11 KiB
Swift
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)
|
|
]
|
|
}
|
|
}
|