summaryrefslogtreecommitdiff
path: root/DomainDig/DomainAvailabilityService.swift
blob: 7bef5866de266c42309b22145198130544fa6019 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
import Foundation

struct DomainAvailabilityService {
    private static let suggestionTLDs = ["net", "io", "dev", "app", "co", "org"]

    static func check(domain: String) async -> DomainAvailabilityResult {
        let normalizedDomain = normalize(domain)
        guard !normalizedDomain.isEmpty else {
            return DomainAvailabilityResult(domain: domain, status: .unknown)
        }

        if await checkViaRDAP(domain: normalizedDomain) == .registered {
            debugLog("rdap", domain: normalizedDomain, status: .registered)
            return DomainAvailabilityResult(domain: normalizedDomain, status: .registered)
        }

        let fallbackStatus = await checkViaDNSFallback(domain: normalizedDomain)
        let method = fallbackStatus == .registered ? "dns" : "fallback"
        debugLog(method, domain: normalizedDomain, status: fallbackStatus)
        return DomainAvailabilityResult(domain: normalizedDomain, status: fallbackStatus)
    }

    static func suggestions(for domain: String, limit: Int = 6) async -> [DomainSuggestionResult] {
        let normalizedDomain = normalize(domain)
        let candidates = suggestionCandidates(for: normalizedDomain, limit: limit)
        guard !candidates.isEmpty else { return [] }

        var results: [DomainSuggestionResult] = []
        for candidate in candidates {
            if Task.isCancelled { break }
            let result = await check(domain: candidate)
            results.append(DomainSuggestionResult(domain: result.domain, status: result.status))
        }
        return results
    }

    private static func checkViaRDAP(domain: String) async -> DomainAvailabilityStatus? {
        guard let url = URL(string: "https://rdap.org/domain/\(domain)") else {
            return nil
        }

        do {
            var request = URLRequest(url: url, timeoutInterval: 8)
            request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept")

            let (data, response) = try await URLSession.shared.data(for: request)
            guard let httpResponse = response as? HTTPURLResponse else {
                return nil
            }

            switch httpResponse.statusCode {
            case 200:
                return isValidRDAPDomainResponse(data) ? .registered : nil
            case 404:
                debugLog("rdap-not-found", domain: domain, details: "Ignoring not-found response from rdap.org")
                return nil
            default:
                return nil
            }
        } catch {
            debugLog("rdap-error", domain: domain, details: error.localizedDescription)
            return nil
        }
    }

    private static func checkViaDNSFallback(domain: String) async -> DomainAvailabilityStatus {
        do {
            let aRecords = try await DNSLookupService.lookup(domain: domain, recordType: .A)
            if !aRecords.isEmpty {
                return .registered
            }
        } catch {
            debugLog("dns-a-error", domain: domain, details: error.localizedDescription)
        }

        do {
            let nsRecords = try await DNSLookupService.lookup(domain: domain, recordType: .NS)
            if !nsRecords.isEmpty {
                return .registered
            }
            return .unknown
        } catch {
            debugLog("dns-ns-error", domain: domain, details: error.localizedDescription)
            return .unknown
        }
    }

    private static func isValidRDAPDomainResponse(_ data: Data) -> Bool {
        guard
            let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
        else {
            return false
        }

        if object["ldhName"] as? String != nil {
            return true
        }

        if object["objectClassName"] as? String == "domain" {
            return true
        }

        if object["handle"] as? String != nil, object["unicodeName"] as? String != nil {
            return true
        }

        return false
    }

    private static func suggestionCandidates(for domain: String, limit: Int) -> [String] {
        let parts = domain.split(separator: ".")
        guard parts.count >= 2 else { return [] }

        let base = parts.dropLast().joined(separator: ".")
        let tld = String(parts.last ?? "")

        var candidates: [String] = []
        for suggestionTLD in suggestionTLDs where suggestionTLD != tld {
            candidates.append("\(base).\(suggestionTLD)")
            if candidates.count == limit {
                return candidates
            }
        }

        if !base.contains("-"), base.count >= 6, candidates.count < limit {
            let midpoint = base.index(base.startIndex, offsetBy: base.count / 2)
            let hyphenated = "\(base[..<midpoint])-\(base[midpoint...]).\(tld)"
            if hyphenated != domain {
                candidates.append(hyphenated)
            }
        }

        return Array(candidates.prefix(limit))
    }

    private static func normalize(_ domain: String) -> String {
        domain
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .lowercased()
    }

    private static func debugLog(_ method: String, domain: String, status: DomainAvailabilityStatus) {
        #if DEBUG
        print("[Availability] \(domain) -> \(status.rawValue) via \(method)")
        #endif
    }

    private static func debugLog(_ method: String, domain: String, details: String) {
        #if DEBUG
        print("[Availability] \(domain) -> \(method): \(details)")
        #endif
    }
}