mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			212 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			212 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2024 Rangeproof Pty Ltd. All rights reserved.
 | |
| //
 | |
| // stringlint:disable
 | |
| 
 | |
| import Foundation
 | |
| import Combine
 | |
| import GRDB
 | |
| import SessionSnodeKit
 | |
| import SessionUtilitiesKit
 | |
| 
 | |
| private extension Log.Category {
 | |
|     static var ip2Country: Log.Category = "IP2Country"
 | |
| }
 | |
| 
 | |
| public enum IP2Country {
 | |
|     public static var isInitialized: Atomic<Bool> = Atomic(false)
 | |
|     private static var countryNamesCache: Atomic<[String: String]> = Atomic([:])
 | |
|     private static var pathsChangedCallbackId: Atomic<UUID?> = Atomic(nil)
 | |
|     private static let _cacheLoaded: CurrentValueSubject<Bool, Never> = CurrentValueSubject(false)
 | |
|     public static var cacheLoaded: AnyPublisher<Bool, Never> {
 | |
|         _cacheLoaded.filter { $0 }.eraseToAnyPublisher()
 | |
|     }
 | |
|     private static var currentLocale: String {
 | |
|         let result: String? = Locale.current.identifier
 | |
|             .components(separatedBy: "_")
 | |
|             .first
 | |
|             .map { identifier in
 | |
|                 switch identifier {
 | |
|                     case "zh-Hans", "zh": return "zh-CN"  // Not ideal, but the best we can do
 | |
|                     default: return identifier
 | |
|                 }
 | |
|             }
 | |
|         
 | |
|         return (result ?? "en")  // Fallback to English
 | |
|     }
 | |
|     
 | |
|     // MARK: - Tables
 | |
|     
 | |
|     /// This struct contains the data from two tables
 | |
|     ///
 | |
|     /// The `countryBlocks` has two columns: the "network" column and the "registered_country_geoname_id" column
 | |
|     ///
 | |
|     /// The network column contains the **lower** bound of an IP range and the "registered_country_geoname_id" column contains the
 | |
|     /// ID of the country corresponding to that range. We look up an IP by finding the first index in the network column where the value is
 | |
|     /// greater than the IP we're looking up (converted to an integer). The IP we're looking up must then be in the range **before** that range.
 | |
|     ///
 | |
|     /// The `countryLocations` has three columns: the "locale_code" column, the "geoname_id" column and the "country_name" column
 | |
|     ///
 | |
|     /// These are populated in such a way that you would retrieve in index range in `countryLocationsLocaleCode` for the current locale
 | |
|     /// (or `en` as default), then find the `geonameId` index from `countryLocationsGeonameId` using the same range, and that index
 | |
|     /// should be retrieved from `countryLocationsCountryName` in order to get the country name
 | |
|     struct IP2CountryCache {
 | |
|         var countryBlocksIPInt: [Int64] = []
 | |
|         var countryBlocksGeonameId: [String] = []
 | |
|         
 | |
|         var countryLocationsLocaleCode: [String] = []
 | |
|         var countryLocationsGeonameId: [String] = []
 | |
|         var countryLocationsCountryName: [String] = []
 | |
|     }
 | |
|     
 | |
|     private static var cache: IP2CountryCache = {
 | |
|         guard
 | |
|             let url: URL = Bundle.main.url(forResource: "GeoLite2-Country-Blocks-IPv4", withExtension: nil),
 | |
|             let data: Data = try? Data(contentsOf: url)
 | |
|         else { return IP2CountryCache() }
 | |
|         
 | |
|         /// Extract the number of IPs
 | |
|         var countryBlockIPCount: Int32 = 0
 | |
|         _ = withUnsafeMutableBytes(of: &countryBlockIPCount) { countBuffer in
 | |
|             data.copyBytes(to: countBuffer, from: ..<MemoryLayout<Int32>.size)
 | |
|         }
 | |
|         
 | |
|         /// Move past the IP count
 | |
|         var remainingData: Data = data.advanced(by: MemoryLayout<Int32>.size)
 | |
|         
 | |
|         /// Extract the IPs
 | |
|         var countryBlockIpInts: [Int64] = [Int64](repeating: 0, count: Int(countryBlockIPCount))
 | |
|         remainingData.withUnsafeBytes { buffer in
 | |
|             _ = countryBlockIpInts.withUnsafeMutableBytes { ipBuffer in
 | |
|                 memcpy(ipBuffer.baseAddress, buffer.baseAddress, Int(countryBlockIPCount) * MemoryLayout<Int64>.size)
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         var countryBlockIpInts2: [Int] = [Int](repeating: 0, count: Int(countryBlockIPCount))
 | |
|         remainingData.withUnsafeBytes { buffer in
 | |
|             _ = countryBlockIpInts.withUnsafeMutableBytes { ipBuffer in
 | |
|                 memcpy(ipBuffer.baseAddress, buffer.baseAddress, Int(countryBlockIPCount) * MemoryLayout<Int>.size)
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         /// Extract arrays from the parts
 | |
|         func consumeStringArray(_ name: String, from targetData: inout Data) -> [String] {
 | |
|             /// The data should have a count, followed by actual data (so should have more data than an Int32 would take
 | |
|             guard targetData.count > MemoryLayout<Int32>.size else {
 | |
|                 Log.error(.ip2Country, "\(name) doesn't have enough data after the count.")
 | |
|                 return []
 | |
|             }
 | |
|             
 | |
|             var targetCount: Int32 = targetData
 | |
|                 .prefix(MemoryLayout<Int32>.size)
 | |
|                 .withUnsafeBytes { bytes -> Int32 in
 | |
|                     guard
 | |
|                         bytes.count >= MemoryLayout<Int32>.size,
 | |
|                         let baseAddress: UnsafePointer<Int32> = bytes
 | |
|                             .bindMemory(to: Int32.self)
 | |
|                             .baseAddress
 | |
|                     else { return 0 }
 | |
|                     
 | |
|                     return baseAddress.pointee
 | |
|                 }
 | |
|             
 | |
|             /// Move past the count and extract the content data
 | |
|             targetData = targetData.dropFirst(MemoryLayout<Int32>.size)
 | |
|             let contentData: Data = targetData.prefix(Int(targetCount))
 | |
|             
 | |
|             guard
 | |
|                 !contentData.isEmpty,
 | |
|                 let contentString: String = String(data: contentData, encoding: .utf8)
 | |
|             else {
 | |
|                 Log.error(.ip2Country, "\(name) failed to convert the content to a string.")
 | |
|                 return []
 | |
|             }
 | |
|             
 | |
|             /// There was a crash related to advancing the data in an invalid way in `2.7.0`, if this does occur then
 | |
|             /// we want to know about it so add a log
 | |
|             if targetCount > targetData.count {
 | |
|                 Log.error(.ip2Country, "\(name) suggested it had mare data then was actually available (\(targetCount) vs. \(targetData.count)).")
 | |
|             }
 | |
|             
 | |
|             /// Move past the data and return the result
 | |
|             targetData = targetData.dropFirst(Int(targetCount))
 | |
|             return contentString.components(separatedBy: "\0\0")
 | |
|         }
 | |
|         
 | |
|         /// Move past the IP data
 | |
|         remainingData = remainingData.advanced(by: (Int(countryBlockIPCount) * MemoryLayout<Int64>.size))
 | |
|         let countryBlocksGeonameIds: [String] = consumeStringArray("CountryBlocks", from: &remainingData)
 | |
|         let countryLocaleCodes: [String] = consumeStringArray("LocaleCodes", from: &remainingData)
 | |
|         let countryGeonameIds: [String] = consumeStringArray("Geonames", from: &remainingData)
 | |
|         let countryNames: [String] = consumeStringArray("CountryNames", from: &remainingData)
 | |
| 
 | |
|         return IP2CountryCache(
 | |
|             countryBlocksIPInt: countryBlockIpInts,
 | |
|             countryBlocksGeonameId: countryBlocksGeonameIds,
 | |
|             countryLocationsLocaleCode: countryLocaleCodes,
 | |
|             countryLocationsGeonameId: countryGeonameIds,
 | |
|             countryLocationsCountryName: countryNames
 | |
|         )
 | |
|     }()
 | |
|     
 | |
|     // MARK: - Implementation
 | |
| 
 | |
|     static func populateCacheIfNeededAsync() {
 | |
|         DispatchQueue.global(qos: .utility).async {
 | |
|             /// Ensure the lookup tables get loaded in the background
 | |
|             _ = cache
 | |
|             
 | |
|             pathsChangedCallbackId.mutate { pathsChangedCallbackId in
 | |
|                 guard pathsChangedCallbackId == nil else { return }
 | |
|                 
 | |
|                 pathsChangedCallbackId = LibSession.onPathsChanged(callback: { paths, _ in
 | |
|                     self.populateCacheIfNeeded(paths: paths)
 | |
|                 })
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private static func populateCacheIfNeeded(paths: [[LibSession.Snode]]) {
 | |
|         guard !paths.isEmpty else { return }
 | |
|         
 | |
|         countryNamesCache.mutate { cache in
 | |
|             paths.forEach { path in
 | |
|                 path.forEach { snode in
 | |
|                     self.cacheCountry(for: snode.ip, inCache: &cache)
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         self._cacheLoaded.send(true)
 | |
|         Log.info("Updated onion request path countries.")
 | |
|     }
 | |
|     
 | |
|     private static func cacheCountry(for ip: String, inCache nameCache: inout [String: String]) {
 | |
|         let currentLocale: String = self.currentLocale  // Store local copy for efficiency
 | |
|         
 | |
|         guard nameCache["\(ip)-\(currentLocale)"] == nil else { return }
 | |
|         
 | |
|         guard
 | |
|             let ipAsInt: Int64 = IPv4.toInt(ip),
 | |
|             let countryBlockGeonameIdIndex: Int = cache.countryBlocksIPInt.firstIndex(where: { $0 > ipAsInt }).map({ $0 - 1 }),
 | |
|             let localeStartIndex: Int = cache.countryLocationsLocaleCode.firstIndex(where: { $0 == currentLocale }),
 | |
|             let countryNameIndex: Int = Array(cache.countryLocationsGeonameId[localeStartIndex...]).firstIndex(where: { geonameId in
 | |
|                 geonameId == cache.countryBlocksGeonameId[countryBlockGeonameIdIndex]
 | |
|             }),
 | |
|             (localeStartIndex + countryNameIndex) < cache.countryLocationsCountryName.count
 | |
|         else { return }
 | |
|         
 | |
|         let result: String = cache.countryLocationsCountryName[localeStartIndex + countryNameIndex]
 | |
|         nameCache["\(ip)-\(currentLocale)"] = result
 | |
|     }
 | |
|     
 | |
|     // MARK: - Functions
 | |
|     
 | |
|     public static func country(for ip: String) -> String {
 | |
|         let fallback: String = "Resolving..."
 | |
|         
 | |
|         guard _cacheLoaded.value else { return fallback }
 | |
|         
 | |
|         return (countryNamesCache.wrappedValue["\(ip)-\(currentLocale)"] ?? fallback)
 | |
|     }
 | |
| }
 |