// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import GRDB import SessionSnodeKit import SessionUtil import SessionUtilitiesKit // MARK: - LibSession public extension LibSession { struct ConfResult { let needsPush: Bool let needsDump: Bool } struct IncomingConfResult { let needsPush: Bool let needsDump: Bool let messageHashes: [String] let latestSentTimestamp: TimeInterval var result: ConfResult { ConfResult(needsPush: needsPush, needsDump: needsDump) } } // MARK: - PendingChanges struct PendingChanges { public struct PushData { let data: Data let seqNo: Int64 let variant: ConfigDump.Variant } var pushData: [PushData] var obsoleteHashes: Set init(pushData: [PushData] = [], obsoleteHashes: Set = []) { self.pushData = pushData self.obsoleteHashes = obsoleteHashes } mutating func append(data: PushData? = nil, hashes: [String] = []) { if let data: PushData = data { pushData.append(data) } obsoleteHashes.insert(contentsOf: Set(hashes)) } } // MARK: - Configs fileprivate static var configStore: Atomic<[ConfigKey: Atomic?>]> = Atomic([:]) static func config(for variant: ConfigDump.Variant, publicKey: String) -> Atomic?> { let key: ConfigKey = ConfigKey(variant: variant, publicKey: publicKey) return ( LibSession.configStore.wrappedValue[key] ?? Atomic(nil) ) } // MARK: - Variables internal static func syncDedupeId(_ publicKey: String) -> String { return "EnqueueConfigurationSyncJob-\(publicKey)" // stringlint:disable } /// Returns `true` if there is a config which needs to be pushed, but returns `false` if the configs are all up to date or haven't been /// loaded yet (eg. fresh install) static var needsSync: Bool { configStore .wrappedValue .contains { _, atomicConf in guard atomicConf.wrappedValue != nil else { return false } return config_needs_push(atomicConf.wrappedValue) } } static var libSessionVersion: String { String(cString: LIBSESSION_UTIL_VERSION_STR) } internal static func lastError(_ conf: UnsafeMutablePointer?) -> String { return (conf?.pointee.last_error.map { String(cString: $0) } ?? "Unknown") // stringlint:disable } // MARK: - Loading static func clearMemoryState() { LibSession.configStore.mutate { confStore in confStore.removeAll() } } static func loadState( _ db: Database? = nil, userPublicKey: String, ed25519SecretKey: [UInt8]? ) { // Ensure we have the ed25519 key and that we haven't already loaded the state before // we continue guard let secretKey: [UInt8] = ed25519SecretKey, LibSession.configStore.wrappedValue.isEmpty else { return SNLog("[LibSession] Ignoring loadState for '\(userPublicKey)' due to existing state") } // If we weren't given a database instance then get one guard let db: Database = db else { Storage.shared.read { db in LibSession.loadState(db, userPublicKey: userPublicKey, ed25519SecretKey: secretKey) } return } // Retrieve the existing dumps from the database let existingDumps: Set = ((try? ConfigDump.fetchSet(db)) ?? []) let existingDumpVariants: Set = existingDumps .map { $0.variant } .asSet() let missingRequiredVariants: Set = ConfigDump.Variant.userVariants .asSet() .subtracting(existingDumpVariants) // Create the 'config_object' records for each dump LibSession.configStore.mutate { confStore in existingDumps.forEach { dump in confStore[ConfigKey(variant: dump.variant, publicKey: dump.publicKey)] = Atomic( try? LibSession.loadState( for: dump.variant, secretKey: secretKey, cachedData: dump.data ) ) } missingRequiredVariants.forEach { variant in confStore[ConfigKey(variant: variant, publicKey: userPublicKey)] = Atomic( try? LibSession.loadState( for: variant, secretKey: secretKey, cachedData: nil ) ) } } SNLog("[LibSession] Completed loadState for '\(userPublicKey)'") } private static func loadState( for variant: ConfigDump.Variant, secretKey ed25519SecretKey: [UInt8], cachedData: Data? ) throws -> UnsafeMutablePointer? { // Setup initial variables (including getting the memory address for any cached data) var conf: UnsafeMutablePointer? = nil var error: [CChar] = [CChar](repeating: 0, count: 256) let cachedDump: (data: UnsafePointer, length: Int)? = cachedData?.withUnsafeBytes { unsafeBytes in return unsafeBytes.baseAddress.map { ( $0.assumingMemoryBound(to: UInt8.self), unsafeBytes.count ) } } // Try to create the object var secretKey: [UInt8] = ed25519SecretKey let result: Int32 = try { switch variant { case .invalid: SNLog("[LibSession Error] Unable to create \(variant.rawValue) config object") throw LibSessionError.unableToCreateConfigObject case .userProfile: return user_profile_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), &error) case .contacts: return contacts_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), &error) case .convoInfoVolatile: return convo_info_volatile_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), &error) case .userGroups: return user_groups_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), &error) } }() guard result == 0 else { SNLog("[LibSession Error] Unable to create \(variant.rawValue) config object: \(String(cString: error))") throw LibSessionError.unableToCreateConfigObject } return conf } internal static func createDump( conf: UnsafeMutablePointer?, for variant: ConfigDump.Variant, publicKey: String, timestampMs: Int64 ) throws -> ConfigDump? { guard conf != nil else { throw LibSessionError.nilConfigObject } // If it doesn't need a dump then do nothing guard config_needs_dump(conf) else { return nil } var dumpResult: UnsafeMutablePointer? = nil var dumpResultLen: Int = 0 try CExceptionHelper.performSafely { config_dump(conf, &dumpResult, &dumpResultLen) } guard let dumpResult: UnsafeMutablePointer = dumpResult else { return nil } let dumpData: Data = Data(bytes: dumpResult, count: dumpResultLen) dumpResult.deallocate() return ConfigDump( variant: variant, publicKey: publicKey, data: dumpData, timestampMs: timestampMs ) } // MARK: - Pushes static func pendingChanges( _ db: Database, publicKey: String ) throws -> PendingChanges { guard Identity.userExists(db) else { throw LibSessionError.userDoesNotExist } let userPublicKey: String = getUserHexEncodedPublicKey(db) var existingDumpVariants: Set = try ConfigDump .select(.variant) .filter(ConfigDump.Columns.publicKey == publicKey) .asRequest(of: ConfigDump.Variant.self) .fetchSet(db) // Ensure we always check the required user config types for changes even if there is no dump // data yet (to deal with first launch cases) if publicKey == userPublicKey { ConfigDump.Variant.userVariants.forEach { existingDumpVariants.insert($0) } } // Ensure we always check the required user config types for changes even if there is no dump // data yet (to deal with first launch cases) return try existingDumpVariants .reduce(into: PendingChanges()) { result, variant in try LibSession .config(for: variant, publicKey: publicKey) .wrappedValue .map { conf in // Check if the config needs to be pushed guard config_needs_push(conf) else { // If not then try retrieve any obsolete hashes to be removed guard let cObsoletePtr: UnsafeMutablePointer = config_old_hashes(conf) else { return } let obsoleteHashes: [String] = [String]( pointer: cObsoletePtr.pointee.value, count: cObsoletePtr.pointee.len, defaultValue: [] ) // If there are no obsolete hashes then no need to return anything guard !obsoleteHashes.isEmpty else { return } result.append(hashes: obsoleteHashes) return } var cPushData: UnsafeMutablePointer! let configCountInfo: String = { var result: String = "Invalid" // stringlint:disable try? CExceptionHelper.performSafely { switch variant { case .userProfile: result = "1 profile" case .contacts: result = "\(contacts_size(conf)) contacts" case .userGroups: result = "\(user_groups_size(conf)) group conversations" case .convoInfoVolatile: result = "\(convo_info_volatile_size(conf)) volatile conversations" case .invalid: break } } return result }() do { try CExceptionHelper.performSafely { cPushData = config_push(conf) } } catch { SNLog("[LibSession] Failed to generate push data for \(variant) config data, size: \(configCountInfo), error: \(error)") throw error } let pushData: Data = Data( bytes: cPushData.pointee.config, count: cPushData.pointee.config_len ) let obsoleteHashes: [String] = [String]( pointer: cPushData.pointee.obsolete, count: cPushData.pointee.obsolete_len, defaultValue: [] ) let seqNo: Int64 = cPushData.pointee.seqno cPushData.deallocate() result.append( data: PendingChanges.PushData( data: pushData, seqNo: seqNo, variant: variant ), hashes: obsoleteHashes ) } } } static func markingAsPushed( seqNo: Int64, serverHash: String, sentTimestamp: Int64, variant: ConfigDump.Variant, publicKey: String ) -> ConfigDump? { return LibSession .config(for: variant, publicKey: publicKey) .mutate { conf in guard conf != nil else { return nil } // Mark the config as pushed var cHash: [CChar] = serverHash.cArray.nullTerminated() config_confirm_pushed(conf, seqNo, &cHash) // Update the result to indicate whether the config needs to be dumped guard config_needs_dump(conf) else { return nil } return try? LibSession.createDump( conf: conf, for: variant, publicKey: publicKey, timestampMs: sentTimestamp ) } } static func configHashes(for publicKey: String) -> [String] { return Storage.shared .read { db -> Set in guard Identity.userExists(db) else { return [] } return try ConfigDump .select(.variant) .filter(ConfigDump.Columns.publicKey == publicKey) .asRequest(of: ConfigDump.Variant.self) .fetchSet(db) } .defaulting(to: []) .map { variant -> [String] in /// Extract all existing hashes for any dumps associated with the given `publicKey` guard let conf = LibSession .config(for: variant, publicKey: publicKey) .wrappedValue, let hashList: UnsafeMutablePointer = config_current_hashes(conf) else { return [] } let result: [String] = [String]( pointer: hashList.pointee.value, count: hashList.pointee.len, defaultValue: [] ) hashList.deallocate() return result } .reduce([], +) } // MARK: - Receiving static func handleConfigMessages( _ db: Database, messages: [ConfigMessageReceiveJob.Details.MessageInfo], publicKey: String ) throws { guard !messages.isEmpty else { return } guard !publicKey.isEmpty else { throw MessageReceiverError.noThread } let groupedMessages: [ConfigDump.Variant: [ConfigMessageReceiveJob.Details.MessageInfo]] = messages .grouped(by: { ConfigDump.Variant(namespace: $0.namespace) }) try groupedMessages .sorted { lhs, rhs in lhs.key.namespace.processingOrder < rhs.key.namespace.processingOrder } .forEach { key, value in try LibSession .config(for: key, publicKey: publicKey) .mutate { conf in // Merge the messages var mergeHashes: [UnsafePointer?] = value .map { message in message.serverHash.cArray.nullTerminated() } .unsafeCopy() var mergeData: [UnsafePointer?] = value .map { message -> [UInt8] in message.data.bytes } .unsafeCopy() var mergeSize: [Int] = value.map { $0.data.count } var mergedHashesPtr: UnsafeMutablePointer? try CExceptionHelper.performSafely { mergedHashesPtr = config_merge( conf, &mergeHashes, &mergeData, &mergeSize, value.count ) } mergeHashes.forEach { $0?.deallocate() } mergeData.forEach { $0?.deallocate() } // Get the list of hashes from the config (to determine which were successful) let mergedHashes: [String] = mergedHashesPtr .map { ptr in [String]( pointer: ptr.pointee.value, count: ptr.pointee.len, defaultValue: [] ) } .defaulting(to: []) let maybeLatestConfigSentTimestampMs: Int64? = value .filter { mergedHashes.contains($0.serverHash) } .compactMap { $0.serverTimestampMs } .sorted() .last mergedHashesPtr?.deallocate() // If no messages were merged then no need to do anything guard let latestConfigSentTimestampMs: Int64 = maybeLatestConfigSentTimestampMs else { return } // Apply the updated states to the database do { switch key { case .userProfile: try LibSession.handleUserProfileUpdate( db, in: conf, mergeNeedsDump: config_needs_dump(conf), latestConfigSentTimestampMs: latestConfigSentTimestampMs ) case .contacts: try LibSession.handleContactsUpdate( db, in: conf, mergeNeedsDump: config_needs_dump(conf), latestConfigSentTimestampMs: latestConfigSentTimestampMs ) case .convoInfoVolatile: try LibSession.handleConvoInfoVolatileUpdate( db, in: conf, mergeNeedsDump: config_needs_dump(conf) ) case .userGroups: try LibSession.handleGroupsUpdate( db, in: conf, mergeNeedsDump: config_needs_dump(conf), latestConfigSentTimestampMs: latestConfigSentTimestampMs ) case .invalid: SNLog("[libSession] Failed to process merge of invalid config namespace") } } catch { SNLog("[LibSession] Failed to process merge of \(key) config data") throw error } // Need to check if the config needs to be dumped (this might have changed // after handling the merge changes) guard config_needs_dump(conf) else { try ConfigDump .filter( ConfigDump.Columns.variant == key && ConfigDump.Columns.publicKey == publicKey ) .updateAll( db, ConfigDump.Columns.timestampMs.set(to: latestConfigSentTimestampMs) ) return } try LibSession.createDump( conf: conf, for: key, publicKey: publicKey, timestampMs: latestConfigSentTimestampMs )?.save(db) } } // Now that the local state has been updated, schedule a config sync (we want to always // do this in case there are obsolete hashes we want to clear) db.afterNextTransactionNestedOnce(dedupeId: LibSession.syncDedupeId(publicKey)) { db in ConfigurationSyncJob.enqueue(db, publicKey: publicKey) } } } // MARK: - Internal Convenience fileprivate extension LibSession { struct ConfigKey: Hashable { let variant: ConfigDump.Variant let publicKey: String } } // MARK: - Convenience public extension LibSession { static func parseCommunity(url: String) -> (room: String, server: String, publicKey: String)? { var cFullUrl: [CChar] = url.cArray.nullTerminated() var cBaseUrl: [CChar] = [CChar](repeating: 0, count: COMMUNITY_BASE_URL_MAX_LENGTH) var cRoom: [CChar] = [CChar](repeating: 0, count: COMMUNITY_ROOM_MAX_LENGTH) var cPubkey: [UInt8] = [UInt8](repeating: 0, count: OpenGroup.pubkeyByteLength) guard community_parse_full_url(&cFullUrl, &cBaseUrl, &cRoom, &cPubkey) && !String(cString: cRoom).isEmpty && !String(cString: cBaseUrl).isEmpty && cPubkey.contains(where: { $0 != 0 }) else { return nil } // Note: Need to store them in variables instead of returning directly to ensure they // don't get freed from memory early (was seeing this happen intermittently during // unit tests...) let room: String = String(cString: cRoom) let baseUrl: String = String(cString: cBaseUrl) let pubkeyHex: String = Data(cPubkey).toHexString() return (room, baseUrl, pubkeyHex) } static func communityUrlFor(server: String, roomToken: String, publicKey: String) -> String { var cBaseUrl: [CChar] = server.cArray.nullTerminated() var cRoom: [CChar] = roomToken.cArray.nullTerminated() var cPubkey: [UInt8] = Data(hex: publicKey).cArray var cFullUrl: [CChar] = [CChar](repeating: 0, count: COMMUNITY_FULL_URL_MAX_LENGTH) community_make_full_url(&cBaseUrl, &cRoom, &cPubkey, &cFullUrl) return String(cString: cFullUrl) } }