package geo import ( "context" "encoding/json" "errors" "fmt" "io" "math" "net/http" "net/netip" "strconv" "strings" "time" ) const ( // GeoJSEndpoint is the URL of the geojs geo endpoint. GeoJSEndpoint = "https://get.geojs.io/v1/ip/geo.json" // geojsTimeLimit limits one request to geojs. geojsTimeLimit = time.Second // geojsAddressSeparator separates the addresses of one request. geojsAddressSeparator = "," // geojsUnknown is what geojs sends for a value it does not know. geojsUnknown = "nil" // maxGeoJSAnswerBytes is far more than an answer for the most addresses // an IP lookup can ask about. maxGeoJSAnswerBytes = 1 << 20 ) var errGeoJSStatus = errors.New("unexpected HTTP status from geojs") // geojsAnswer is one address's entry in an answer from geojs. // //nolint:tagliatelle // the keys geojs sends type geojsAnswer struct { IP string `json:"ip"` CountryCode string `json:"country_code"` Country string `json:"country"` ContinentCode string `json:"continent_code"` Region string `json:"region"` City string `json:"city"` Latitude string `json:"latitude"` Longitude string `json:"longitude"` Accuracy *int `json:"accuracy"` TimeZone string `json:"timezone"` } // askGeoJS asks geojs about addresses in one request, and returns its answers // by address. An address geojs left out has no entry. func (l *Locator) askGeoJS( ctx context.Context, addresses []string, ) (map[string]*Location, error) { ctx, cancel := context.WithTimeout(ctx, l.timeLimit) defer cancel() // A comma and a colon are legal in a query, and the escaped forms are // not what geojs documents. url := l.geojsURL + "?ip=" + strings.Join(addresses, geojsAddressSeparator) request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } request.Header.Set(userAgentHeader, userAgent) response, err := l.client.Do(request) if err != nil { return nil, err } defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { return nil, fmt.Errorf("%w: %d", errGeoJSStatus, response.StatusCode) } var parsed []geojsAnswer err = json.NewDecoder(io.LimitReader(response.Body, maxGeoJSAnswerBytes)). Decode(&parsed) if err != nil { return nil, fmt.Errorf("reading the geojs answer: %w", err) } fetched := l.now().UTC().Truncate(time.Second) answers := make(map[string]*Location, len(parsed)) for _, answer := range parsed { addr, err := netip.ParseAddr(answer.IP) if err != nil { continue } answers[addr.Unmap().String()] = answer.location(fetched) } return answers, nil } // location returns the answer as a Location fetched at the time given. func (a geojsAnswer) location(fetched time.Time) *Location { return &Location{ CountryCode: knownText(a.CountryCode), Country: knownText(a.Country), ContinentCode: knownText(a.ContinentCode), Region: knownText(a.Region), City: knownText(a.City), Latitude: knownNumber(a.Latitude), Longitude: knownNumber(a.Longitude), AccuracyRadiusKm: a.Accuracy, TimeZone: knownText(a.TimeZone), Source: sourceGeoJS, Updated: fetched, } } // knownText returns a pointer to text, or nil when geojs does not know it. func knownText(text string) *string { if text == "" || text == geojsUnknown { return nil } return &text } // knownNumber returns text as a number, or nil when geojs does not know it. NaN // and infinity parse, but JSON cannot carry them. func knownNumber(text string) *float64 { number, err := strconv.ParseFloat(text, 64) if err != nil || math.IsNaN(number) || math.IsInf(number, 0) { return nil } return &number }