From 6c23daaaba2a954220ece2afa7193dbdcad55c22 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 22 Apr 2026 13:47:37 -0500 Subject: feat(v3.2.0): add Data+ tier and external data integrations - introduce Data+ tier for advanced data features - add ownership history and DNS history - expand subdomain discovery with external sources - add domain pricing insights - implement local usage/credit system - integrate external data service layer with rate limiting - extend export and CLI for Data+ features --- DomainDig/ExternalDataService.swift | 627 ++++++++++++++++++++++++++++++++++++ 1 file changed, 627 insertions(+) create mode 100644 DomainDig/ExternalDataService.swift (limited to 'DomainDig/ExternalDataService.swift') diff --git a/DomainDig/ExternalDataService.swift b/DomainDig/ExternalDataService.swift new file mode 100644 index 0000000..5b4bf66 --- /dev/null +++ b/DomainDig/ExternalDataService.swift @@ -0,0 +1,627 @@ +import Foundation + +actor ExternalDataService { + static let shared = ExternalDataService() + + private enum RequestKey: Hashable { + case ownershipHistory(String) + case dnsHistory(String) + case extendedSubdomains(String) + case pricing(String) + } + + private enum RateLimitBucket: Hashable { + case history + case subdomains + case pricing + + var minimumSpacing: TimeInterval { + switch self { + case .history: + return 1.0 + case .subdomains: + return 1.5 + case .pricing: + return 1.0 + } + } + } + + private enum CachedPayload { + case ownershipHistory(ServiceResult<[DomainOwnershipHistoryEvent]>) + case dnsHistory(ServiceResult<[DNSHistoryEvent]>) + case extendedSubdomains(ServiceResult<[DiscoveredSubdomain]>) + case pricing(ServiceResult) + } + + private struct CacheEntry { + let payload: CachedPayload + let expiresAt: Date + } + + private struct Configuration { + let ownershipHistoryURL: String? + let dnsHistoryURL: String? + let extendedSubdomainsURL: String? + let pricingURL: String? + } + + private let ttl: TimeInterval = 900 + private let session: URLSession = .shared + private var cache: [RequestKey: CacheEntry] = [:] + private var inFlight: [RequestKey: Task] = [:] + private var nextAllowedAt: [RateLimitBucket: Date] = [:] + + func clearCache() { + cache.removeAll() + inFlight.values.forEach { $0.cancel() } + inFlight.removeAll() + nextAllowedAt.removeAll() + } + + func ownershipHistory( + domain: String, + currentOwnership: DomainOwnership?, + historyEntries: [HistoryEntry] + ) async -> CachedLookupResult> { + let normalizedDomain = Self.normalize(domain) + return await execute( + key: .ownershipHistory(normalizedDomain), + rateLimitBucket: .history, + extract: { payload in + guard case let .ownershipHistory(result) = payload else { return nil } + return result + }, + operation: { [configuration = configuration()] in + let localEvents = Self.localOwnershipHistory( + domain: normalizedDomain, + currentOwnership: currentOwnership, + historyEntries: historyEntries + ) + let externalEvents = await self.fetchOwnershipHistory(domain: normalizedDomain, configuration: configuration) + return .ownershipHistory(Self.mergeOwnershipHistory(localEvents: localEvents, externalEvents: externalEvents)) + } + ) + } + + func dnsHistory( + domain: String, + dnsSections: [DNSSection], + historyEntries: [HistoryEntry] + ) async -> CachedLookupResult> { + let normalizedDomain = Self.normalize(domain) + return await execute( + key: .dnsHistory(normalizedDomain), + rateLimitBucket: .history, + extract: { payload in + guard case let .dnsHistory(result) = payload else { return nil } + return result + }, + operation: { [configuration = configuration()] in + let localEvents = Self.localDNSHistory( + domain: normalizedDomain, + dnsSections: dnsSections, + historyEntries: historyEntries + ) + let externalEvents = await self.fetchDNSHistory(domain: normalizedDomain, configuration: configuration) + return .dnsHistory(Self.mergeDNSHistory(localEvents: localEvents, externalEvents: externalEvents)) + } + ) + } + + func extendedSubdomains( + domain: String, + existing: [DiscoveredSubdomain] + ) async -> CachedLookupResult> { + let normalizedDomain = Self.normalize(domain) + return await execute( + key: .extendedSubdomains(normalizedDomain), + rateLimitBucket: .subdomains, + extract: { payload in + guard case let .extendedSubdomains(result) = payload else { return nil } + return result + }, + operation: { [configuration = configuration()] in + var merged = existing + switch await SubdomainDiscoveryService.discover(for: normalizedDomain, limit: 100) { + case let .success(results): + merged = Self.mergeSubdomains(primary: merged, additional: results.map { + DiscoveredSubdomain(hostname: $0.hostname, source: $0.source ?? "crt.sh", isExtended: true) + }) + case .empty, .error: + break + } + + if let external = await self.fetchExtendedSubdomains(domain: normalizedDomain, configuration: configuration) { + merged = Self.mergeSubdomains(primary: merged, additional: external) + } + + if merged.count <= existing.count { + return .extendedSubdomains(.empty("No extended subdomains available")) + } + + let onlyExtended = merged.filter(\.isExtended) + return .extendedSubdomains(.success(onlyExtended.sorted { $0.hostname < $1.hostname })) + } + ) + } + + func pricing(domain: String) async -> CachedLookupResult> { + let normalizedDomain = Self.normalize(domain) + return await execute( + key: .pricing(normalizedDomain), + rateLimitBucket: .pricing, + extract: { payload in + guard case let .pricing(result) = payload else { return nil } + return result + }, + operation: { [configuration = configuration()] in + .pricing(await self.fetchPricing(domain: normalizedDomain, configuration: configuration)) + } + ) + } + + private func execute( + key: RequestKey, + rateLimitBucket: RateLimitBucket, + extract: @escaping (CachedPayload) -> T?, + operation: @escaping @Sendable () async -> CachedPayload + ) async -> CachedLookupResult { + if let cachedEntry = cache[key], cachedEntry.expiresAt > Date(), let value = extract(cachedEntry.payload) { + return CachedLookupResult(value: value, source: .cached) + } + + if let task = inFlight[key], let value = extract(await task.value) { + return CachedLookupResult(value: value, source: .mixed) + } + + let task = Task { + await self.enforceRateLimit(for: rateLimitBucket) + return await operation() + } + inFlight[key] = task + + let payload = await task.value + cache[key] = CacheEntry(payload: payload, expiresAt: Date().addingTimeInterval(ttl)) + inFlight[key] = nil + + guard let value = extract(payload) else { + fatalError("ExternalDataService payload extraction mismatch") + } + + return CachedLookupResult(value: value, source: .live) + } + + private func enforceRateLimit(for bucket: RateLimitBucket) async { + let now = Date() + if let nextAllowed = nextAllowedAt[bucket], nextAllowed > now { + let delay = nextAllowed.timeIntervalSince(now) + if delay > 0 { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + } + nextAllowedAt[bucket] = Date().addingTimeInterval(bucket.minimumSpacing) + } + + private func configuration() -> Configuration { + let defaults = UserDefaults.standard + return Configuration( + ownershipHistoryURL: defaults.string(forKey: "externalData.ownershipHistoryURL") + ?? Bundle.main.object(forInfoDictionaryKey: "ExternalOwnershipHistoryURL") as? String, + dnsHistoryURL: defaults.string(forKey: "externalData.dnsHistoryURL") + ?? Bundle.main.object(forInfoDictionaryKey: "ExternalDNSHistoryURL") as? String, + extendedSubdomainsURL: defaults.string(forKey: "externalData.extendedSubdomainsURL") + ?? Bundle.main.object(forInfoDictionaryKey: "ExternalExtendedSubdomainsURL") as? String, + pricingURL: defaults.string(forKey: "externalData.pricingURL") + ?? Bundle.main.object(forInfoDictionaryKey: "ExternalPricingURL") as? String + ) + } + + private func fetchOwnershipHistory( + domain: String, + configuration: Configuration + ) async -> [DomainOwnershipHistoryEvent] { + guard let template = configuration.ownershipHistoryURL, + let url = Self.url(from: template, domain: domain) else { + return [] + } + + switch await requestData(url: url) { + case let .success(data): + return parseOwnershipHistoryEvents(from: data) + case .empty, .error: + return [] + } + } + + private func fetchDNSHistory( + domain: String, + configuration: Configuration + ) async -> [DNSHistoryEvent] { + guard let template = configuration.dnsHistoryURL, + let url = Self.url(from: template, domain: domain) else { + return [] + } + + switch await requestData(url: url) { + case let .success(data): + return parseDNSHistoryEvents(from: data) + case .empty, .error: + return [] + } + } + + private func fetchExtendedSubdomains( + domain: String, + configuration: Configuration + ) async -> [DiscoveredSubdomain]? { + guard let template = configuration.extendedSubdomainsURL, + let url = Self.url(from: template, domain: domain) else { + return nil + } + + switch await requestData(url: url) { + case let .success(data): + return parseSubdomains(from: data) + case .empty, .error: + return nil + } + } + + private func fetchPricing( + domain: String, + configuration: Configuration + ) async -> ServiceResult { + guard let template = configuration.pricingURL, + let url = Self.url(from: template, domain: domain) else { + return .empty("External pricing unavailable") + } + + switch await requestData(url: url) { + case let .success(data): + guard let pricing = parsePricing(from: data) else { + return .error("Invalid external response") + } + return .success(pricing) + case let .empty(message): + return .empty(message) + case let .error(message): + return .error(message) + } + } + + private func requestData(url: URL) async -> ServiceResult { + for attempt in 0..<2 { + do { + let (data, response) = try await session.data(for: URLRequest(url: url, timeoutInterval: 8)) + guard let httpResponse = response as? HTTPURLResponse else { + return .error("External data unavailable") + } + + switch httpResponse.statusCode { + case 200: + return .success(data) + case 204, 404: + return .empty("No external data available") + case 429: + return .error("Rate limit reached") + case 500...599 where attempt == 0: + continue + default: + return .error("External provider failed") + } + } catch is DecodingError { + return .error("Invalid external response") + } catch { + if attempt == 0 { + continue + } + return .error(error.localizedDescription) + } + } + + return .error("External provider failed") + } + + private func parseOwnershipHistoryEvents(from data: Data) -> [DomainOwnershipHistoryEvent] { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawEvents = json["events"] as? [[String: Any]] else { + return [] + } + + return rawEvents.compactMap { event in + guard let dateString = event["date"] as? String, + let date = Self.iso8601DateFormatter.date(from: dateString) else { + return nil + } + + return DomainOwnershipHistoryEvent( + date: date, + summary: event["summary"] as? String ?? "Ownership change observed", + registrar: event["registrar"] as? String, + registrant: event["registrant"] as? String, + nameservers: event["nameservers"] as? [String] ?? [], + source: event["source"] as? String ?? "Configured external history feed", + isExternal: true + ) + } + } + + private func parseDNSHistoryEvents(from data: Data) -> [DNSHistoryEvent] { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawEvents = json["events"] as? [[String: Any]] else { + return [] + } + + return rawEvents.compactMap { event in + guard let dateString = event["date"] as? String, + let date = Self.iso8601DateFormatter.date(from: dateString) else { + return nil + } + + return DNSHistoryEvent( + date: date, + summary: event["summary"] as? String ?? "DNS change observed", + aRecords: event["a_records"] as? [String] ?? [], + nameservers: event["nameservers"] as? [String] ?? [], + source: event["source"] as? String ?? "Configured external history feed", + isExternal: true + ) + } + } + + private func parseSubdomains(from data: Data) -> [DiscoveredSubdomain] { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawSubdomains = json["subdomains"] as? [[String: Any]] else { + return [] + } + + return rawSubdomains.compactMap { item in + guard let hostname = item["hostname"] as? String else { return nil } + return DiscoveredSubdomain( + hostname: hostname, + source: item["source"] as? String ?? "Configured external subdomain feed", + isExtended: true + ) + } + } + + private func parsePricing(from data: Data) -> DomainPricingInsight? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + return DomainPricingInsight( + estimatedPrice: json["estimated_price"] as? String, + premiumIndicator: json["premium"] as? Bool, + resaleSignal: json["resale_signal"] as? String, + auctionSignal: json["auction_signal"] as? String, + source: json["source"] as? String ?? "Configured external pricing feed", + collectedAt: Date() + ) + } + + private static func localOwnershipHistory( + domain: String, + currentOwnership: DomainOwnership?, + historyEntries: [HistoryEntry] + ) -> [DomainOwnershipHistoryEvent] { + let domainHistory = historyEntries + .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } + .sorted { $0.timestamp < $1.timestamp } + + var events: [DomainOwnershipHistoryEvent] = [] + var previousOwnership: DomainOwnership? + + for entry in domainHistory { + guard let ownership = entry.ownership else { continue } + let summary = ownershipSummaryChange(previous: previousOwnership, current: ownership) + if let summary { + events.append( + DomainOwnershipHistoryEvent( + date: entry.timestamp, + summary: summary, + registrar: ownership.registrar, + registrant: ownership.registrant, + nameservers: ownership.nameservers, + source: "Local observations", + isExternal: false + ) + ) + } + previousOwnership = ownership + } + + if let currentOwnership, events.isEmpty { + events.append( + DomainOwnershipHistoryEvent( + date: Date(), + summary: "Current ownership snapshot", + registrar: currentOwnership.registrar, + registrant: currentOwnership.registrant, + nameservers: currentOwnership.nameservers, + source: "Local observations", + isExternal: false + ) + ) + } + + return events.sorted { $0.date > $1.date } + } + + private static func localDNSHistory( + domain: String, + dnsSections: [DNSSection], + historyEntries: [HistoryEntry] + ) -> [DNSHistoryEvent] { + let domainHistory = historyEntries + .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } + .sorted { $0.timestamp < $1.timestamp } + + var events: [DNSHistoryEvent] = [] + var previousARecords: [String] = [] + var previousNameservers: [String] = [] + + for entry in domainHistory { + let aRecords = Self.dnsValues(for: .A, in: entry.dnsSections) + let nameservers = Self.dnsValues(for: .NS, in: entry.dnsSections) + let summary = dnsSummaryChange( + previousARecords: previousARecords, + currentARecords: aRecords, + previousNameservers: previousNameservers, + currentNameservers: nameservers + ) + + if let summary { + events.append( + DNSHistoryEvent( + date: entry.timestamp, + summary: summary, + aRecords: aRecords, + nameservers: nameservers, + source: "Local observations", + isExternal: false + ) + ) + } + + previousARecords = aRecords + previousNameservers = nameservers + } + + if events.isEmpty { + let currentARecords = Self.dnsValues(for: .A, in: dnsSections) + let currentNameservers = Self.dnsValues(for: .NS, in: dnsSections) + if !currentARecords.isEmpty || !currentNameservers.isEmpty { + events.append( + DNSHistoryEvent( + date: Date(), + summary: "Current DNS snapshot", + aRecords: currentARecords, + nameservers: currentNameservers, + source: "Local observations", + isExternal: false + ) + ) + } + } + + return events.sorted { $0.date > $1.date } + } + + private static func mergeOwnershipHistory( + localEvents: [DomainOwnershipHistoryEvent], + externalEvents: [DomainOwnershipHistoryEvent] + ) -> ServiceResult<[DomainOwnershipHistoryEvent]> { + let merged = (externalEvents + localEvents) + .sorted { $0.date > $1.date } + .reduce(into: [DomainOwnershipHistoryEvent]()) { partialResult, event in + let duplicate = partialResult.contains { + $0.date == event.date + && $0.summary == event.summary + && $0.registrar == event.registrar + && $0.nameservers == event.nameservers + } + if !duplicate { + partialResult.append(event) + } + } + + return merged.isEmpty ? .empty("No ownership history available") : .success(merged) + } + + private static func mergeDNSHistory( + localEvents: [DNSHistoryEvent], + externalEvents: [DNSHistoryEvent] + ) -> ServiceResult<[DNSHistoryEvent]> { + let merged = (externalEvents + localEvents) + .sorted { $0.date > $1.date } + .reduce(into: [DNSHistoryEvent]()) { partialResult, event in + let duplicate = partialResult.contains { + $0.date == event.date + && $0.summary == event.summary + && $0.aRecords == event.aRecords + && $0.nameservers == event.nameservers + } + if !duplicate { + partialResult.append(event) + } + } + + return merged.isEmpty ? .empty("No DNS history available") : .success(merged) + } + + private static func mergeSubdomains( + primary: [DiscoveredSubdomain], + additional: [DiscoveredSubdomain] + ) -> [DiscoveredSubdomain] { + var seen = Set(primary.map { $0.hostname.lowercased() }) + var merged = primary + + for subdomain in additional where seen.insert(subdomain.hostname.lowercased()).inserted { + merged.append(subdomain) + } + + return merged.sorted { $0.hostname < $1.hostname } + } + + private static func ownershipSummaryChange(previous: DomainOwnership?, current: DomainOwnership) -> String? { + guard let previous else { + return "Initial ownership observation" + } + + var changes: [String] = [] + if previous.registrar != current.registrar { + changes.append("Registrar changed") + } + if previous.registrant != current.registrant, current.registrant != nil { + changes.append("Ownership changed") + } + if previous.nameservers != current.nameservers { + changes.append("Nameservers changed") + } + + return changes.isEmpty ? nil : changes.joined(separator: " • ") + } + + private static func dnsSummaryChange( + previousARecords: [String], + currentARecords: [String], + previousNameservers: [String], + currentNameservers: [String] + ) -> String? { + var changes: [String] = [] + if previousARecords != currentARecords, !currentARecords.isEmpty { + changes.append("A records changed") + } + if previousNameservers != currentNameservers, !currentNameservers.isEmpty { + changes.append("NS records changed") + } + if previousARecords.isEmpty && previousNameservers.isEmpty && (!currentARecords.isEmpty || !currentNameservers.isEmpty) { + changes.append("Initial DNS observation") + } + return changes.isEmpty ? nil : changes.joined(separator: " • ") + } + + private static func url(from template: String, domain: String) -> URL? { + URL(string: template.replacingOccurrences(of: "{domain}", with: domain)) + } + + private static func normalize(_ domain: String) -> String { + domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private static func dnsValues(for type: DNSRecordType, in sections: [DNSSection]) -> [String] { + sections + .first(where: { $0.recordType == type })? + .records + .map(\.value) + .sorted() ?? [] + } + + private static let iso8601DateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() +} -- cgit v1.2.3