trackdown 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,24 @@
2
2
 
3
3
  require 'countries'
4
4
 
5
+ require_relative '../location_result'
6
+
5
7
  module Trackdown
6
8
  module Providers
7
9
  class BaseProvider
10
+ # What a CDN may legitimately send as a coordinate: a plain decimal number,
11
+ # and nothing else. Ruling the rest out here rather than after converting
12
+ # rejects hexadecimal ("0x10" is a perfectly good latitude of 16.0 to
13
+ # Kernel#Float), underscored digits, and the "NaN"/"Infinity" literals.
14
+ #
15
+ # The digit and exponent limits are not arbitrary: no WGS-84 coordinate
16
+ # exceeds 180, so three integer digits is already generous, and staying this
17
+ # side of Float's range means converting a forged header can never overflow.
18
+ # Ruby warns about an overflowing conversion in verbose mode, and untrusted
19
+ # input must not be able to make a library talk into someone's logs.
20
+ # https://www.rfc-editor.org/rfc/rfc5870#section-3.4.2
21
+ DECIMAL_COORDINATE_PATTERN = /\A[+-]?\d{1,3}(?:\.\d+)?(?:[eE][+-]?\d{1,2})?\z/
22
+ private_constant :DECIMAL_COORDINATE_PATTERN
8
23
  # Returns true if this provider can handle the given request/context
9
24
  def self.available?(request: nil)
10
25
  raise NotImplementedError, "#{self} must implement .available?"
@@ -18,21 +33,72 @@ module Trackdown
18
33
  raise NotImplementedError, "#{self} must implement .locate"
19
34
  end
20
35
 
21
- protected
36
+ # How a result names this provider: the same symbol you'd set as
37
+ # `config.provider`, so `result.provider_name == :cloudflare` lines up with
38
+ # `config.provider = :cloudflare`.
39
+ def self.provider_name
40
+ raise NotImplementedError, "#{self} must implement .provider_name"
41
+ end
42
+
43
+ # Where this provider's answers physically come from, e.g.
44
+ # :cloudflare_request_headers or :maxmind_local_database.
45
+ def self.provider_source
46
+ raise NotImplementedError, "#{self} must implement .provider_source"
47
+ end
48
+
49
+ # The provenance a request-backed provider stamps on every result.
50
+ #
51
+ # The trust state is :unverified unless the host's own verifier vouches for
52
+ # the request. Trackdown never reads trust out of the headers themselves —
53
+ # anyone who can reach an unprotected origin can send those.
54
+ def self.request_provenance(request)
55
+ source_was_verified = Trackdown.configuration.request_came_through_trusted_cdn_path?(
56
+ request,
57
+ provider_name: provider_name
58
+ )
59
+
60
+ {
61
+ provider_name: provider_name,
62
+ provider_source: provider_source,
63
+ source_trust: source_was_verified ? :host_verified : :unverified
64
+ }
65
+ end
22
66
 
23
67
  # Helper to get emoji flag from country code
24
68
  def self.get_emoji_flag(country_code)
25
- country_code ? country_code.tr('A-Z', "\u{1F1E6}-\u{1F1FF}") : "🏳️"
69
+ return LocationResult::UNKNOWN_FLAG unless country_code.is_a?(String)
70
+ return LocationResult::UNKNOWN_FLAG unless /\A[A-Za-z]{2}\z/.match?(country_code)
71
+
72
+ normalized_code = country_code.upcase
73
+ normalized_code.tr('A-Z', "\u{1F1E6}-\u{1F1FF}")
26
74
  end
27
75
 
28
76
  # Helper to extract country name from country code using countries gem
29
77
  def self.get_country_name(country_code)
30
- return 'Unknown' unless country_code
78
+ return LocationResult::UNKNOWN unless country_code
31
79
 
32
80
  country = ISO3166::Country.new(country_code)
33
- country&.iso_short_name || country&.name || 'Unknown'
81
+ country&.iso_short_name || country&.name || LocationResult::UNKNOWN
34
82
  rescue StandardError
35
- 'Unknown'
83
+ LocationResult::UNKNOWN
84
+ end
85
+
86
+ # Parse an untrusted coordinate: a plain decimal within the given WGS-84
87
+ # bounds, or nothing. The range check also settles NaN and Infinity, since
88
+ # a Range covers neither.
89
+ # https://www.rfc-editor.org/rfc/rfc5870#section-3.4.2
90
+ def self.parse_coordinate(value, range:)
91
+ return nil unless value.is_a?(String)
92
+
93
+ decimal = value.strip
94
+ return nil unless DECIMAL_COORDINATE_PATTERN.match?(decimal)
95
+
96
+ coordinate = decimal.to_f
97
+ range.cover?(coordinate) ? coordinate : nil
98
+ end
99
+
100
+ class << self
101
+ protected :get_emoji_flag, :get_country_name, :parse_coordinate
36
102
  end
37
103
  end
38
104
  end
@@ -10,6 +10,10 @@ module Trackdown
10
10
  #
11
11
  # Cloudflare must have "IP Geolocation" or "Add visitor location headers" enabled
12
12
  # in the dashboard under Network settings or via Managed Transforms
13
+ # Exact header contract:
14
+ # https://developers.cloudflare.com/fundamentals/reference/http-headers/
15
+ # Exact origin-protection guidance:
16
+ # https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/
13
17
  class CloudflareProvider < BaseProvider
14
18
  COUNTRY_HEADER = 'HTTP_CF_IPCOUNTRY'
15
19
  CITY_HEADER = 'HTTP_CF_IPCITY'
@@ -22,30 +26,54 @@ module Trackdown
22
26
  METRO_CODE_HEADER = 'HTTP_CF_METRO_CODE'
23
27
  POSTAL_CODE_HEADER = 'HTTP_CF_POSTAL_CODE'
24
28
 
25
- # Special Cloudflare country codes
29
+ # Cloudflare's XX and T1 pseudo-codes do not name countries. Unicode also
30
+ # defines ZZ as unknown/invalid territory, so none is treated as a country.
31
+ # Cloudflare: https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-ipcountry
32
+ # Unicode ZZ semantics:
33
+ # https://www.unicode.org/reports/tr35/tr35-78/tr35.html#unicode_region_subtag_validity
26
34
  UNKNOWN_CODE = 'XX'
35
+ UNKNOWN_OR_INVALID_TERRITORY_CODE = 'ZZ'
27
36
  TOR_CODE = 'T1'
37
+ UNAVAILABLE_COUNTRY_CODES = [UNKNOWN_CODE, UNKNOWN_OR_INVALID_TERRITORY_CODE].freeze
38
+ COUNTRY_CODE_PATTERN = /\A[A-Za-z]{2}\z/
39
+ LATITUDE_RANGE = (-90.0..90.0)
40
+ LONGITUDE_RANGE = (-180.0..180.0)
41
+
42
+ private_constant :UNAVAILABLE_COUNTRY_CODES,
43
+ :COUNTRY_CODE_PATTERN,
44
+ :LATITUDE_RANGE,
45
+ :LONGITUDE_RANGE
28
46
 
29
47
  class << self
48
+ def provider_name
49
+ :cloudflare
50
+ end
51
+
52
+ def provider_source
53
+ :cloudflare_request_headers
54
+ end
55
+
30
56
  # Check if Cloudflare headers are available in the request
31
57
  def available?(request: nil)
32
58
  return false unless request
33
59
 
34
- country_code = request.env[COUNTRY_HEADER]
35
- !country_code.nil? && !country_code.empty? && country_code != UNKNOWN_CODE
60
+ !extract_country_code(request).nil?
36
61
  end
37
62
 
38
63
  # Locate IP using Cloudflare headers
39
64
  # @param ip [String] The IP address (not used, as Cloudflare already resolved it)
40
65
  # @param request [ActionDispatch::Request] Rails request object with Cloudflare headers
41
66
  # @return [LocationResult] The location information
42
- def locate(ip, request: nil)
67
+ def locate(_ip, request: nil)
43
68
  raise Trackdown::Error, "CloudflareProvider requires a request object with Cloudflare headers" unless request
44
69
 
70
+ provenance = request_provenance(request)
45
71
  country_code = extract_country_code(request)
46
72
 
47
73
  # If no valid country code, return unknown
48
- return LocationResult.new(nil, 'Unknown', 'Unknown', '🏳️') if country_code.nil? || country_code == UNKNOWN_CODE
74
+ if country_code.nil? || country_code == UNKNOWN_CODE
75
+ return LocationResult.unavailable(:provider_returned_unknown_country, **provenance)
76
+ end
49
77
 
50
78
  country_name = get_country_name(country_code)
51
79
  city = extract_city(request)
@@ -57,10 +85,18 @@ module Trackdown
57
85
  region_code: extract_header(request, REGION_CODE_HEADER),
58
86
  continent: extract_header(request, CONTINENT_HEADER),
59
87
  timezone: extract_header(request, TIMEZONE_HEADER),
60
- latitude: parse_coordinate(request.env[LATITUDE_HEADER]),
61
- longitude: parse_coordinate(request.env[LONGITUDE_HEADER]),
88
+ latitude: parse_coordinate(request.env[LATITUDE_HEADER], range: LATITUDE_RANGE),
89
+ longitude: parse_coordinate(request.env[LONGITUDE_HEADER], range: LONGITUDE_RANGE),
62
90
  postal_code: extract_header(request, POSTAL_CODE_HEADER),
63
- metro_code: extract_header(request, METRO_CODE_HEADER)
91
+ metro_code: extract_header(request, METRO_CODE_HEADER),
92
+ # "T1" says the visitor came through Tor, which is precisely a country
93
+ # Cloudflare could not determine. The code is kept, the claim is not.
94
+ # Only Cloudflare's own two pseudo-codes are treated this way: a code
95
+ # we simply haven't heard of (Kosovo's user-assigned "XK", say) is a
96
+ # real answer, not an unresolved one.
97
+ # https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-ipcountry
98
+ unavailable_reason: (:provider_returned_unknown_country if country_code == TOR_CODE),
99
+ **provenance
64
100
  )
65
101
  end
66
102
 
@@ -68,9 +104,16 @@ module Trackdown
68
104
 
69
105
  def extract_country_code(request)
70
106
  code = request.env[COUNTRY_HEADER]
71
- return nil if code.nil? || code.empty? || code == UNKNOWN_CODE
107
+ return nil unless code.is_a?(String)
108
+
109
+ normalized_code = code.upcase
110
+ return nil if UNAVAILABLE_COUNTRY_CODES.include?(normalized_code)
111
+ return normalized_code if normalized_code == TOR_CODE
112
+ return nil unless COUNTRY_CODE_PATTERN.match?(code)
72
113
 
73
- code.upcase
114
+ normalized_code
115
+ rescue StandardError
116
+ nil
74
117
  end
75
118
 
76
119
  def extract_city(request)
@@ -78,25 +121,19 @@ module Trackdown
78
121
 
79
122
  # Cloudflare city header might not always be present
80
123
  # It requires "Add visitor location headers" Managed Transform
81
- return 'Unknown' if city.nil? || city.empty?
124
+ return 'Unknown' unless city.is_a?(String)
125
+ return 'Unknown' if city.empty?
82
126
 
83
127
  city
84
128
  end
85
129
 
86
130
  def extract_header(request, header)
87
131
  value = request.env[header]
88
- return nil if value.nil? || value.empty?
132
+ return nil unless value.is_a?(String)
133
+ return nil if value.empty?
89
134
 
90
135
  value
91
136
  end
92
-
93
- def parse_coordinate(value)
94
- return nil if value.nil? || value.empty?
95
-
96
- Float(value)
97
- rescue ArgumentError, TypeError
98
- nil
99
- end
100
137
  end
101
138
  end
102
139
  end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_provider'
4
+ require_relative '../location_result'
5
+
6
+ module Trackdown
7
+ module Providers
8
+ # Provider that uses Amazon CloudFront HTTP headers for IP geolocation.
9
+ # This is the fastest and most lightweight option when your app is behind
10
+ # CloudFront (the AWS CDN) — a direct analog to the Cloudflare provider.
11
+ #
12
+ # CloudFront resolves the viewer's location at the edge and forwards it to the
13
+ # origin as CloudFront-Viewer-* headers. To receive them, attach an origin
14
+ # request policy that forwards the CloudFront geolocation headers.
15
+ #
16
+ # Exact AWS viewer-location header contract (names, availability, encoding):
17
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location
18
+ # Exact AWS managed-policy contents:
19
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#managed-origin-request-policy-all-viewer-and-cloudfront
20
+ #
21
+ # IMPORTANT: Header presence does not authenticate CloudFront. A custom origin
22
+ # must reject direct traffic before an application can trust these values. AWS's
23
+ # exact origin-restriction guidance is:
24
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-overview.html
25
+ #
26
+ # Note: CloudFront does not emit a continent header. We derive #continent from the
27
+ # country code so it matches the 2-letter code (e.g. "NA") the other providers
28
+ # return. The countries gem exposes a continent *name* ("North America") but no
29
+ # code, and its names don't map by initials (Africa/Asia/Antarctica/Australia all
30
+ # start with "A", and it labels Oceania "Australia"), so an explicit table is the
31
+ # only reliable mapping.
32
+ class CloudfrontProvider < BaseProvider
33
+ # ISO3166 continent name (from the countries gem) => 2-letter code used by the
34
+ # Cloudflare and MaxMind providers.
35
+ # countries gem source: https://github.com/countries/countries
36
+ CONTINENT_CODES = {
37
+ 'Africa' => 'AF',
38
+ 'Antarctica' => 'AN',
39
+ 'Asia' => 'AS',
40
+ 'Europe' => 'EU',
41
+ 'North America' => 'NA',
42
+ 'South America' => 'SA',
43
+ 'Australia' => 'OC' # the countries gem labels Oceania "Australia"
44
+ }.freeze
45
+
46
+ # Rack exposes ordinary HTTP request headers as HTTP_* environment entries:
47
+ # https://github.com/rack/rack/blob/main/SPEC.rdoc#http_-headers
48
+ COUNTRY_HEADER = 'HTTP_CLOUDFRONT_VIEWER_COUNTRY'
49
+ CITY_HEADER = 'HTTP_CLOUDFRONT_VIEWER_CITY'
50
+ # CloudFront exposes both a region code ("CA") and its full name ("California").
51
+ # Match the Cloudflare provider's semantics: #region is the name, #region_code the code.
52
+ REGION_HEADER = 'HTTP_CLOUDFRONT_VIEWER_COUNTRY_REGION_NAME'
53
+ REGION_CODE_HEADER = 'HTTP_CLOUDFRONT_VIEWER_COUNTRY_REGION'
54
+ LATITUDE_HEADER = 'HTTP_CLOUDFRONT_VIEWER_LATITUDE'
55
+ LONGITUDE_HEADER = 'HTTP_CLOUDFRONT_VIEWER_LONGITUDE'
56
+ TIMEZONE_HEADER = 'HTTP_CLOUDFRONT_VIEWER_TIME_ZONE'
57
+ POSTAL_CODE_HEADER = 'HTTP_CLOUDFRONT_VIEWER_POSTAL_CODE'
58
+ METRO_CODE_HEADER = 'HTTP_CLOUDFRONT_VIEWER_METRO_CODE'
59
+
60
+ COUNTRY_CODE_PATTERN = /\A[A-Za-z]{2}\z/
61
+ INDUSTRY_PRACTICE_COUNTRY_CODES = %w[XK].freeze
62
+ INVALID_PERCENT_ESCAPE_PATTERN = /%(?![0-9A-Fa-f]{2})/
63
+ PERCENT_ESCAPE_PATTERN = /%([0-9A-Fa-f]{2})/
64
+ LATITUDE_RANGE = (-90.0..90.0)
65
+ LONGITUDE_RANGE = (-180.0..180.0)
66
+
67
+ private_constant :COUNTRY_CODE_PATTERN,
68
+ :INDUSTRY_PRACTICE_COUNTRY_CODES,
69
+ :INVALID_PERCENT_ESCAPE_PATTERN,
70
+ :PERCENT_ESCAPE_PATTERN,
71
+ :LATITUDE_RANGE,
72
+ :LONGITUDE_RANGE
73
+
74
+ class << self
75
+ def provider_name
76
+ :cloudfront
77
+ end
78
+
79
+ def provider_source
80
+ :cloudfront_request_headers
81
+ end
82
+
83
+ # Check if CloudFront headers are available in the request
84
+ def available?(request: nil)
85
+ return false unless request
86
+
87
+ !extract_country_code(request).nil?
88
+ end
89
+
90
+ # Locate IP using CloudFront headers
91
+ # @param ip [String] The IP address (not used, as CloudFront already resolved it)
92
+ # @param request [#env] Rack-compatible request object with CloudFront headers
93
+ # @return [LocationResult] The location information
94
+ def locate(_ip, request: nil)
95
+ raise Trackdown::Error, 'CloudfrontProvider requires a request object with CloudFront headers' unless request
96
+
97
+ provenance = request_provenance(request)
98
+ country_code = extract_country_code(request)
99
+
100
+ # If no valid country code, return unknown
101
+ return LocationResult.unavailable(:provider_returned_unknown_country, **provenance) if country_code.nil?
102
+
103
+ build_location_result(country_code, request, **provenance)
104
+ end
105
+
106
+ private
107
+
108
+ def build_location_result(country_code, request, **provenance)
109
+ LocationResult.new(
110
+ country_code,
111
+ get_country_name(country_code),
112
+ extract_city(request),
113
+ get_emoji_flag(country_code),
114
+ region: extract_header(request, REGION_HEADER),
115
+ region_code: extract_header(request, REGION_CODE_HEADER),
116
+ continent: continent_code(country_code),
117
+ timezone: extract_header(request, TIMEZONE_HEADER),
118
+ latitude: parse_coordinate(request.env[LATITUDE_HEADER], range: LATITUDE_RANGE),
119
+ longitude: parse_coordinate(request.env[LONGITUDE_HEADER], range: LONGITUDE_RANGE),
120
+ postal_code: extract_header(request, POSTAL_CODE_HEADER),
121
+ metro_code: extract_header(request, METRO_CODE_HEADER),
122
+ **provenance
123
+ )
124
+ end
125
+
126
+ # Derive the 2-letter continent code from the country code via the countries gem.
127
+ # Returns nil for unknown countries or continents outside the table.
128
+ def continent_code(country_code)
129
+ country = ISO3166::Country.new(country_code)
130
+ CONTINENT_CODES[country&.continent]
131
+ rescue StandardError
132
+ nil
133
+ end
134
+
135
+ def extract_country_code(request)
136
+ # AWS specifies an ISO 3166-1 alpha-2 value and links the authoritative list:
137
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location
138
+ code = request.env[COUNTRY_HEADER]
139
+ return nil unless code.is_a?(String)
140
+
141
+ return nil unless COUNTRY_CODE_PATTERN.match?(code)
142
+
143
+ normalized_code = code.upcase
144
+
145
+ # Unicode CLDR gives XK (Kosovo) defined industry-practice semantics,
146
+ # while ZZ explicitly means an unknown or invalid territory. Preserve
147
+ # XK even though the countries gem's ISO catalog does not contain it;
148
+ # continue to reject arbitrary unassigned codes such as ZZ.
149
+ # https://www.unicode.org/reports/tr35/tr35-78/tr35.html#unicode_region_subtag_validity
150
+ return normalized_code if INDUSTRY_PRACTICE_COUNTRY_CODES.include?(normalized_code)
151
+
152
+ country = ISO3166::Country.new(normalized_code)
153
+ return nil unless country&.alpha2 == normalized_code
154
+
155
+ normalized_code
156
+ rescue StandardError
157
+ nil
158
+ end
159
+
160
+ def extract_city(request)
161
+ city = decode_header_value(request.env[CITY_HEADER])
162
+
163
+ # The city header is only present when the origin request policy forwards it.
164
+ return 'Unknown' if city.nil?
165
+
166
+ city
167
+ end
168
+
169
+ def extract_header(request, header)
170
+ decode_header_value(request.env[header])
171
+ end
172
+
173
+ # AWS percent-encodes non-ASCII viewer-location header characters according
174
+ # to RFC 3986. Decode percent octets—not form data—so a literal "+" remains
175
+ # a plus. Reject malformed escapes and invalid UTF-8 instead of exposing
176
+ # ambiguous/binary strings to callers.
177
+ # AWS: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html#cloudfront-headers-viewer-location
178
+ # RFC 3986: https://www.rfc-editor.org/rfc/rfc3986#section-2.1
179
+ def decode_header_value(value)
180
+ return nil unless value.is_a?(String)
181
+ return nil if value.empty?
182
+
183
+ encoded_value = value.b
184
+ return nil if INVALID_PERCENT_ESCAPE_PATTERN.match?(encoded_value)
185
+
186
+ decoded_value = encoded_value.gsub(PERCENT_ESCAPE_PATTERN) do
187
+ Regexp.last_match(1).to_i(16).chr
188
+ end
189
+ decoded_value.force_encoding(Encoding::UTF_8)
190
+ return nil unless decoded_value.valid_encoding?
191
+
192
+ decoded_value
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -3,6 +3,7 @@
3
3
  require 'timeout'
4
4
  require_relative 'base_provider'
5
5
  require_relative '../location_result'
6
+ require_relative '../database_fingerprint'
6
7
 
7
8
  # Conditionally require MaxMind - this is an optional dependency
8
9
  begin
@@ -20,10 +21,30 @@ module Trackdown
20
21
  class TimeoutError < Trackdown::Error; end
21
22
  class DatabaseError < Trackdown::Error; end
22
23
 
23
- @@reader_pool = nil
24
- @@pool_mutex = Mutex.new
24
+ DatabaseReader = Struct.new(:reader, :fingerprint, keyword_init: true)
25
+ DatabaseLookup = Struct.new(:record, :fingerprint, keyword_init: true)
26
+ private_constant :DatabaseReader, :DatabaseLookup
27
+
28
+ # MaxMind publishes the accuracy radius as the radius, in kilometres, within
29
+ # which the address is likely to be, at a 67% confidence level:
30
+ # https://support.maxmind.com/knowledge-base/articles/maxmind-geolocation-accuracy
31
+ ACCURACY_RADIUS_CONFIDENCE_PERCENTAGE = 67
32
+
33
+ @reader_pool = nil
34
+ @pool_mutex = Mutex.new
35
+ @database_fingerprint = nil
36
+ @database_fingerprints = {}
37
+ @fingerprint_mutex = Mutex.new
25
38
 
26
39
  class << self
40
+ def provider_name
41
+ :maxmind
42
+ end
43
+
44
+ def provider_source
45
+ :maxmind_local_database
46
+ end
47
+
27
48
  # Check if MaxMind database is available
28
49
  def available?(request: nil)
29
50
  return false unless maxmind_available?
@@ -32,6 +53,29 @@ module Trackdown
32
53
  true
33
54
  end
34
55
 
56
+ # The fingerprint used by the most recent successful reader fetch. Results
57
+ # do not read this global diagnostic: each one retains its reader-bound
58
+ # fingerprint, so concurrent generations can never mix provenance.
59
+ def database_fingerprint
60
+ @fingerprint_mutex.synchronize { @database_fingerprint }
61
+ end
62
+
63
+ # Forget the open database. Call this after replacing the .mmdb file so the
64
+ # next lookup opens the new one — Trackdown::DatabaseUpdater already does.
65
+ def reset_database!
66
+ # Let go of the pool rather than shutting it down: a lookup already in
67
+ # flight must not fail because a refresh happened underneath it. Ruby
68
+ # reclaims the old readers once the last lookup lets go of them.
69
+ @pool_mutex.synchronize do
70
+ @reader_pool = nil
71
+ @fingerprint_mutex.synchronize do
72
+ @database_fingerprint = nil
73
+ @database_fingerprints = {}
74
+ end
75
+ end
76
+ nil
77
+ end
78
+
35
79
  # Locate IP using MaxMind database
36
80
  # @param ip [String] The IP address to locate
37
81
  # @param request [ActionDispatch::Request, nil] Not used by MaxMind provider
@@ -40,13 +84,19 @@ module Trackdown
40
84
  raise Trackdown::Error, "MaxMind database not found" unless Trackdown.database_exists?
41
85
  raise Trackdown::Error, "maxmind-db gem not installed. Add it to your Gemfile: gem 'maxmind-db'" unless maxmind_available?
42
86
 
43
- record = fetch_record(ip)
44
- return LocationResult.new(nil, 'Unknown', 'Unknown', '🏳️') if record.nil?
87
+ lookup = fetch_record(ip)
88
+ record = lookup.record
89
+ fingerprint = lookup.fingerprint
90
+ provenance = database_provenance(fingerprint)
91
+
92
+ # We looked, in this exact database, and this address simply isn't in it.
93
+ return LocationResult.unavailable(:address_not_found, **provenance) if record.nil?
45
94
 
46
95
  country_code = extract_country_code(record)
47
96
  country_name = extract_country_name(record)
48
97
  city = extract_city(record)
49
98
  flag_emoji = get_emoji_flag(country_code)
99
+ accuracy_radius = record&.dig('location', 'accuracy_radius')
50
100
 
51
101
  LocationResult.new(
52
102
  country_code, country_name, city, flag_emoji,
@@ -57,20 +107,37 @@ module Trackdown
57
107
  latitude: record&.dig('location', 'latitude'),
58
108
  longitude: record&.dig('location', 'longitude'),
59
109
  postal_code: record&.dig('postal', 'code'),
60
- metro_code: record&.dig('location', 'metro_code')&.to_s
110
+ metro_code: record&.dig('location', 'metro_code')&.to_s,
111
+ accuracy_radius_in_kilometers: accuracy_radius,
112
+ accuracy_radius_confidence_percentage: (ACCURACY_RADIUS_CONFIDENCE_PERCENTAGE if accuracy_radius),
113
+ **provenance
61
114
  )
62
115
  end
63
116
 
64
117
  private
65
118
 
119
+ # Which database answered, plus how to identify it. The digest is a lambda
120
+ # so reading it stays optional: an ordinary lookup never re-reads the file.
121
+ def database_provenance(fingerprint)
122
+ {
123
+ provider_name: provider_name,
124
+ provider_source: provider_source,
125
+ database_build_epoch: fingerprint&.build_epoch,
126
+ database_sha256: (-> { fingerprint.sha256 } if fingerprint)
127
+ }
128
+ end
129
+
66
130
  def maxmind_available?
67
131
  defined?(MaxMind::DB)
68
132
  end
69
133
 
70
134
  def fetch_record(ip)
71
135
  Timeout.timeout(Trackdown.configuration.timeout) do
72
- reader_pool.with do |reader|
73
- reader.get(ip)
136
+ reader_pool.with do |database_reader|
137
+ record = database_reader.reader.get(ip)
138
+ fingerprint = remember_database(database_reader.fingerprint)
139
+
140
+ DatabaseLookup.new(record: record, fingerprint: fingerprint)
74
141
  end
75
142
  end
76
143
  rescue Timeout::Error
@@ -83,21 +150,51 @@ module Trackdown
83
150
  end
84
151
 
85
152
  def reader_pool
86
- return @@reader_pool if @@reader_pool
153
+ return @reader_pool if @reader_pool
87
154
 
88
- @@pool_mutex.synchronize do
89
- @@reader_pool ||= ConnectionPool.new(
155
+ @pool_mutex.synchronize do
156
+ @reader_pool ||= ConnectionPool.new(
90
157
  size: Trackdown.configuration.pool_size,
91
158
  timeout: Trackdown.configuration.pool_timeout
92
159
  ) do
93
- MaxMind::DB.new(
94
- Trackdown.configuration.database_path,
95
- mode: Trackdown.configuration.memory_mode
96
- )
160
+ open_database_reader
97
161
  end
98
162
  end
99
163
  end
100
164
 
165
+ # Capture the file identity before MaxMind opens it, then bind that exact
166
+ # identity to the reader for its whole lifetime. If the path changes while
167
+ # the reader opens, its eventual digest is nil rather than a digest from a
168
+ # different database generation.
169
+ def open_database_reader
170
+ path = Trackdown.configuration.database_path
171
+ fingerprint = DatabaseFingerprint.new(path: path)
172
+ reader = MaxMind::DB.new(path, mode: Trackdown.configuration.memory_mode)
173
+ fingerprint = fingerprint.with_build_epoch(build_epoch_of(reader))
174
+
175
+ DatabaseReader.new(reader: reader, fingerprint: canonical_fingerprint(fingerprint))
176
+ end
177
+
178
+ def remember_database(fingerprint)
179
+ @fingerprint_mutex.synchronize { @database_fingerprint = fingerprint }
180
+ fingerprint
181
+ end
182
+
183
+ # Reuse one lazy digest for every pooled reader that opened the same path,
184
+ # file identity, and database build. Readers from different generations
185
+ # always retain different fingerprint objects.
186
+ def canonical_fingerprint(fingerprint)
187
+ @fingerprint_mutex.synchronize do
188
+ @database_fingerprints[fingerprint.cache_key] ||= fingerprint
189
+ end
190
+ end
191
+
192
+ def build_epoch_of(reader)
193
+ reader.metadata&.build_epoch
194
+ rescue StandardError
195
+ nil
196
+ end
197
+
101
198
  def extract_country_code(record)
102
199
  record&.dig('country', 'iso_code')
103
200
  end
@@ -105,13 +202,13 @@ module Trackdown
105
202
  def extract_country_name(record)
106
203
  record&.dig('country', 'names', 'en') ||
107
204
  (record&.dig('country', 'names')&.values&.first) ||
108
- 'Unknown'
205
+ LocationResult::UNKNOWN
109
206
  end
110
207
 
111
208
  def extract_city(record)
112
209
  record&.dig('city', 'names', 'en') ||
113
210
  (record&.dig('city', 'names')&.values&.first) ||
114
- 'Unknown'
211
+ LocationResult::UNKNOWN
115
212
  end
116
213
 
117
214
  def extract_region(record)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Trackdown
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/trackdown.rb CHANGED
@@ -6,9 +6,11 @@ require_relative "trackdown/configuration"
6
6
  require_relative "trackdown/ip_validator"
7
7
  require_relative "trackdown/ip_locator"
8
8
  require_relative "trackdown/database_updater"
9
+ require_relative "trackdown/database_fingerprint"
9
10
  require_relative "trackdown/location_result"
10
11
  require_relative "trackdown/providers/base_provider"
11
12
  require_relative "trackdown/providers/cloudflare_provider"
13
+ require_relative "trackdown/providers/cloudfront_provider"
12
14
  require_relative "trackdown/providers/maxmind_provider"
13
15
  require_relative "trackdown/providers/auto_provider"
14
16
 
@@ -27,7 +29,7 @@ module Trackdown
27
29
 
28
30
  # Locate an IP address using the configured provider
29
31
  # @param ip [String] The IP address to locate
30
- # @param request [ActionDispatch::Request, nil] Optional Rails request object (required for Cloudflare provider)
32
+ # @param request [#env, nil] Optional Rack-compatible request object (required for CDN providers)
31
33
  # @return [LocationResult] The location information
32
34
  def self.locate(ip, request: nil)
33
35
  IpLocator.locate(ip, request: request)