diff options
| author | Christian Cleberg <[email protected]> | 2026-04-22 13:47:37 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-22 13:47:37 -0500 |
| commit | 6c23daaaba2a954220ece2afa7193dbdcad55c22 (patch) | |
| tree | febfca71eba8a025374d993e7a39fdb13194af25 | |
| parent | dcf4135ec376d357b8fafc0101a75b674e76329a (diff) | |
| download | domain-dig-6c23daaaba2a954220ece2afa7193dbdcad55c22.tar.gz domain-dig-6c23daaaba2a954220ece2afa7193dbdcad55c22.tar.bz2 domain-dig-6c23daaaba2a954220ece2afa7193dbdcad55c22.zip | |
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
| -rw-r--r-- | DomainDig/AppVersion.swift | 4 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 438 | ||||
| -rw-r--r-- | DomainDig/DataAccessService.swift | 2 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 338 | ||||
| -rw-r--r-- | DomainDig/ExternalDataService.swift | 627 | ||||
| -rw-r--r-- | DomainDig/FeatureAccessService.swift | 16 | ||||
| -rw-r--r-- | DomainDig/HistoryView.swift | 3 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 158 | ||||
| -rw-r--r-- | DomainDig/PaywallView.swift | 13 | ||||
| -rw-r--r-- | DomainDig/PurchaseService.swift | 36 | ||||
| -rw-r--r-- | DomainDig/RDAPService.swift | 5 | ||||
| -rw-r--r-- | DomainDig/SubdomainDiscoveryService.swift | 2 | ||||
| -rw-r--r-- | DomainDig/UsageCreditService.swift | 73 | ||||
| -rw-r--r-- | DomainDigCLI.swift | 208 | ||||
| -rw-r--r-- | DomainInspectionService.swift | 8 | ||||
| -rw-r--r-- | DomainReportBuilder.swift | 10 | ||||
| -rw-r--r-- | DomainReportExporter.swift | 75 | ||||
| -rw-r--r-- | LookupSnapshot.swift | 16 |
18 files changed, 1935 insertions, 97 deletions
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift index d9f3fc7..f28a017 100644 --- a/DomainDig/AppVersion.swift +++ b/DomainDig/AppVersion.swift @@ -1,7 +1,7 @@ import Foundation enum AppVersion { - static var current: String { - "3.1.0" + nonisolated static var current: String { + "3.2.0" } } diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index e6e7a41..a4e8db3 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -71,62 +71,11 @@ struct ContentView: View { .padding(.top, appDensity.metrics.cardSpacing) } } - DomainSectionView( - isCollapsed: sectionCollapsedBinding(.domain), - rows: viewModel.domainRows, - suggestions: viewModel.suggestionRows, - showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, - availabilityLoading: viewModel.availabilityLoading, - suggestionsLoading: viewModel.suggestionsLoading, - provenance: viewModel.currentSnapshot.provenanceBySection[.availability], - confidence: viewModel.currentSnapshot.availabilityConfidence, - snapshotNote: viewModel.currentSnapshot.note, - trackedDomain: viewModel.currentTrackedDomain, - workflows: viewModel.currentDomainWorkflows, - trackingLimitMessage: viewModel.trackingLimitMessage, - onTrack: { - _ = viewModel.trackCurrentDomain() - }, - onTogglePinned: { - guard let trackedDomain = viewModel.currentTrackedDomain else { return } - viewModel.togglePinned(for: trackedDomain) - }, - onEditNote: { - guard let trackedDomain = viewModel.currentTrackedDomain else { return } - trackingNoteDraft = trackedDomain.note ?? "" - editingTrackedDomain = trackedDomain - }, - onAddToWorkflow: { - showingCurrentDomainWorkflowSheet = true - }, - onOpenWorkflow: { workflow in - navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id)) - }, - onRunWorkflow: { workflow in - viewModel.rerunCurrentDomain(in: workflow) - } - ) + domainOverviewSection .padding(.top, appDensity.metrics.sectionSpacing) - OwnershipSectionView( - isCollapsed: sectionCollapsedBinding(.ownership), - rows: viewModel.ownershipRows, - loading: viewModel.ownershipLoading, - error: viewModel.ownershipError, - provenance: viewModel.currentSnapshot.provenanceBySection[.ownership], - confidence: viewModel.currentSnapshot.ownershipConfidence, - showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) - ) + ownershipSection .padding(.top, appDensity.metrics.sectionSpacing) - SubdomainsSectionView( - isCollapsed: sectionCollapsedBinding(.subdomains), - rows: viewModel.subdomainRows, - groups: viewModel.currentSubdomainGroups, - loading: viewModel.subdomainsLoading, - error: viewModel.subdomainsError, - provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains], - confidence: viewModel.currentSnapshot.subdomainConfidence, - showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) - ) + subdomainsSection .padding(.top, appDensity.metrics.sectionSpacing) if !viewModel.currentDiffSections.isEmpty { DomainDiffView( @@ -137,17 +86,7 @@ struct ContentView: View { ) .padding(.top, appDensity.metrics.sectionSpacing) } - DNSSectionView( - isCollapsed: sectionCollapsedBinding(.dns), - dnssecLabel: viewModel.dnssecLabel, - patternSummary: viewModel.currentDNSPatterns, - sections: viewModel.dnsRows, - ptrMessage: viewModel.ptrMessage, - loading: viewModel.dnsLoading || viewModel.ptrLoading, - dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns], - ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr], - sectionError: viewModel.dnsError - ) + dnsSection .padding(.top, appDensity.metrics.sectionSpacing) WebSectionView( isCollapsed: sectionCollapsedBinding(.web), @@ -240,6 +179,9 @@ struct ContentView: View { .onAppear { domainFieldFocused = true } + .task { + await viewModel.refreshUsageCredits() + } .onChange(of: viewModel.searchedDomain) { _, _ in collapsedSections = defaultCollapsedSections } @@ -372,6 +314,117 @@ struct ContentView: View { .padding(.vertical, appDensity.metrics.sectionSpacing) } + private var domainOverviewSection: some View { + let trackedDomain = viewModel.currentTrackedDomain + let workflows = viewModel.currentDomainWorkflows + + return DomainSectionView( + isCollapsed: sectionCollapsedBinding(.domain), + rows: viewModel.domainRows, + suggestions: viewModel.suggestionRows, + showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, + availabilityLoading: viewModel.availabilityLoading, + suggestionsLoading: viewModel.suggestionsLoading, + provenance: viewModel.currentSnapshot.provenanceBySection[.availability], + confidence: viewModel.currentSnapshot.availabilityConfidence, + snapshotNote: viewModel.currentSnapshot.note, + trackedDomain: trackedDomain, + workflows: workflows, + trackingLimitMessage: viewModel.trackingLimitMessage, + pricingLoading: viewModel.domainPricingLoading, + pricingError: viewModel.domainPricingError, + showsPricingPlaceholder: !DataAccessService.hasAccess(to: .domainPricing), + onTrack: { + _ = viewModel.trackCurrentDomain() + }, + onTogglePinned: { + guard let trackedDomain else { return } + viewModel.togglePinned(for: trackedDomain) + }, + onEditNote: { + guard let trackedDomain else { return } + trackingNoteDraft = trackedDomain.note ?? "" + editingTrackedDomain = trackedDomain + }, + onAddToWorkflow: { + showingCurrentDomainWorkflowSheet = true + }, + onOpenWorkflow: { workflow in + navigationPath.append(WorkflowNavigationTarget(workflowID: workflow.id)) + }, + onRunWorkflow: { workflow in + viewModel.rerunCurrentDomain(in: workflow) + } + ) + } + + private var ownershipSection: some View { + OwnershipSectionView( + isCollapsed: sectionCollapsedBinding(.ownership), + rows: viewModel.ownershipRows, + loading: viewModel.ownershipLoading, + error: viewModel.ownershipError, + provenance: viewModel.currentSnapshot.provenanceBySection[.ownership], + confidence: viewModel.currentSnapshot.ownershipConfidence, + showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory), + history: viewModel.ownershipHistory, + historyLoading: viewModel.ownershipHistoryLoading, + historyError: viewModel.ownershipHistoryError, + historyCreditStatus: viewModel.ownershipHistoryCreditStatus, + onLoadHistory: { + Task { + await viewModel.loadOwnershipHistory() + } + } + ) + } + + private var subdomainsSection: some View { + SubdomainsSectionView( + isCollapsed: sectionCollapsedBinding(.subdomains), + rows: viewModel.subdomainRows, + groups: viewModel.currentSubdomainGroups, + loading: viewModel.subdomainsLoading, + error: viewModel.subdomainsError, + provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains], + confidence: viewModel.currentSnapshot.subdomainConfidence, + showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains), + extendedCount: viewModel.extendedSubdomains.count, + extendedLoading: viewModel.extendedSubdomainsLoading, + extendedError: viewModel.extendedSubdomainsError, + extendedCreditStatus: viewModel.extendedSubdomainsCreditStatus, + onLoadExtended: { + Task { + await viewModel.loadExtendedSubdomains() + } + } + ) + } + + private var dnsSection: some View { + DNSSectionView( + isCollapsed: sectionCollapsedBinding(.dns), + dnssecLabel: viewModel.dnssecLabel, + patternSummary: viewModel.currentDNSPatterns, + sections: viewModel.dnsRows, + ptrMessage: viewModel.ptrMessage, + loading: viewModel.dnsLoading || viewModel.ptrLoading, + dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns], + ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr], + sectionError: viewModel.dnsError, + history: viewModel.dnsHistory, + historyLoading: viewModel.dnsHistoryLoading, + historyError: viewModel.dnsHistoryError, + showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .dnsHistory), + historyCreditStatus: viewModel.dnsHistoryCreditStatus, + onLoadHistory: { + Task { + await viewModel.loadDNSHistory() + } + } + ) + } + private var actionButtons: some View { HStack { Spacer() @@ -1192,6 +1245,9 @@ struct DomainSectionView: View { let trackedDomain: TrackedDomain? let workflows: [DomainWorkflow] let trackingLimitMessage: String? + let pricingLoading: Bool + let pricingError: String? + let showsPricingPlaceholder: Bool let onTrack: () -> Void let onTogglePinned: () -> Void let onEditNote: (() -> Void)? @@ -1313,12 +1369,24 @@ struct DomainSectionView: View { } } } + if pricingLoading { + ProgressView("Loading external pricing…") + .appLoadingStyle() + .padding(.top, 4) + } else if let pricingError { + MessageRowView(text: pricingError, isError: false) + .padding(.top, 4) + } else if showsPricingPlaceholder { + MessageRowView(text: "Pricing signals available in Data+", isError: false) + .padding(.top, 4) + } } } } } struct OwnershipSectionView: View { + @Environment(\.appDensity) private var appDensity @Binding var isCollapsed: Bool let rows: [InfoRowViewData] let loading: Bool @@ -1326,6 +1394,39 @@ struct OwnershipSectionView: View { let provenance: SectionProvenance? let confidence: ConfidenceLevel? let showsHistoryPlaceholder: Bool + let history: [DomainOwnershipHistoryEvent] + let historyLoading: Bool + let historyError: String? + let historyCreditStatus: UsageCreditStatus? + let onLoadHistory: (() -> Void)? + + init( + isCollapsed: Binding<Bool>, + rows: [InfoRowViewData], + loading: Bool, + error: String?, + provenance: SectionProvenance?, + confidence: ConfidenceLevel?, + showsHistoryPlaceholder: Bool, + history: [DomainOwnershipHistoryEvent] = [], + historyLoading: Bool = false, + historyError: String? = nil, + historyCreditStatus: UsageCreditStatus? = nil, + onLoadHistory: (() -> Void)? = nil + ) { + _isCollapsed = isCollapsed + self.rows = rows + self.loading = loading + self.error = error + self.provenance = provenance + self.confidence = confidence + self.showsHistoryPlaceholder = showsHistoryPlaceholder + self.history = history + self.historyLoading = historyLoading + self.historyError = historyError + self.historyCreditStatus = historyCreditStatus + self.onLoadHistory = onLoadHistory + } var body: some View { CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) { @@ -1342,9 +1443,41 @@ struct OwnershipSectionView: View { MessageRowView(text: error, isError: error != "Unavailable") .padding(.top, 4) } - if showsHistoryPlaceholder { - MessageRowView(text: "Ownership history (coming soon)", isError: false) - .padding(.top, 4) + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("History") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + Spacer() + if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { + Button("Load (\(historyCreditStatus.remaining) left)") { + onLoadHistory() + } + .buttonStyle(.bordered) + .font(appDensity.font(.caption2)) + } + } + if historyLoading { + ProgressView("Loading history…") + .appLoadingStyle() + } else if !history.isEmpty { + ForEach(history) { event in + VStack(alignment: .leading, spacing: 3) { + Text(event.date.formatted(date: .abbreviated, time: .omitted)) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + Text(event.summary) + .font(appDensity.font(.caption)) + Text(event.source) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } + } else if let historyError { + MessageRowView(text: historyError, isError: false) + } else if showsHistoryPlaceholder { + MessageRowView(text: "Ownership history available in Data+", isError: false) + } } } } @@ -1362,6 +1495,41 @@ struct SubdomainsSectionView: View { let provenance: SectionProvenance? let confidence: ConfidenceLevel? let showsExtendedPlaceholder: Bool + let extendedCount: Int + let extendedLoading: Bool + let extendedError: String? + let extendedCreditStatus: UsageCreditStatus? + let onLoadExtended: (() -> Void)? + + init( + isCollapsed: Binding<Bool>, + rows: [SubdomainRowViewData], + groups: [SubdomainGroup], + loading: Bool, + error: String?, + provenance: SectionProvenance?, + confidence: ConfidenceLevel?, + showsExtendedPlaceholder: Bool, + extendedCount: Int = 0, + extendedLoading: Bool = false, + extendedError: String? = nil, + extendedCreditStatus: UsageCreditStatus? = nil, + onLoadExtended: (() -> Void)? = nil + ) { + _isCollapsed = isCollapsed + self.rows = rows + self.groups = groups + self.loading = loading + self.error = error + self.provenance = provenance + self.confidence = confidence + self.showsExtendedPlaceholder = showsExtendedPlaceholder + self.extendedCount = extendedCount + self.extendedLoading = extendedLoading + self.extendedError = extendedError + self.extendedCreditStatus = extendedCreditStatus + self.onLoadExtended = onLoadExtended + } var body: some View { CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") { @@ -1373,10 +1541,17 @@ struct SubdomainsSectionView: View { } else if rows.isEmpty { MessageRowView(text: error ?? "No passive subdomains found", isError: false) if showsExtendedPlaceholder { - MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false) + MessageRowView(text: "Extended subdomain discovery available in Data+", isError: false) .padding(.top, 4) } } else { + if let extendedCreditStatus, let onLoadExtended, extendedCount == 0, !extendedLoading, !showsExtendedPlaceholder { + Button("Load extended results (\(extendedCreditStatus.remaining) left)") { + onLoadExtended() + } + .buttonStyle(.bordered) + .font(appDensity.font(.caption2)) + } if !groups.isEmpty { Text("Groups") .font(appDensity.font(.caption2)) @@ -1411,8 +1586,18 @@ struct SubdomainsSectionView: View { } } } - if showsExtendedPlaceholder { - MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false) + if extendedLoading { + ProgressView("Loading extended subdomains…") + .appLoadingStyle() + .padding(.top, 4) + } else if extendedCount > 0 { + MessageRowView(text: "\(extendedCount) extended results included", isError: false) + .padding(.top, 4) + } else if let extendedError { + MessageRowView(text: extendedError, isError: false) + .padding(.top, 4) + } else if showsExtendedPlaceholder { + MessageRowView(text: "Extended subdomain discovery available in Data+", isError: false) .padding(.top, 4) } } @@ -1422,6 +1607,7 @@ struct SubdomainsSectionView: View { } struct DNSSectionView: View { + @Environment(\.appDensity) private var appDensity @Binding var isCollapsed: Bool let dnssecLabel: String? let patternSummary: DNSPatternSummary? @@ -1431,6 +1617,46 @@ struct DNSSectionView: View { let dnsProvenance: SectionProvenance? let ptrProvenance: SectionProvenance? let sectionError: String? + let history: [DNSHistoryEvent] + let historyLoading: Bool + let historyError: String? + let showsHistoryPlaceholder: Bool + let historyCreditStatus: UsageCreditStatus? + let onLoadHistory: (() -> Void)? + + init( + isCollapsed: Binding<Bool>, + dnssecLabel: String?, + patternSummary: DNSPatternSummary?, + sections: [DNSRecordSectionViewData], + ptrMessage: SectionMessageViewData?, + loading: Bool, + dnsProvenance: SectionProvenance?, + ptrProvenance: SectionProvenance?, + sectionError: String?, + history: [DNSHistoryEvent] = [], + historyLoading: Bool = false, + historyError: String? = nil, + showsHistoryPlaceholder: Bool = false, + historyCreditStatus: UsageCreditStatus? = nil, + onLoadHistory: (() -> Void)? = nil + ) { + _isCollapsed = isCollapsed + self.dnssecLabel = dnssecLabel + self.patternSummary = patternSummary + self.sections = sections + self.ptrMessage = ptrMessage + self.loading = loading + self.dnsProvenance = dnsProvenance + self.ptrProvenance = ptrProvenance + self.sectionError = sectionError + self.history = history + self.historyLoading = historyLoading + self.historyError = historyError + self.showsHistoryPlaceholder = showsHistoryPlaceholder + self.historyCreditStatus = historyCreditStatus + self.onLoadHistory = onLoadHistory + } var body: some View { CollapsibleSectionView(title: "DNS", isCollapsed: $isCollapsed, subtitle: dnssecLabel) { @@ -1491,6 +1717,50 @@ struct DNSSectionView: View { MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError) } } + + CardView(allowsHorizontalScroll: false) { + HStack { + Text("History") + .font(appDensity.font(.subheadline, weight: .semibold)) + .foregroundStyle(.cyan) + Spacer() + if let historyCreditStatus, let onLoadHistory, history.isEmpty, !historyLoading, !showsHistoryPlaceholder { + Button("Load (\(historyCreditStatus.remaining) left)") { + onLoadHistory() + } + .buttonStyle(.bordered) + .font(appDensity.font(.caption2)) + } + } + if historyLoading { + ProgressView("Loading DNS history…") + .appLoadingStyle() + } else if !history.isEmpty { + ForEach(history) { event in + VStack(alignment: .leading, spacing: 3) { + Text(event.date.formatted(date: .abbreviated, time: .omitted)) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + Text(event.summary) + .font(appDensity.font(.caption)) + if !event.aRecords.isEmpty { + Text("A: \(event.aRecords.joined(separator: ", "))") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + if !event.nameservers.isEmpty { + Text("NS: \(event.nameservers.joined(separator: ", "))") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } + } + } else if let historyError { + MessageRowView(text: historyError, isError: false) + } else if showsHistoryPlaceholder { + MessageRowView(text: "DNS history available in Data+", isError: false) + } + } } } } @@ -2220,11 +2490,11 @@ struct SettingsView: View { } } - Section("Pro") { + Section("Tier") { LabeledContent("Status", value: purchaseService.currentTier.title) if purchaseService.currentTier == .free { - Button("Upgrade to Pro") { + Button("Upgrade") { viewModel.isPaywallPresented = true } } else { @@ -2255,6 +2525,23 @@ struct SettingsView: View { } } + Section("Data+ Usage") { + ForEach(UsageCreditFeature.allCases) { feature in + let status = viewModel.usageCredits[feature] ?? UsageCreditStatus( + feature: feature, + remaining: feature.defaultAllowance, + total: feature.defaultAllowance, + resetContext: "Resets with app version \(AppVersion.current)" + ) + VStack(alignment: .leading, spacing: 2) { + LabeledContent(feature.title, value: status.summary) + Text(status.resetContext) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } + } + } + Section("Features") { ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in Text(label) @@ -2264,7 +2551,7 @@ struct SettingsView: View { Section("About") { LabeledContent("Version", value: appVersion) LabeledContent("Storage", value: "Local-only") - LabeledContent("Report Schema", value: "3.1.0") + LabeledContent("Report Schema", value: "3.2.0") } } .navigationTitle("Settings") @@ -2304,6 +2591,9 @@ struct SettingsView: View { let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) resolverOption = DNSResolverOption.option(for: currentResolverURL) customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString + Task { + await viewModel.refreshUsageCredits() + } } .onChange(of: resolverOption) { _, newValue in guard let presetURL = newValue.urlString else { diff --git a/DomainDig/DataAccessService.swift b/DomainDig/DataAccessService.swift index d0254ac..98e215d 100644 --- a/DomainDig/DataAccessService.swift +++ b/DomainDig/DataAccessService.swift @@ -10,7 +10,7 @@ enum DataAccessService { case .extendedSubdomains: return FeatureAccessService.hasAccess(to: .extendedSubdomains) case .domainPricing: - return false + return FeatureAccessService.hasAccess(to: .domainPricing) } } } diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 6ec7e64..83d282c 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -147,6 +147,9 @@ final class DomainViewModel { var ownershipResult: DomainOwnership? var ownershipLoading = false var ownershipError: String? + var ownershipHistory: [DomainOwnershipHistoryEvent] = [] + var ownershipHistoryLoading = false + var ownershipHistoryError: String? var ptrRecord: String? var ptrLoading = false @@ -159,6 +162,16 @@ final class DomainViewModel { var subdomains: [DiscoveredSubdomain] = [] var subdomainsLoading = false var subdomainsError: String? + var extendedSubdomains: [DiscoveredSubdomain] = [] + var extendedSubdomainsLoading = false + var extendedSubdomainsError: String? + var dnsHistory: [DNSHistoryEvent] = [] + var dnsHistoryLoading = false + var dnsHistoryError: String? + var domainPricing: DomainPricingInsight? + var domainPricingLoading = false + var domainPricingError: String? + var usageCredits: [UsageCreditFeature: UsageCreditStatus] = DomainViewModel.defaultUsageCredits() var portScanResults: [PortScanResult] = [] var portScanLoading = false @@ -263,8 +276,12 @@ final class DomainViewModel { if sslLoading || hstsLoading { labels.append("TLS") } if httpHeadersLoading { labels.append("HTTP") } if ownershipLoading { labels.append("Ownership") } + if ownershipHistoryLoading { labels.append("Ownership History") } if emailSecurityLoading { labels.append("Email") } if subdomainsLoading { labels.append("Subdomains") } + if extendedSubdomainsLoading { labels.append("Extended Subdomains") } + if dnsHistoryLoading { labels.append("DNS History") } + if domainPricingLoading { labels.append("Pricing") } if redirectChainLoading { labels.append("Redirects") } if reachabilityLoading { labels.append("Reachability") } if ipGeolocationLoading { labels.append("Geolocation") } @@ -438,12 +455,20 @@ final class DomainViewModel { emailSecurityError: emailSecurityError, ownership: ownershipResult, ownershipError: ownershipError, + ownershipHistory: ownershipHistory, + ownershipHistoryError: ownershipHistoryError, ptrRecord: ptrRecord, ptrError: ptrError, redirectChain: redirectChain, redirectChainError: redirectChainError, subdomains: subdomains, subdomainsError: subdomainsError, + extendedSubdomains: extendedSubdomains, + extendedSubdomainsError: extendedSubdomainsError, + dnsHistory: dnsHistory, + dnsHistoryError: dnsHistoryError, + domainPricing: domainPricing, + domainPricingError: domainPricingError, portScanResults: allPortScanResults, portScanError: combinedPortScanError, changeSummary: currentChangeSummary, @@ -482,6 +507,23 @@ final class DomainViewModel { currentReport?.web } + var ownershipHistoryCreditStatus: UsageCreditStatus { + usageCredits[.ownershipHistory] ?? Self.fallbackCreditStatus(for: .ownershipHistory) + } + + var dnsHistoryCreditStatus: UsageCreditStatus { + usageCredits[.dnsHistory] ?? Self.fallbackCreditStatus(for: .dnsHistory) + } + + var extendedSubdomainsCreditStatus: UsageCreditStatus { + usageCredits[.extendedSubdomains] ?? Self.fallbackCreditStatus(for: .extendedSubdomains) + } + + var combinedSubdomains: [DiscoveredSubdomain] { + let existingHosts = Set(subdomains.map { $0.hostname.lowercased() }) + return subdomains + extendedSubdomains.filter { !existingHosts.contains($0.hostname.lowercased()) } + } + var summaryFields: [SummaryFieldViewData] { Self.summaryFields(from: currentSnapshot) } @@ -527,7 +569,7 @@ final class DomainViewModel { } var subdomainRows: [SubdomainRowViewData] { - Self.subdomainRows(from: currentSnapshot) + Self.subdomainRows(from: combinedSubdomains) } var reachabilityRows: [ReachabilityRowViewData] { @@ -934,6 +976,151 @@ final class DomainViewModel { return String(data: data, encoding: .utf8) } + func loadOwnershipHistory() async { + guard !searchedDomain.isEmpty else { return } + guard DataAccessService.hasAccess(to: .ownershipHistory) else { + upgradePrompt = FeatureAccessService.upgradePrompt(for: .ownershipHistory) + return + } + guard ownershipHistory.isEmpty else { return } + + let creditStatus = await UsageCreditService.shared.status(for: .ownershipHistory) + guard !creditStatus.isExhausted else { + ownershipHistoryError = "No ownership history credits remaining" + await refreshUsageCredits() + return + } + + ownershipHistoryLoading = true + ownershipHistoryError = nil + + let outcome = await ExternalDataService.shared.ownershipHistory( + domain: searchedDomain, + currentOwnership: ownershipResult, + historyEntries: history + ) + + switch outcome.value { + case let .success(events): + ownershipHistory = events + ownershipHistoryError = nil + if outcome.source != .cached { + _ = await UsageCreditService.shared.consume(.ownershipHistory) + } + case let .empty(message): + ownershipHistory = [] + ownershipHistoryError = message + if outcome.source != .cached { + _ = await UsageCreditService.shared.consume(.ownershipHistory) + } + case let .error(message): + ownershipHistory = [] + ownershipHistoryError = conciseExternalMessage(message, fallback: "Ownership history unavailable") + } + + ownershipHistoryLoading = false + _ = saveHistoryEntry(replaceLatest: true) + await refreshUsageCredits() + } + + func loadDNSHistory() async { + guard !searchedDomain.isEmpty else { return } + guard DataAccessService.hasAccess(to: .dnsHistory) else { + upgradePrompt = FeatureAccessService.upgradePrompt(for: .dnsHistory) + return + } + guard dnsHistory.isEmpty else { return } + + let creditStatus = await UsageCreditService.shared.status(for: .dnsHistory) + guard !creditStatus.isExhausted else { + dnsHistoryError = "No DNS history credits remaining" + await refreshUsageCredits() + return + } + + dnsHistoryLoading = true + dnsHistoryError = nil + + let outcome = await ExternalDataService.shared.dnsHistory( + domain: searchedDomain, + dnsSections: dnsSections, + historyEntries: history + ) + + switch outcome.value { + case let .success(events): + dnsHistory = events + dnsHistoryError = nil + if outcome.source != .cached { + _ = await UsageCreditService.shared.consume(.dnsHistory) + } + case let .empty(message): + dnsHistory = [] + dnsHistoryError = message + if outcome.source != .cached { + _ = await UsageCreditService.shared.consume(.dnsHistory) + } + case let .error(message): + dnsHistory = [] + dnsHistoryError = conciseExternalMessage(message, fallback: "DNS history unavailable") + } + + dnsHistoryLoading = false + _ = saveHistoryEntry(replaceLatest: true) + await refreshUsageCredits() + } + + func loadExtendedSubdomains() async { + guard !searchedDomain.isEmpty else { return } + guard DataAccessService.hasAccess(to: .extendedSubdomains) else { + upgradePrompt = FeatureAccessService.upgradePrompt(for: .extendedSubdomains) + return + } + guard extendedSubdomains.isEmpty else { return } + + let creditStatus = await UsageCreditService.shared.status(for: .extendedSubdomains) + guard !creditStatus.isExhausted else { + extendedSubdomainsError = "No extended subdomain credits remaining" + await refreshUsageCredits() + return + } + + extendedSubdomainsLoading = true + extendedSubdomainsError = nil + + let outcome = await ExternalDataService.shared.extendedSubdomains( + domain: searchedDomain, + existing: subdomains + ) + + switch outcome.value { + case let .success(results): + extendedSubdomains = results + extendedSubdomainsError = nil + if outcome.source != .cached { + _ = await UsageCreditService.shared.consume(.extendedSubdomains) + } + case let .empty(message): + extendedSubdomains = [] + extendedSubdomainsError = message + if outcome.source != .cached { + _ = await UsageCreditService.shared.consume(.extendedSubdomains) + } + case let .error(message): + extendedSubdomains = [] + extendedSubdomainsError = conciseExternalMessage(message, fallback: "Extended subdomains unavailable") + } + + extendedSubdomainsLoading = false + _ = saveHistoryEntry(replaceLatest: true) + await refreshUsageCredits() + } + + func refreshUsageCredits() async { + let statuses = await UsageCreditService.shared.allStatuses() + usageCredits = Dictionary(uniqueKeysWithValues: statuses.map { ($0.feature, $0) }) + } + func exportBatchText() -> String { DomainReportExporter.batchText( for: currentBatchReports(), @@ -1019,11 +1206,18 @@ final class DomainViewModel { lastLookupDurationMs = snapshot.totalLookupDurationMs refreshingTrackedDomainID = nil + if DataAccessService.hasAccess(to: .domainPricing), domainPricing == nil { + await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false) + } + guard snapshot.statusMessage == nil else { + await refreshUsageCredits() return history.first(where: { $0.id == snapshot.historyEntryID }) } - return saveHistoryEntry(replaceLatest: false) + let entry = saveHistoryEntry(replaceLatest: false) + await refreshUsageCredits() + return entry } private func applySnapshot(_ snapshot: LookupSnapshot) { @@ -1066,12 +1260,20 @@ final class DomainViewModel { emailSecurityError = snapshot.emailSecurityError ownershipResult = snapshot.ownership ownershipError = snapshot.ownershipError + ownershipHistory = snapshot.ownershipHistory + ownershipHistoryError = snapshot.ownershipHistoryError ptrRecord = snapshot.ptrRecord ptrError = snapshot.ptrError redirectChain = snapshot.redirectChain redirectChainError = snapshot.redirectChainError subdomains = snapshot.subdomains subdomainsError = snapshot.subdomainsError + extendedSubdomains = snapshot.extendedSubdomains + extendedSubdomainsError = snapshot.extendedSubdomainsError + dnsHistory = snapshot.dnsHistory + dnsHistoryError = snapshot.dnsHistoryError + domainPricing = snapshot.domainPricing + domainPricingError = snapshot.domainPricingError portScanResults = snapshot.portScanResults.filter { $0.kind == .standard } customPortResults = snapshot.portScanResults.filter { $0.kind == .custom } portScanError = snapshot.portScanError @@ -1087,9 +1289,13 @@ final class DomainViewModel { ipGeolocationLoading = false emailSecurityLoading = false ownershipLoading = false + ownershipHistoryLoading = false ptrLoading = false redirectChainLoading = false subdomainsLoading = false + extendedSubdomainsLoading = false + dnsHistoryLoading = false + domainPricingLoading = false portScanLoading = false customPortScanLoading = false } @@ -1144,12 +1350,20 @@ final class DomainViewModel { emailSecurityError: previousSnapshot.emailSecurityError, ownership: previousSnapshot.ownership, ownershipError: previousSnapshot.ownershipError, + ownershipHistory: previousSnapshot.ownershipHistory, + ownershipHistoryError: previousSnapshot.ownershipHistoryError, ptrRecord: previousSnapshot.ptrRecord, ptrError: previousSnapshot.ptrError, redirectChain: previousSnapshot.redirectChain, redirectChainError: previousSnapshot.redirectChainError, subdomains: previousSnapshot.subdomains, subdomainsError: previousSnapshot.subdomainsError, + extendedSubdomains: previousSnapshot.extendedSubdomains, + extendedSubdomainsError: previousSnapshot.extendedSubdomainsError, + dnsHistory: previousSnapshot.dnsHistory, + dnsHistoryError: previousSnapshot.dnsHistoryError, + domainPricing: previousSnapshot.domainPricing, + domainPricingError: previousSnapshot.domainPricingError, portScanResults: previousSnapshot.portScanResults, portScanError: previousSnapshot.portScanError, changeSummary: previousSnapshot.changeSummary, @@ -1515,9 +1729,13 @@ final class DomainViewModel { emailSecurity: snapshot.emailSecurity, mtaSts: snapshot.emailSecurity?.mtaSts, ownership: snapshot.ownership, + ownershipHistory: snapshot.ownershipHistory, ptrRecord: snapshot.ptrRecord, redirectChain: snapshot.redirectChain, subdomains: snapshot.subdomains, + extendedSubdomains: snapshot.extendedSubdomains, + dnsHistory: snapshot.dnsHistory, + domainPricing: snapshot.domainPricing, portScanResults: snapshot.portScanResults, hstsPreloaded: snapshot.hstsPreloaded, availabilityResult: snapshot.availabilityResult, @@ -1549,9 +1767,13 @@ final class DomainViewModel { ipGeolocationError: snapshot.ipGeolocationError, emailSecurityError: snapshot.emailSecurityError, ownershipError: snapshot.ownershipError, + ownershipHistoryError: snapshot.ownershipHistoryError, ptrError: snapshot.ptrError, redirectChainError: snapshot.redirectChainError, subdomainsError: snapshot.subdomainsError, + extendedSubdomainsError: snapshot.extendedSubdomainsError, + dnsHistoryError: snapshot.dnsHistoryError, + domainPricingError: snapshot.domainPricingError, portScanError: snapshot.portScanError ) @@ -2038,6 +2260,9 @@ final class DomainViewModel { ownershipResult = nil ownershipError = nil ownershipLoading = false + ownershipHistory = [] + ownershipHistoryError = nil + ownershipHistoryLoading = false ptrRecord = nil ptrError = nil ptrLoading = false @@ -2047,6 +2272,15 @@ final class DomainViewModel { subdomains = [] subdomainsError = nil subdomainsLoading = false + extendedSubdomains = [] + extendedSubdomainsError = nil + extendedSubdomainsLoading = false + dnsHistory = [] + dnsHistoryError = nil + dnsHistoryLoading = false + domainPricing = nil + domainPricingError = nil + domainPricingLoading = false portScanResults = [] portScanError = nil portScanLoading = false @@ -2072,9 +2306,13 @@ final class DomainViewModel { ipGeolocationLoading = loading emailSecurityLoading = loading ownershipLoading = loading + ownershipHistoryLoading = false ptrLoading = loading redirectChainLoading = loading subdomainsLoading = loading + extendedSubdomainsLoading = false + dnsHistoryLoading = false + domainPricingLoading = false portScanLoading = loading } @@ -2248,12 +2486,20 @@ final class DomainViewModel { emailSecurityError: nil, ownership: nil, ownershipError: nil, + ownershipHistory: [], + ownershipHistoryError: nil, ptrRecord: nil, ptrError: nil, redirectChain: [], redirectChainError: nil, subdomains: [], subdomainsError: nil, + extendedSubdomains: [], + extendedSubdomainsError: nil, + dnsHistory: [], + dnsHistoryError: nil, + domainPricing: nil, + domainPricingError: nil, portScanResults: [], portScanError: nil, changeSummary: trackedDomain.lastChangeSummary, @@ -2435,6 +2681,30 @@ final class DomainViewModel { at: 3 ) } + if let pricing = snapshot.domainPricing { + rows.append( + InfoRowViewData( + label: "External Price", + value: pricing.estimatedPrice ?? "Unavailable", + tone: .secondary + ) + ) + if let premiumIndicator = pricing.premiumIndicator { + rows.append( + InfoRowViewData( + label: "Premium", + value: premiumIndicator ? "Yes" : "No", + tone: premiumIndicator ? .warning : .secondary + ) + ) + } + if let resaleSignal = pricing.resaleSignal { + rows.append(InfoRowViewData(label: "Resale", value: resaleSignal, tone: .secondary)) + } + if let auctionSignal = pricing.auctionSignal { + rows.append(InfoRowViewData(label: "Auction", value: auctionSignal, tone: .secondary)) + } + } if let certificateStatus = certificateBadgeLabel(from: snapshot) { rows.insert( InfoRowViewData( @@ -2460,6 +2730,15 @@ final class DomainViewModel { } } + static func subdomainRows(from subdomains: [DiscoveredSubdomain]) -> [SubdomainRowViewData] { + subdomains.map { subdomain in + SubdomainRowViewData( + hostname: subdomain.hostname, + isInteresting: subdomain.isExtended || isInterestingSubdomain(subdomain.hostname) + ) + } + } + static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] { snapshot.dnsSections.map { section in DNSRecordSectionViewData( @@ -3098,6 +3377,61 @@ final class DomainViewModel { return formatter }() + private func refreshDomainPricing(for domain: String, persistAfterFetch: Bool) async { + domainPricingLoading = true + let outcome = await ExternalDataService.shared.pricing(domain: domain) + + switch outcome.value { + case let .success(pricing): + domainPricing = pricing + domainPricingError = nil + case let .empty(message): + domainPricing = nil + domainPricingError = conciseExternalMessage(message, fallback: "External pricing unavailable") + case let .error(message): + domainPricing = nil + domainPricingError = conciseExternalMessage(message, fallback: "External pricing unavailable") + } + + domainPricingLoading = false + + if persistAfterFetch { + _ = saveHistoryEntry(replaceLatest: true) + } + } + + private func conciseExternalMessage(_ message: String, fallback: String) -> String { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return fallback + } + if trimmed.localizedCaseInsensitiveContains("rate") { + return "Rate limited. Try again later." + } + if trimmed.localizedCaseInsensitiveContains("invalid") { + return "External data was invalid." + } + if trimmed.localizedCaseInsensitiveContains("network") { + return "External data is offline." + } + return trimmed + } + + private static func defaultUsageCredits() -> [UsageCreditFeature: UsageCreditStatus] { + Dictionary(uniqueKeysWithValues: UsageCreditFeature.allCases.map { feature in + (feature, fallbackCreditStatus(for: feature)) + }) + } + + private static func fallbackCreditStatus(for feature: UsageCreditFeature) -> UsageCreditStatus { + UsageCreditStatus( + feature: feature, + remaining: feature.defaultAllowance, + total: feature.defaultAllowance, + resetContext: "Resets with app version \(AppVersion.current)" + ) + } + private static func csvEscaped(_ value: String) -> String { let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") return "\"\(escaped)\"" 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<DomainPricingInsight>) + } + + 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<CachedPayload, Never>] = [:] + 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<ServiceResult<[DomainOwnershipHistoryEvent]>> { + 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<ServiceResult<[DNSHistoryEvent]>> { + 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<ServiceResult<[DiscoveredSubdomain]>> { + 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<ServiceResult<DomainPricingInsight>> { + 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<T>( + key: RequestKey, + rateLimitBucket: RateLimitBucket, + extract: @escaping (CachedPayload) -> T?, + operation: @escaping @Sendable () async -> CachedPayload + ) async -> CachedLookupResult<T> { + 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<CachedPayload, Never> { + 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<DomainPricingInsight> { + 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<Data> { + 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 + }() +} diff --git a/DomainDig/FeatureAccessService.swift b/DomainDig/FeatureAccessService.swift index d23f348..535ba7c 100644 --- a/DomainDig/FeatureAccessService.swift +++ b/DomainDig/FeatureAccessService.swift @@ -29,6 +29,7 @@ enum FeatureCapability: String, CaseIterable, Identifiable { case ownershipHistory case dnsHistory case extendedSubdomains + case domainPricing var id: String { rawValue } @@ -52,6 +53,8 @@ enum FeatureCapability: String, CaseIterable, Identifiable { return "DNS history" case .extendedSubdomains: return "Extended subdomains" + case .domainPricing: + return "Domain pricing" } } } @@ -139,7 +142,7 @@ enum FeatureAccessService { switch capability { case .workflows, .batchOperations, .advancedExports: return "Available in Pro" - case .ownershipHistory, .dnsHistory, .extendedSubdomains: + case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing: return "Available in Data+" case .limitedTracking: return "Tracking is limited on Free" @@ -199,8 +202,15 @@ enum FeatureAccessService { } static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext { - UpgradePromptContext( - title: "Available in Pro", + let title: String + switch capability { + case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing: + title = "Available in Data+" + default: + title = "Available in Pro" + } + return UpgradePromptContext( + title: title, message: upgradeMessage(for: capability), capability: capability ) diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift index e3a77f4..6eb6777 100644 --- a/DomainDig/HistoryView.swift +++ b/DomainDig/HistoryView.swift @@ -198,6 +198,9 @@ struct HistoryDetailView: View { trackedDomain: trackedDomain, workflows: viewModel.workflowsContaining(domain: entry.domain), trackingLimitMessage: nil, + pricingLoading: false, + pricingError: snapshot.domainPricingError, + showsPricingPlaceholder: !DataAccessService.hasAccess(to: .domainPricing), onTrack: { _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status) }, diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 6b7c2c1..530a89e 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -370,6 +370,117 @@ enum DataCapability: String, Codable { case domainPricing } +enum UsageCreditFeature: String, Codable, CaseIterable, Identifiable, Sendable { + case ownershipHistory + case dnsHistory + case extendedSubdomains + + nonisolated var id: String { rawValue } + + nonisolated var title: String { + switch self { + case .ownershipHistory: + return "Ownership history" + case .dnsHistory: + return "DNS history" + case .extendedSubdomains: + return "Extended subdomains" + } + } + + nonisolated var defaultAllowance: Int { + switch self { + case .ownershipHistory, .dnsHistory: + return 8 + case .extendedSubdomains: + return 12 + } + } +} + +struct UsageCreditStatus: Codable, Equatable, Sendable { + let feature: UsageCreditFeature + let remaining: Int + let total: Int + let resetContext: String + + nonisolated var summary: String { + "\(remaining) uses remaining" + } + + nonisolated var isExhausted: Bool { + remaining <= 0 + } +} + +struct DomainOwnershipHistoryEvent: Identifiable, Codable, Equatable, Sendable { + let id: UUID + let date: Date + let summary: String + let registrar: String? + let registrant: String? + let nameservers: [String] + let source: String + let isExternal: Bool + + nonisolated init( + id: UUID = UUID(), + date: Date, + summary: String, + registrar: String? = nil, + registrant: String? = nil, + nameservers: [String] = [], + source: String, + isExternal: Bool + ) { + self.id = id + self.date = date + self.summary = summary + self.registrar = registrar + self.registrant = registrant + self.nameservers = nameservers + self.source = source + self.isExternal = isExternal + } +} + +struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable { + let id: UUID + let date: Date + let summary: String + let aRecords: [String] + let nameservers: [String] + let source: String + let isExternal: Bool + + nonisolated init( + id: UUID = UUID(), + date: Date, + summary: String, + aRecords: [String] = [], + nameservers: [String] = [], + source: String, + isExternal: Bool + ) { + self.id = id + self.date = date + self.summary = summary + self.aRecords = aRecords + self.nameservers = nameservers + self.source = source + self.isExternal = isExternal + } +} + +struct DomainPricingInsight: Codable, Equatable, Sendable { + let estimatedPrice: String? + let premiumIndicator: Bool? + let resaleSignal: String? + let auctionSignal: String? + let source: String + let collectedAt: Date +} + enum HistoryDateFilter: String, CaseIterable, Identifiable { case today case last7Days @@ -691,6 +802,7 @@ struct IPGeolocation: Codable { struct DomainOwnership: Codable, Equatable { let registrar: String? + let registrant: String? let createdDate: Date? let expirationDate: Date? let status: [String] @@ -699,6 +811,7 @@ struct DomainOwnership: Codable, Equatable { init( registrar: String? = nil, + registrant: String? = nil, createdDate: Date? = nil, expirationDate: Date? = nil, status: [String] = [], @@ -706,6 +819,7 @@ struct DomainOwnership: Codable, Equatable { abuseEmail: String? = nil ) { self.registrar = registrar + self.registrant = registrant self.createdDate = createdDate self.expirationDate = expirationDate self.status = status @@ -716,6 +830,7 @@ struct DomainOwnership: Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) registrar = try container.decodeIfPresent(String.self, forKey: .registrar) + registrant = try container.decodeIfPresent(String.self, forKey: .registrant) createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate) expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate) status = try container.decodeIfPresent([String].self, forKey: .status) ?? [] @@ -724,9 +839,17 @@ struct DomainOwnership: Codable, Equatable { } } -struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable { +struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable, Sendable { var id: String { hostname } let hostname: String + let source: String? + let isExtended: Bool + + nonisolated init(hostname: String, source: String? = nil, isExtended: Bool = false) { + self.hostname = hostname + self.source = source + self.isExtended = isExtended + } } // MARK: - Email Security Models @@ -857,9 +980,13 @@ struct HistoryEntry: Identifiable, Codable { var emailSecurity: EmailSecurityResult? var mtaSts: MTASTSResult? var ownership: DomainOwnership? + var ownershipHistory: [DomainOwnershipHistoryEvent] var ptrRecord: String? var redirectChain: [RedirectHop] var subdomains: [DiscoveredSubdomain] + var extendedSubdomains: [DiscoveredSubdomain] + var dnsHistory: [DNSHistoryEvent] + var domainPricing: DomainPricingInsight? var portScanResults: [PortScanResult] var hstsPreloaded: Bool? var availabilityResult: DomainAvailabilityResult? @@ -891,16 +1018,23 @@ struct HistoryEntry: Identifiable, Codable { var ipGeolocationError: String? var emailSecurityError: String? var ownershipError: String? + var ownershipHistoryError: String? var ptrError: String? var redirectChainError: String? var subdomainsError: String? + var extendedSubdomainsError: String? + var dnsHistoryError: String? + var domainPricingError: String? var portScanError: String? init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection], sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, + ownershipHistory: [DomainOwnershipHistoryEvent] = [], ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], + extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [], + domainPricing: DomainPricingInsight? = nil, portScanResults: [PortScanResult] = [], hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0", @@ -915,8 +1049,10 @@ struct HistoryEntry: Identifiable, Codable { tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil, reachabilityError: String? = nil, ipGeolocationError: String? = nil, - emailSecurityError: String? = nil, ownershipError: String? = nil, ptrError: String? = nil, - redirectChainError: String? = nil, subdomainsError: String? = nil, portScanError: String? = nil) { + emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil, + ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil, + extendedSubdomainsError: String? = nil, dnsHistoryError: String? = nil, + domainPricingError: String? = nil, portScanError: String? = nil) { self.domain = domain self.timestamp = timestamp self.trackedDomainID = trackedDomainID @@ -929,9 +1065,13 @@ struct HistoryEntry: Identifiable, Codable { self.emailSecurity = emailSecurity self.mtaSts = mtaSts ?? emailSecurity?.mtaSts self.ownership = ownership + self.ownershipHistory = ownershipHistory self.ptrRecord = ptrRecord self.redirectChain = redirectChain self.subdomains = subdomains + self.extendedSubdomains = extendedSubdomains + self.dnsHistory = dnsHistory + self.domainPricing = domainPricing self.portScanResults = portScanResults self.hstsPreloaded = hstsPreloaded self.availabilityResult = availabilityResult @@ -963,9 +1103,13 @@ struct HistoryEntry: Identifiable, Codable { self.ipGeolocationError = ipGeolocationError self.emailSecurityError = emailSecurityError self.ownershipError = ownershipError + self.ownershipHistoryError = ownershipHistoryError self.ptrError = ptrError self.redirectChainError = redirectChainError self.subdomainsError = subdomainsError + self.extendedSubdomainsError = extendedSubdomainsError + self.dnsHistoryError = dnsHistoryError + self.domainPricingError = domainPricingError self.portScanError = portScanError } @@ -984,9 +1128,13 @@ struct HistoryEntry: Identifiable, Codable { emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity) mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership) + ownershipHistory = try container.decodeIfPresent([DomainOwnershipHistoryEvent].self, forKey: .ownershipHistory) ?? [] ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? [] + extendedSubdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .extendedSubdomains) ?? [] + dnsHistory = try container.decodeIfPresent([DNSHistoryEvent].self, forKey: .dnsHistory) ?? [] + domainPricing = try container.decodeIfPresent(DomainPricingInsight.self, forKey: .domainPricing) portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult) @@ -1018,9 +1166,13 @@ struct HistoryEntry: Identifiable, Codable { ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError) emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError) ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError) + ownershipHistoryError = try container.decodeIfPresent(String.self, forKey: .ownershipHistoryError) ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError) redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError) subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError) + extendedSubdomainsError = try container.decodeIfPresent(String.self, forKey: .extendedSubdomainsError) + dnsHistoryError = try container.decodeIfPresent(String.self, forKey: .dnsHistoryError) + domainPricingError = try container.decodeIfPresent(String.self, forKey: .domainPricingError) portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError) } diff --git a/DomainDig/PaywallView.swift b/DomainDig/PaywallView.swift index a21448f..d0293a8 100644 --- a/DomainDig/PaywallView.swift +++ b/DomainDig/PaywallView.swift @@ -10,7 +10,7 @@ struct PaywallView: View { NavigationStack { List { Section { - Text("Pro unlocks automation, larger runs, and export convenience while keeping the core lookup experience free.") + Text("Pro unlocks workflows, scale, and exports. Data+ adds deeper external intelligence with local-first usage credits and no account requirement.") .font(appDensity.font(.body, design: .default)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -23,6 +23,13 @@ struct PaywallView: View { featureRow("Advanced exports") } + Section("What Data+ Unlocks") { + featureRow("Ownership history") + featureRow("DNS history") + featureRow("Extended subdomains") + featureRow("External pricing signals") + } + Section("Subscription") { if purchaseService.isLoadingProducts { ProgressView("Loading pricing…") @@ -116,6 +123,10 @@ struct PaywallView: View { return "Pro Monthly" case PurchaseService.yearlyProductID: return "Pro Yearly" + case PurchaseService.dataPlusMonthlyProductID: + return "Data+ Monthly" + case PurchaseService.dataPlusYearlyProductID: + return "Data+ Yearly" default: return product.displayName } diff --git a/DomainDig/PurchaseService.swift b/DomainDig/PurchaseService.swift index 3d19165..20ee818 100644 --- a/DomainDig/PurchaseService.swift +++ b/DomainDig/PurchaseService.swift @@ -17,7 +17,14 @@ final class PurchaseService { static let shared = PurchaseService() static let monthlyProductID = "domaindig.pro.monthly" static let yearlyProductID = "domaindig.pro.yearly" - static let productIDs = [monthlyProductID, yearlyProductID] + static let dataPlusMonthlyProductID = "domaindig.dataplus.monthly" + static let dataPlusYearlyProductID = "domaindig.dataplus.yearly" + static let productIDs = [ + monthlyProductID, + yearlyProductID, + dataPlusMonthlyProductID, + dataPlusYearlyProductID + ] private static let entitlementCacheKey = "purchase.cachedEntitlement" @@ -52,7 +59,11 @@ final class PurchaseService { } var hasProAccess: Bool { - currentTier == .pro + currentTier != .free + } + + var hasDataPlusAccess: Bool { + currentTier == .dataPlus } func refreshProducts() async { @@ -91,7 +102,7 @@ final class PurchaseService { .productID self.activeProductID = activeProductID - currentTier = activeProductID == nil ? .free : .pro + currentTier = tier(for: activeProductID) persistCurrentEntitlement() } @@ -109,7 +120,7 @@ final class PurchaseService { apply(transaction: transaction) await transaction.finish() await refreshEntitlements() - statusMessage = "Pro is active." + statusMessage = currentTier == .dataPlus ? "Data+ is active." : "Pro is active." case .userCancelled: break case .pending: @@ -177,7 +188,7 @@ final class PurchaseService { } activeProductID = transaction.productID - currentTier = .pro + currentTier = tier(for: transaction.productID) persistCurrentEntitlement() } @@ -224,11 +235,26 @@ final class PurchaseService { return 0 case Self.yearlyProductID: return 1 + case Self.dataPlusMonthlyProductID: + return 2 + case Self.dataPlusYearlyProductID: + return 3 default: return Int.max } } + private func tier(for productID: String?) -> FeatureTier { + switch productID { + case Self.monthlyProductID, Self.yearlyProductID: + return .pro + case Self.dataPlusMonthlyProductID, Self.dataPlusYearlyProductID: + return .dataPlus + default: + return .free + } + } + private func storeMessage(for error: Error, fallback: String) -> String { if let storeKitError = error as? StoreKitError { switch storeKitError { diff --git a/DomainDig/RDAPService.swift b/DomainDig/RDAPService.swift index 3556624..e7e2c93 100644 --- a/DomainDig/RDAPService.swift +++ b/DomainDig/RDAPService.swift @@ -25,6 +25,7 @@ enum RDAPService { case let .success(response): let ownership = DomainOwnership( registrar: response.registrarName, + registrant: response.registrantName, createdDate: response.createdDate, expirationDate: response.expirationDate, status: response.status, @@ -113,6 +114,10 @@ private struct RDAPDomainResponse: Decodable, Sendable { entities?.first(where: { $0.roles.contains("registrar") })?.bestDisplayName } + var registrantName: String? { + entities?.first(where: { $0.roles.contains("registrant") })?.bestDisplayName + } + var createdDate: Date? { eventDate(for: ["registration", "registered"]) } diff --git a/DomainDig/SubdomainDiscoveryService.swift b/DomainDig/SubdomainDiscoveryService.swift index 4e25cf0..7339648 100644 --- a/DomainDig/SubdomainDiscoveryService.swift +++ b/DomainDig/SubdomainDiscoveryService.swift @@ -59,7 +59,7 @@ enum SubdomainDiscoveryService { guard seen.insert(sanitized).inserted else { continue } - results.append(DiscoveredSubdomain(hostname: sanitized)) + results.append(DiscoveredSubdomain(hostname: sanitized, source: "crt.sh")) if results.count == limit { return results } diff --git a/DomainDig/UsageCreditService.swift b/DomainDig/UsageCreditService.swift new file mode 100644 index 0000000..48f40be --- /dev/null +++ b/DomainDig/UsageCreditService.swift @@ -0,0 +1,73 @@ +import Foundation + +actor UsageCreditService { + static let shared = UsageCreditService() + + private struct CreditLedger: Codable { + let appVersion: String + var remainingByFeature: [UsageCreditFeature: Int] + } + + private let storageKey = "usageCredits.ledger" + private var ledger: CreditLedger + + init(defaults: UserDefaults = .standard) { + if let data = defaults.data(forKey: storageKey), + let decoded = try? JSONDecoder().decode(CreditLedger.self, from: data), + decoded.appVersion == AppVersion.current { + ledger = decoded + } else { + ledger = Self.makeLedger() + if let data = try? JSONEncoder().encode(ledger) { + defaults.set(data, forKey: storageKey) + } + } + } + + func status(for feature: UsageCreditFeature) -> UsageCreditStatus { + let total = feature.defaultAllowance + let remaining = ledger.remainingByFeature[feature] ?? total + return UsageCreditStatus( + feature: feature, + remaining: remaining, + total: total, + resetContext: "Resets with app version \(ledger.appVersion)" + ) + } + + func allStatuses() -> [UsageCreditStatus] { + UsageCreditFeature.allCases.map { status(for: $0) } + } + + func canUse(_ feature: UsageCreditFeature) -> Bool { + status(for: feature).remaining > 0 + } + + @discardableResult + func consume(_ feature: UsageCreditFeature) -> UsageCreditStatus { + let current = ledger.remainingByFeature[feature] ?? feature.defaultAllowance + ledger.remainingByFeature[feature] = max(0, current - 1) + persist() + return status(for: feature) + } + + func resetForCurrentVersion() { + ledger = Self.makeLedger() + persist() + } + + private static func makeLedger() -> CreditLedger { + CreditLedger( + appVersion: AppVersion.current, + remainingByFeature: Dictionary( + uniqueKeysWithValues: UsageCreditFeature.allCases.map { ($0, $0.defaultAllowance) } + ) + ) + } + + private func persist(defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(ledger) { + defaults.set(data, forKey: storageKey) + } + } +} diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift index d0a4d86..a6d2cf3 100644 --- a/DomainDigCLI.swift +++ b/DomainDigCLI.swift @@ -6,12 +6,17 @@ struct DomainDigCLI { let arguments = Array(CommandLine.arguments.dropFirst()) guard let command = CommandLine.arguments.first else { - fputs("usage: domaindig <domain> [--json]\n", stderr) + fputs("usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]\n", stderr) Foundation.exit(1) } _ = command let wantsJSON = arguments.contains("--json") || arguments.contains("-j") + let wantsOwnershipHistory = arguments.contains("--ownership-history") + let wantsDNSHistory = arguments.contains("--dns-history") + let wantsExtendedSubdomains = arguments.contains("--extended-subdomains") + let wantsPricing = arguments.contains("--pricing") + let wantsUsage = arguments.contains("--show-usage") let domains = arguments.filter { !$0.hasPrefix("-") } let requestedDomains = domains @@ -19,18 +24,29 @@ struct DomainDigCLI { .filter { !$0.isEmpty } guard !requestedDomains.isEmpty else { - fputs("usage: domaindig <domain> [--json]\n", stderr) + fputs("usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage]\n", stderr) Foundation.exit(1) } let inspectionService = DomainInspectionService() + let reportBuilder = DomainReportBuilder() var reports: [DomainReport] = [] var seen = Set<String>() + var usageImpact: [String] = [] for domain in requestedDomains { let normalizedDomain = domain.lowercased() guard seen.insert(normalizedDomain).inserted else { continue } - reports.append(await inspectionService.inspect(domain: domain)) + let snapshot = await inspectionService.inspectSnapshot(domain: domain) + let enrichedSnapshot = await enrichSnapshot( + snapshot, + wantsOwnershipHistory: wantsOwnershipHistory, + wantsDNSHistory: wantsDNSHistory, + wantsExtendedSubdomains: wantsExtendedSubdomains, + wantsPricing: wantsPricing, + usageImpact: &usageImpact + ) + reports.append(reportBuilder.build(from: enrichedSnapshot)) } do { @@ -47,6 +63,9 @@ struct DomainDigCLI { title: "DomainDig Batch Report" ) } + if wantsUsage, !usageImpact.isEmpty { + FileHandle.standardError.write(Data(("Data+ usage impact: " + usageImpact.joined(separator: ", ") + "\n").utf8)) + } FileHandle.standardOutput.write(data) if data.last != 0x0A { FileHandle.standardOutput.write(Data([0x0A])) @@ -56,4 +75,187 @@ struct DomainDigCLI { Foundation.exit(1) } } + + private static func enrichSnapshot( + _ snapshot: LookupSnapshot, + wantsOwnershipHistory: Bool, + wantsDNSHistory: Bool, + wantsExtendedSubdomains: Bool, + wantsPricing: Bool, + usageImpact: inout [String] + ) async -> LookupSnapshot { + guard FeatureAccessService.currentTier == .dataPlus else { + return snapshot + } + + let historyEntries = loadHistoryEntries() + var ownershipHistory = snapshot.ownershipHistory + var ownershipHistoryError = snapshot.ownershipHistoryError + var dnsHistory = snapshot.dnsHistory + var dnsHistoryError = snapshot.dnsHistoryError + var extendedSubdomains = snapshot.extendedSubdomains + var extendedSubdomainsError = snapshot.extendedSubdomainsError + var domainPricing = snapshot.domainPricing + var domainPricingError = snapshot.domainPricingError + + if wantsOwnershipHistory, + await UsageCreditService.shared.canUse(.ownershipHistory) { + let outcome = await ExternalDataService.shared.ownershipHistory( + domain: snapshot.domain, + currentOwnership: snapshot.ownership, + historyEntries: historyEntries + ) + switch outcome.value { + case let .success(events): + ownershipHistory = events + ownershipHistoryError = nil + if outcome.source != .cached { + _ = await UsageCreditService.shared.consume(.ownershipHistory) + usageImpact.append("ownership history -1") + } + case let .empty(message): + ownershipHistoryError = message + if outcome.source != .cached { + _ = await UsageCreditService.shared.consume(.ownershipHistory) + usageImpact.append("ownership history -1") + } + case let .error(message): + ownershipHistoryError = message + } + } + + if wantsDNSHistory, + await UsageCreditService.shared.canUse(.dnsHistory) { + let outcome = await ExternalDataService.shared.dnsHistory( + domain: snapshot.domain, + dnsSections: snapshot.dnsSections, + historyEntries: historyEntries + ) + switch outcome.value { + case let .success(events): + dnsHistory = events + dnsHistoryError = nil + if outcome.source != .cached { + _ = await UsageCreditService.shared.consume(.dnsHistory) + usageImpact.append("dns history -1") + } + case let .empty(message): + dnsHistoryError = message + if outcome.source != .cached { + _ = await UsageCreditService.shared.consume(.dnsHistory) + usageImpact.append("dns history -1") + } + case let .error(message): + dnsHistoryError = message + } + } + + if wantsExtendedSubdomains, + await UsageCreditService.shared.canUse(.extendedSubdomains) { + let outcome = await ExternalDataService.shared.extendedSubdomains( + domain: snapshot.domain, + existing: snapshot.subdomains + ) + switch outcome.value { + case let .success(results): + extendedSubdomains = results + extendedSubdomainsError = nil + if outcome.source != .cached { + _ = await UsageCreditService.shared.consume(.extendedSubdomains) + usageImpact.append("extended subdomains -1") + } + case let .empty(message): + extendedSubdomainsError = message + if outcome.source != .cached { + _ = await UsageCreditService.shared.consume(.extendedSubdomains) + usageImpact.append("extended subdomains -1") + } + case let .error(message): + extendedSubdomainsError = message + } + } + + if wantsPricing { + let outcome = await ExternalDataService.shared.pricing(domain: snapshot.domain) + switch outcome.value { + case let .success(pricing): + domainPricing = pricing + domainPricingError = nil + case let .empty(message), let .error(message): + domainPricingError = message + } + } + + return LookupSnapshot( + historyEntryID: snapshot.historyEntryID, + domain: snapshot.domain, + timestamp: snapshot.timestamp, + trackedDomainID: snapshot.trackedDomainID, + note: snapshot.note, + appVersion: snapshot.appVersion, + resolverDisplayName: snapshot.resolverDisplayName, + resolverURLString: snapshot.resolverURLString, + dataSources: snapshot.dataSources, + provenanceBySection: snapshot.provenanceBySection, + availabilityConfidence: snapshot.availabilityConfidence, + ownershipConfidence: snapshot.ownershipConfidence, + subdomainConfidence: snapshot.subdomainConfidence, + emailSecurityConfidence: snapshot.emailSecurityConfidence, + geolocationConfidence: snapshot.geolocationConfidence, + errorDetails: snapshot.errorDetails, + isPartialSnapshot: snapshot.isPartialSnapshot, + validationIssues: snapshot.validationIssues, + totalLookupDurationMs: snapshot.totalLookupDurationMs, + dnsSections: snapshot.dnsSections, + dnsError: snapshot.dnsError, + availabilityResult: snapshot.availabilityResult, + suggestions: snapshot.suggestions, + sslInfo: snapshot.sslInfo, + sslError: snapshot.sslError, + hstsPreloaded: snapshot.hstsPreloaded, + httpHeaders: snapshot.httpHeaders, + httpSecurityGrade: snapshot.httpSecurityGrade, + httpStatusCode: snapshot.httpStatusCode, + httpResponseTimeMs: snapshot.httpResponseTimeMs, + httpProtocol: snapshot.httpProtocol, + http3Advertised: snapshot.http3Advertised, + httpHeadersError: snapshot.httpHeadersError, + reachabilityResults: snapshot.reachabilityResults, + reachabilityError: snapshot.reachabilityError, + ipGeolocation: snapshot.ipGeolocation, + ipGeolocationError: snapshot.ipGeolocationError, + emailSecurity: snapshot.emailSecurity, + emailSecurityError: snapshot.emailSecurityError, + ownership: snapshot.ownership, + ownershipError: snapshot.ownershipError, + ownershipHistory: ownershipHistory, + ownershipHistoryError: ownershipHistoryError, + ptrRecord: snapshot.ptrRecord, + ptrError: snapshot.ptrError, + redirectChain: snapshot.redirectChain, + redirectChainError: snapshot.redirectChainError, + subdomains: snapshot.subdomains, + subdomainsError: snapshot.subdomainsError, + extendedSubdomains: extendedSubdomains, + extendedSubdomainsError: extendedSubdomainsError, + dnsHistory: dnsHistory, + dnsHistoryError: dnsHistoryError, + domainPricing: domainPricing, + domainPricingError: domainPricingError, + portScanResults: snapshot.portScanResults, + portScanError: snapshot.portScanError, + changeSummary: snapshot.changeSummary, + resultSource: snapshot.resultSource, + cachedSections: snapshot.cachedSections, + statusMessage: snapshot.statusMessage + ) + } + + private static func loadHistoryEntries() -> [HistoryEntry] { + guard let data = UserDefaults.standard.data(forKey: "lookupHistory"), + let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else { + return [] + } + return entries + } } diff --git a/DomainInspectionService.swift b/DomainInspectionService.swift index b906b83..a4a1172 100644 --- a/DomainInspectionService.swift +++ b/DomainInspectionService.swift @@ -313,12 +313,20 @@ struct DomainInspectionService { emailSecurityError: emailSecurity.message, ownership: ownership.value, ownershipError: ownership.message, + ownershipHistory: [], + ownershipHistoryError: nil, ptrRecord: ptrRecord.value, ptrError: ptrRecord.message, redirectChain: redirectChain.value, redirectChainError: redirectChain.message, subdomains: subdomains.value, subdomainsError: subdomains.message, + extendedSubdomains: [], + extendedSubdomainsError: nil, + dnsHistory: [], + dnsHistoryError: nil, + domainPricing: nil, + domainPricingError: nil, portScanResults: portScanResults.value, portScanError: portScanResults.message, changeSummary: nil, diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift index 0f42519..3ed88ac 100644 --- a/DomainReportBuilder.swift +++ b/DomainReportBuilder.swift @@ -21,11 +21,15 @@ struct DomainReport: Codable { let emailConfidence: ConfidenceLevel? let geolocationConfidence: ConfidenceLevel? let ownership: DomainOwnership? + let ownershipHistory: [DomainOwnershipHistoryEvent] let dns: DNSResultSummary let web: WebResultSummary let email: EmailSecuritySummary let network: NetworkSummary let subdomains: [String] + let extendedSubdomains: [String] + let dnsHistory: [DNSHistoryEvent] + let domainPricing: DomainPricingInsight? let subdomainGroups: [SubdomainGroup] let riskAssessment: DomainRiskAssessment let insights: [String] @@ -164,6 +168,7 @@ struct DomainReportBuilder { emailConfidence: snapshot.emailSecurityConfidence, geolocationConfidence: snapshot.geolocationConfidence, ownership: snapshot.ownership, + ownershipHistory: snapshot.ownershipHistory, dns: DNSResultSummary( resolverDisplayName: snapshot.resolverDisplayName, resolverURLString: snapshot.resolverURLString, @@ -216,13 +221,16 @@ struct DomainReportBuilder { portScanError: snapshot.portScanError ), subdomains: snapshot.subdomains.map(\.hostname), + extendedSubdomains: snapshot.extendedSubdomains.map(\.hostname), + dnsHistory: snapshot.dnsHistory, + domainPricing: snapshot.domainPricing, subdomainGroups: analysis.subdomainGroups, riskAssessment: analysis.riskAssessment, insights: analysis.insights, changeSummary: changeSummary, workflowContext: workflowContext, metadata: DomainReportMetadata( - schemaVersion: "3.0.0", + schemaVersion: "3.2.0", resolverDisplayName: snapshot.resolverDisplayName, resolverURLString: snapshot.resolverURLString, appVersion: snapshot.appVersion, diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift index b7d4606..4fa7470 100644 --- a/DomainReportExporter.swift +++ b/DomainReportExporter.swift @@ -67,7 +67,9 @@ enum DomainReportExporter { "TLS Status: \(report.web.tlsStatus)", "HTTP: \(httpSummary(for: report))", "Email: \(report.email.summary)", - "Subdomains: \(report.subdomains.count)" + "Subdomains: \(report.subdomains.count)", + "Extended Subdomains: \(report.extendedSubdomains.count)", + "External Price: \(report.domainPricing?.estimatedPrice ?? "Unavailable")" ] } @@ -97,6 +99,7 @@ enum DomainReportExporter { "Confidence: \(report.ownershipConfidence?.title ?? "N/A")", "Created: \(ownershipDateLabel(report.ownership?.createdDate))", "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))", + "Registrant: \(report.ownership?.registrant ?? "Unavailable")", "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")", "Status: \(joined(report.ownership?.status) ?? "Unavailable")", "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")" @@ -112,6 +115,20 @@ enum DomainReportExporter { return ownershipLines } + appendSection("Ownership History", to: &lines) { + guard !report.ownershipHistory.isEmpty else { + return ["No ownership history available"] + } + + return report.ownershipHistory.map { event in + [ + textDateFormatter.string(from: event.date), + event.summary, + "source=\(event.source)" + ].joined(separator: " | ") + } + } + appendSection("DNS", to: &lines) { var dnsLines = [ "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))", @@ -139,6 +156,22 @@ enum DomainReportExporter { return dnsLines } + appendSection("DNS History", to: &lines) { + guard !report.dnsHistory.isEmpty else { + return ["No DNS history available"] + } + + return report.dnsHistory.map { event in + [ + textDateFormatter.string(from: event.date), + event.summary, + "A=\(event.aRecords.joined(separator: " | ").nilIfEmpty ?? "-")", + "NS=\(event.nameservers.joined(separator: " | ").nilIfEmpty ?? "-")", + "source=\(event.source)" + ].joined(separator: " | ") + } + } + appendSection("Web", to: &lines) { var webLines = [ "TLS Status: \(report.web.tlsStatus)", @@ -254,9 +287,28 @@ enum DomainReportExporter { values.append("Groups: \(report.subdomainGroups.map { "\($0.label): \($0.subdomains.count)" }.joined(separator: " | "))") } values.append(contentsOf: report.subdomains.map { "- \($0)" }) + if !report.extendedSubdomains.isEmpty { + values.append("Extended:") + values.append(contentsOf: report.extendedSubdomains.map { "- \($0)" }) + } return values } + appendSection("Pricing", to: &lines) { + guard let pricing = report.domainPricing else { + return ["External pricing unavailable"] + } + + return [ + "Estimated Price: \(pricing.estimatedPrice ?? "Unavailable")", + "Premium: \(pricing.premiumIndicator == true ? "Yes" : "No")", + "Resale: \(pricing.resaleSignal ?? "Unavailable")", + "Auction: \(pricing.auctionSignal ?? "Unavailable")", + "Source: \(pricing.source)", + "Collected: \(textDateFormatter.string(from: pricing.collectedAt))" + ] + } + appendSection("Changes", to: &lines) { guard let changeSummary = report.changeSummary else { return ["No comparison available"] @@ -342,9 +394,18 @@ enum DomainReportExporter { "email_grade", "email_confidence", "subdomain_count", + "extended_subdomain_count", "subdomain_groups", "subdomain_confidence", "subdomains", + "extended_subdomains", + "ownership_history", + "dns_history", + "pricing_estimated", + "pricing_premium", + "pricing_resale_signal", + "pricing_auction_signal", + "pricing_source", "open_ports", "reachability_summary", "geolocation_summary", @@ -368,12 +429,15 @@ enum DomainReportExporter { let httpStatus = report.web.statusCode.map(String.init) ?? "" let subdomainCount = String(report.subdomains.count) let subdomains = report.subdomains.joined(separator: " | ") + let extendedSubdomains = report.extendedSubdomains.joined(separator: " | ") let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ") let riskFactors = report.riskAssessment.factors.map(\.description).joined(separator: " | ") let insights = report.insights.joined(separator: " | ") let dnsPatterns = report.dns.patternSummary.patterns.joined(separator: " | ") let tlsHighlights = report.web.tlsHighlights.joined(separator: " | ") let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ") + let ownershipHistory = report.ownershipHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ") + let dnsHistory = report.dnsHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ") return [ report.domain, @@ -407,9 +471,18 @@ enum DomainReportExporter { report.email.grade?.rawValue ?? "", report.emailConfidence?.rawValue ?? "", subdomainCount, + String(report.extendedSubdomains.count), subdomainGroups, report.subdomainConfidence?.rawValue ?? "", subdomains, + extendedSubdomains, + ownershipHistory, + dnsHistory, + report.domainPricing?.estimatedPrice ?? "", + report.domainPricing?.premiumIndicator == true ? "true" : "false", + report.domainPricing?.resaleSignal ?? "", + report.domainPricing?.auctionSignal ?? "", + report.domainPricing?.source ?? "", openPorts, report.network.reachabilitySummary, report.network.geolocationSummary, diff --git a/LookupSnapshot.swift b/LookupSnapshot.swift index f51f546..a817805 100644 --- a/LookupSnapshot.swift +++ b/LookupSnapshot.swift @@ -42,12 +42,20 @@ struct LookupSnapshot { let emailSecurityError: String? let ownership: DomainOwnership? let ownershipError: String? + let ownershipHistory: [DomainOwnershipHistoryEvent] + let ownershipHistoryError: String? let ptrRecord: String? let ptrError: String? let redirectChain: [RedirectHop] let redirectChainError: String? let subdomains: [DiscoveredSubdomain] let subdomainsError: String? + let extendedSubdomains: [DiscoveredSubdomain] + let extendedSubdomainsError: String? + let dnsHistory: [DNSHistoryEvent] + let dnsHistoryError: String? + let domainPricing: DomainPricingInsight? + let domainPricingError: String? let portScanResults: [PortScanResult] let portScanError: String? let changeSummary: DomainChangeSummary? @@ -104,12 +112,20 @@ extension HistoryEntry { emailSecurityError: emailSecurityError, ownership: ownership, ownershipError: ownershipError, + ownershipHistory: ownershipHistory, + ownershipHistoryError: ownershipHistoryError, ptrRecord: ptrRecord, ptrError: ptrError, redirectChain: redirectChain, redirectChainError: redirectChainError, subdomains: subdomains, subdomainsError: subdomainsError, + extendedSubdomains: extendedSubdomains, + extendedSubdomainsError: extendedSubdomainsError, + dnsHistory: dnsHistory, + dnsHistoryError: dnsHistoryError, + domainPricing: domainPricing, + domainPricingError: domainPricingError, portScanResults: portScanResults, portScanError: portScanError, changeSummary: changeSummary, |
