package whois import ( "encoding/json" "fmt" "strings" ) type rdapDomainDoc struct { ObjectClassName string `json:"objectClassName"` LDHName string `json:"ldhName"` UnicodeName string `json:"unicodeName"` Handle string `json:"handle"` Status []string `json:"status"` Nameservers []rdapNameServer `json:"nameservers"` Events []rdapEvent `json:"events"` Entities []rdapEntityDoc `json:"entities"` } type rdapNameServer struct { LDHName string `json:"ldhName"` UnicodeName string `json:"unicodeName"` } type rdapEvent struct { EventAction string `json:"eventAction"` EventDate string `json:"eventDate"` } type rdapEntityDoc struct { Handle string `json:"handle"` Roles []string `json:"roles"` PublicIDs []rdapPublicID `json:"publicIds"` VCard any `json:"vcardArray"` } type rdapPublicID struct { Type string `json:"type"` Identifier string `json:"identifier"` } func rdapResponseToResult(query string, resp *RDAPResponse, targetKind lookupTargetKind) (Result, error) { if resp == nil { return Result{}, fmt.Errorf("whois/rdap: nil response") } var doc rdapDomainDoc if err := json.Unmarshal(resp.Body, &doc); err != nil { return Result{}, fmt.Errorf("whois/rdap: decode domain response failed: %w", err) } normalizedQuery := strings.TrimSpace(query) if targetKind == lookupTargetDomain { normalizedQuery = strings.ToLower(strings.Trim(normalizedQuery, ".")) } result := Result{ exists: false, domain: normalizedQuery, rawData: string(resp.Body), whoisSer: resp.Endpoint, } switch targetKind { case lookupTargetDomain: if n := strings.TrimSpace(doc.LDHName); n != "" { result.domain = strings.ToLower(n) result.exists = true } else if n := strings.TrimSpace(doc.UnicodeName); n != "" { result.domain = n result.exists = true } if strings.EqualFold(strings.TrimSpace(doc.ObjectClassName), "domain") { result.exists = true } case lookupTargetIP, lookupTargetASN: if strings.TrimSpace(doc.ObjectClassName) != "" || strings.TrimSpace(doc.Handle) != "" { result.exists = true } if result.domain == "" { result.domain = normalizedQuery } } if h := strings.TrimSpace(doc.Handle); h != "" { result.domainID = h } result.statusRaw = appendUniqueStrings(result.statusRaw, normalizeStringSlice(doc.Status)...) for _, ns := range doc.Nameservers { v := strings.TrimSpace(ns.LDHName) if v == "" { v = strings.TrimSpace(ns.UnicodeName) } if v != "" { result.nsServers = appendUniqueStrings(result.nsServers, v) } } for _, ev := range doc.Events { action := strings.ToLower(strings.TrimSpace(ev.EventAction)) if action == "" { continue } t := parseDateAuto(strings.TrimSpace(ev.EventDate)) if t.IsZero() { continue } switch { case strings.Contains(action, "registration"), strings.Contains(action, "registered"), strings.Contains(action, "creation"): if !result.hasRegisterDate { result.hasRegisterDate = true result.registerDate = t } case strings.Contains(action, "expiration"), strings.Contains(action, "expiry"), strings.Contains(action, "expire"): if !result.hasExpireDate { result.hasExpireDate = true result.expireDate = t } case strings.Contains(action, "changed"), strings.Contains(action, "update"): if !result.hasUpdateDate { result.hasUpdateDate = true result.updateDate = t } } } for _, ent := range doc.Entities { info := parseRDAPEntityPersonalInfo(ent.VCard) isRegistrar, isRegistrant, isAdmin, isTech := classifyRDAPEntityRoles(ent.Roles) if isRegistrar { if result.registrar == "" { result.registrar = firstNonEmpty(info.Org, info.Name, ent.Handle) } if result.ianaID == "" { for _, pid := range ent.PublicIDs { typed := strings.ToLower(strings.TrimSpace(pid.Type)) if strings.Contains(typed, "iana") { result.ianaID = strings.TrimSpace(pid.Identifier) break } } } } if isRegistrant { result.registerInfo = mergePersonalInfo(result.registerInfo, info) } if isAdmin { result.adminInfo = mergePersonalInfo(result.adminInfo, info) } if isTech { result.techInfo = mergePersonalInfo(result.techInfo, info) } } result.meta = buildResultMeta(result, "rdap", resp.Endpoint) result.meta.Charset = "utf-8" return result, nil } func parseRDAPEntityPersonalInfo(vcard any) PersonalInfo { info := PersonalInfo{} root, ok := vcard.([]any) if !ok || len(root) < 2 { return info } props, ok := root[1].([]any) if !ok { return info } for _, p := range props { kv, ok := p.([]any) if !ok || len(kv) < 4 { continue } key := strings.ToLower(strings.TrimSpace(fmt.Sprint(kv[0]))) val := kv[3] switch key { case "fn": info.Name = firstNonEmpty(info.Name, rdapValueString(val)) case "org": info.Org = firstNonEmpty(info.Org, rdapValueString(val)) case "email": info.Email = firstNonEmpty(info.Email, rdapValueString(val)) case "tel": info.Phone = firstNonEmpty(info.Phone, rdapValueString(val)) case "adr": addr, city, state, zip, country := rdapAddressFields(val) info.Addr = firstNonEmpty(info.Addr, addr) info.City = firstNonEmpty(info.City, city) info.State = firstNonEmpty(info.State, state) info.Zip = firstNonEmpty(info.Zip, zip) info.Country = firstNonEmpty(info.Country, country) } } return info } func rdapAddressFields(v any) (addr, city, state, zip, country string) { arr, ok := v.([]any) if !ok { return rdapValueString(v), "", "", "", "" } get := func(i int) string { if i < 0 || i >= len(arr) { return "" } return strings.TrimSpace(rdapValueString(arr[i])) } addr = strings.TrimSpace(strings.Join(compactStrings([]string{get(0), get(1), get(2)}), " ")) city = get(3) state = get(4) zip = get(5) country = get(6) return } func rdapValueString(v any) string { switch x := v.(type) { case string: return strings.TrimSpace(x) case []any: parts := make([]string, 0, len(x)) for _, item := range x { s := strings.TrimSpace(rdapValueString(item)) if s != "" { parts = append(parts, s) } } return strings.Join(parts, " ") default: return strings.TrimSpace(fmt.Sprint(v)) } } func classifyRDAPEntityRoles(roles []string) (isRegistrar, isRegistrant, isAdmin, isTech bool) { for _, role := range roles { r := strings.ToLower(strings.TrimSpace(role)) switch { case strings.Contains(r, "registrar"): isRegistrar = true case strings.Contains(r, "registrant"), strings.Contains(r, "holder"): isRegistrant = true case strings.Contains(r, "administrative"), strings.Contains(r, "admin"): isAdmin = true case strings.Contains(r, "technical"), strings.Contains(r, "tech"): isTech = true } } return } func normalizeStringSlice(in []string) []string { out := make([]string, 0, len(in)) seen := make(map[string]struct{}, len(in)) for _, v := range in { v = strings.TrimSpace(v) if v == "" { continue } if _, ok := seen[v]; ok { continue } seen[v] = struct{}{} out = append(out, v) } return out } func compactStrings(in []string) []string { out := make([]string, 0, len(in)) for _, s := range in { s = strings.TrimSpace(s) if s != "" { out = append(out, s) } } return out } func firstNonEmpty(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } return "" }