trackdown 0.1.1 β†’ 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07f58b2d8b21b6a7a7a556cb1c5ed24023e751506cc42e630f30fd10c45b1ae4
4
- data.tar.gz: b307787b7160c444b8ac7e73883e38c8bcd60f8c7d532bb35a12cb9d0c957472
3
+ metadata.gz: 3ac258f067b151ee511c897d3460694466ed9ea36e2ff96010cccb5acf106b16
4
+ data.tar.gz: 1120ed37341b3fe876ed38997440537968e9014a1918b0c2c12acd3d10c36615
5
5
  SHA512:
6
- metadata.gz: 8e5a9dee06f9c556d10ed33880fef9ab5f1acc4323367a73ba6721dc492790b03d28ec62ec39a405b8d76f46e3f442a1a5459883cdd3ee7b7dd904e58a811279
7
- data.tar.gz: 7c2ccb29ec564cd3d70762e2746eb1cc236191687eab91757597f8e6c2074096c91524c1b64bddccef14ac93e712b1f2dbc56854c307f9536b6a24bdfbc1850a
6
+ metadata.gz: fd9029fb452c671613dcadb3081fc8e036634506f564b354997798d4b59617ac316c19976ae0ea2d9f4cfbc6a6d16a9a7729fc70d60d6a47354a808205bb6106
7
+ data.tar.gz: 94a789bbd321cb389590c466da2de5c7887812c9362d086a3d35446a428bd2a2a6a398f485d7629f86fec24e7e51a5b26d9871e512a4bb4e49f10edf9de45f7b
data/AGENTS.md ADDED
@@ -0,0 +1,5 @@
1
+ # AGENTS.md
2
+
3
+ This file provides guidance to AI agents (OpenAI Codex, Claude Code, etc.) when working with code in this repository.
4
+
5
+ Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now
data/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.2.0] - 2026-01-02
2
+
3
+ - Completely decouple Maxmind from the gem, making it optional
4
+ - Add the provider pattern to support more Geo IP providers than MaxMind
5
+ - Add support for Cloudflare IP headers out of the box
6
+
1
7
  ## [0.1.1] - 2024-10-29
2
8
 
3
9
  - Fix config validationerror on deployment
data/CLAUDE.md ADDED
@@ -0,0 +1,5 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now
data/README.md CHANGED
@@ -1,13 +1,39 @@
1
- # πŸ“ `trackdown` - Ruby gem to geolocate IPs (MaxMind BYOK)
1
+ # πŸ“ `trackdown` - Ruby gem to geolocate IPs
2
2
 
3
- `trackdown` is a Ruby gem that easily allows you to geolocate IP addresses. It's a simple, convenient wrapper on top of MaxMind. Just bring your own MaxMind keys, and you're good to go. It keeps your MaxMind database updated regularly, and it offers a handy API for Rails applications to fetch country, city, and emoji flag information for any IP address.
3
+ `trackdown` is a Ruby gem that allows you to geolocate IP addresses easily. It works out-of-the-box with **Cloudflare** (zero config!); and it's also a simple, convenient wrapper on top of **MaxMind** (just bring your own MaxMind key, and you're good to go!). `trackdown` offers a clean API for Rails applications to fetch country, city, and emoji flag information for any IP address.
4
4
 
5
5
  Given an IP, it gives you the corresponding:
6
6
  - πŸ—ΊοΈ Country (two-letter country code + country name)
7
7
  - πŸ“ City
8
8
  - πŸ‡ΊπŸ‡Έ Emoji flag of the country
9
9
 
10
- `trackdown` is BYOK (Bring Your Own Key) – you'll need your own MaxMind keys for it to work. It's your responsibility to make sure your app complies with the license for the MaxMind database you're using. Get a MaxMind account and license key at [MaxMind](https://www.maxmind.com/).
10
+ ## Two ways to use `trackdown`
11
+
12
+ ### Option 1: Cloudflare (recommended, zero config)
13
+
14
+ If your app is behind Cloudflare, you can use `trackdown` with **zero configuration**:
15
+ - No API keys needed
16
+ - No database downloads
17
+ - No external dependencies
18
+ - Instant lookups from Cloudflare headers
19
+
20
+ Just enable "IP Geolocation" in your Cloudflare dashboard and you're done! We automatically check for the Cloudflare headers in the context of a `request` and provide you with the IP geo data.
21
+
22
+ ### Option 2: MaxMind (BYOK - Bring Your Own Key)
23
+
24
+ For apps not behind Cloudflare, offline apps, non-Rails apps, or as a fallback, use MaxMind:
25
+ - Requires MaxMind account and license key
26
+ - Requires downloading and maintaining a local database
27
+ - Works offline once database is downloaded
28
+ - Get started at [MaxMind](https://www.maxmind.com/)
29
+
30
+ ### Option 3: Auto
31
+
32
+ By default, `trackdown` uses **`:auto` mode** which tries Cloudflare first and falls back to MaxMind automatically.
33
+
34
+ > [!NOTE]
35
+ > Trackdown fails gracefully. If no provider is available (no Cloudflare headers, no MaxMind database), it returns `'Unknown'` instead of raising an error, so your app doesn't crash due to a missing geolocation provider.
36
+
11
37
 
12
38
  ## Installation
13
39
 
@@ -15,6 +41,10 @@ Add this line to your application's Gemfile:
15
41
 
16
42
  ```ruby
17
43
  gem 'trackdown'
44
+
45
+ # Optional: Only needed if using MaxMind provider
46
+ gem 'maxmind-db' # For MaxMind database access
47
+ gem 'connection_pool' # For connection pooling
18
48
  ```
19
49
 
20
50
  And then execute:
@@ -25,79 +55,121 @@ bundle install
25
55
 
26
56
  ## Setup
27
57
 
28
- First, run the installation generator:
58
+ ### Quick Start (Cloudflare)
59
+
60
+ If your app is behind Cloudflare, setup is super simple:
61
+
62
+ 1. **Enable IP Geolocation in Cloudflare**
29
63
 
64
+ 2. **That's it!** No initializer needed. Just use it:
65
+
66
+ ```ruby
67
+ # In your controller
68
+ Trackdown.locate(request.remote_ip, request: request).country
69
+ # => 'United States'
70
+ ```
71
+
72
+ ### Setup with MaxMind
73
+
74
+ If you want to use `trackdown` with a MaxMind database as the geo IP data provider:
75
+
76
+ 1. **Run the generator**:
30
77
  ```bash
31
78
  rails generate trackdown:install
32
79
  ```
33
80
 
34
- This will create an initializer file at `config/initializers/trackdown.rb`. Open this file and add your MaxMind license key and account ID:
81
+ This will create an initializer file at `config/initializers/trackdown.rb`. Open this file and add your MaxMind license key and account ID next.
35
82
 
83
+ 2. **Configure your MaxMind credentials** in `config/initializers/trackdown.rb`:
36
84
  ```ruby
37
85
  Trackdown.configure do |config|
38
- # Tip: do not write your plaintext keys in the code, use Rails.application.credentials instead
39
- config.maxmind_account_id = 'your_account_id_here'
40
- config.maxmind_license_key = 'your_license_key_here'
86
+ config.provider = :auto # or :maxmind to use MaxMind exclusively
87
+
88
+ # Use Rails credentials (recommended)
89
+ config.maxmind_account_id = Rails.application.credentials.dig(:maxmind, :account_id)
90
+ config.maxmind_license_key = Rails.application.credentials.dig(:maxmind, :license_key)
41
91
  end
42
92
  ```
43
93
 
44
94
  > [!TIP]
45
95
  > To get your MaxMind account ID and license key, you need to create an account at [MaxMind](https://www.maxmind.com/) and get a license key.
46
96
 
47
- You can also configure the path where the MaxMind database will be stored. By default, it will be stored at `db/GeoLite2-City.mmdb`:
97
+ 3. **Download the database**:
98
+ ```ruby
99
+ Trackdown.update_database
100
+ ```
101
+
102
+ You can configure the path where the MaxMind database will be stored. By default, it will be stored at `db/GeoLite2-City.mmdb`:
48
103
 
49
104
  ```ruby
50
105
  config.database_path = Rails.root.join('db', 'GeoLite2-City.mmdb').to_s
51
106
  ```
52
107
 
53
- The generator also creates a `TrackdownDatabaseRefreshJob` job for regularly updating the MaxMind database. You can just get a database the first time and just keep using it, but the information will get outdated and some IPs will become stale or inaccurate.
108
+ 4. **Schedule regular updates** (optional but recommended):
109
+
110
+ The `trackdown` gem generator creates a `TrackdownDatabaseRefreshJob` job for regularly updating the MaxMind database. You can just get a database the first time and just keep using it, but the information will get outdated and some IPs will become stale or inaccurate.
54
111
 
55
112
  To keep your IP geolocation accurate, you need to make sure the `TrackdownDatabaseRefreshJob` runs regularly. How you do that, exactly, depends on the queueing system you're using.
56
113
 
114
+
57
115
  If you're using `solid_queue` (the Rails 8 default), you can easily add it to your schedule in the `config/recurring.yml` file like this:
116
+
58
117
  ```yaml
59
118
  production:
60
- refresh_maxmind_database:
119
+ refresh_trackdown_database:
61
120
  class: TrackdownDatabaseRefreshJob
62
121
  queue: default
63
- schedule: every week at 4am US/Pacific
122
+ schedule: every Saturday at 4am US/Pacific
64
123
  ```
65
124
 
66
- After setting everything up, you can run the following command to update the MaxMind database / get the first fresh copy of it:
125
+ > [!NOTE]
126
+ > MaxMind updates their databases [every Tuesday and Friday](https://dev.maxmind.com/geoip/geoip2/geoip2-update-process/).
127
+
128
+ ## Usage
129
+
130
+ ### With Cloudflare (recommended when available)
67
131
 
68
132
  ```ruby
69
- Trackdown.update_database
133
+ # In your controller - pass the request object
134
+ result = Trackdown.locate(request.remote_ip, request: request)
135
+ result.country
136
+ # => 'United States'
70
137
  ```
71
138
 
72
- ## Usage
139
+ ### With MaxMind or without request object
73
140
 
74
141
  To geolocate an IP address:
75
142
 
76
143
  ```ruby
77
- Trackdown.locate('8.8.8.8').country
144
+ # Works anywhere - just needs the IP
145
+ result = Trackdown.locate('8.8.8.8')
146
+ result.country
78
147
  # => 'United States'
79
148
  ```
80
149
 
81
- You can also do things like:
150
+ ### API Methods
151
+
152
+ You can do things like:
82
153
  ```ruby
83
154
  Trackdown.locate('8.8.8.8').emoji
84
155
  # => 'πŸ‡ΊπŸ‡Έ'
85
156
  ```
86
157
 
87
158
  In fact, there are a few methods you can use:
88
- ```ruby
89
- result = Trackdown.locate('8.8.8.8')
90
159
 
160
+ ```ruby
91
161
  result.country_code # => 'US'
92
162
  result.country_name # => 'United States'
93
163
  result.country # => 'United States' (alias for country_name)
94
- result.country_info # => # A big hash of information about the country, from the `countries` gem
95
- result.city # => 'Mountain View'
164
+ result.city # => 'Mountain View' (from MaxMind or Cloudflare's "Add visitor location headers")
96
165
  result.flag_emoji # => 'πŸ‡ΊπŸ‡Έ'
97
166
  result.emoji # => 'πŸ‡ΊπŸ‡Έ' (alias for flag_emoji)
98
167
  result.country_flag # => 'πŸ‡ΊπŸ‡Έ' (alias for flag_emoji)
168
+ result.country_info # => # Rich country data from the `countries` gem
99
169
  ```
100
170
 
171
+ ### Rich country information
172
+
101
173
  For `country_info` we're leveraging the [`countries`](https://github.com/countries/countries) gem, so you get a lot of information about the country, like the continent, the region, the languages spoken, the currency, and more:
102
174
 
103
175
  ```ruby
@@ -108,7 +180,10 @@ result.country_info.nationality # => 'American'
108
180
  result.country_info.iso_long_name # => 'The United States of America'
109
181
  ```
110
182
 
183
+ ### Hash data
184
+
111
185
  If you prefer, you can also get all the information as a hash:
186
+
112
187
  ```ruby
113
188
  result.to_h
114
189
  # => {
@@ -120,15 +195,66 @@ result.to_h
120
195
  # }
121
196
  ```
122
197
 
123
- To manually update the MaxMind IP database:
198
+ ## Configuration
199
+
200
+ ### Provider Options
201
+
202
+ ```ruby
203
+ Trackdown.configure do |config|
204
+ # :auto - Try Cloudflare first, fall back to MaxMind (default, recommended)
205
+ # :cloudflare - Only use Cloudflare headers
206
+ # :maxmind - Only use MaxMind database
207
+ config.provider = :auto
208
+ end
209
+ ```
210
+
211
+ ### Full Configuration Example
212
+
213
+ ```ruby
214
+ Trackdown.configure do |config|
215
+ # Provider
216
+ config.provider = :auto
217
+
218
+ # MaxMind settings (only needed if using MaxMind)
219
+ config.maxmind_account_id = Rails.application.credentials.dig(:maxmind, :account_id)
220
+ config.maxmind_license_key = Rails.application.credentials.dig(:maxmind, :license_key)
221
+ config.database_path = Rails.root.join('db', 'GeoLite2-City.mmdb').to_s
222
+
223
+ # Performance tuning (MaxMind only - requires maxmind-db gem)
224
+ config.timeout = 3
225
+ config.pool_size = 5
226
+ config.pool_timeout = 3
227
+ # config.memory_mode = MaxMind::DB::MODE_MEMORY # or MODE_FILE to reduce memory
228
+
229
+ # General
230
+ config.reject_private_ips = true # Reject 192.168.x.x, 127.0.0.1, etc.
231
+ end
232
+ ```
233
+
234
+ ### Updating the MaxMind database
235
+
236
+ Only needed when using the MaxMind provider:
237
+
124
238
  ```ruby
125
239
  Trackdown.update_database
126
240
  ```
127
241
 
242
+ ## How It Works
243
+
244
+ ### Cloudflare Provider
245
+
246
+ When you enable "IP Geolocation" in Cloudflare, they add the `CF-IPCountry` header to every request. If you enable "Add visitor location headers" (via Managed Transforms), you also get `CF-IPCity`.
247
+
248
+ Trackdown reads these headers directly from the request with zero overhead, and no database lookups.
249
+
250
+ ### MaxMind Provider
251
+
252
+ Downloads the GeoLite2-City database to your server and performs local lookups using connection pooling for performance.
253
+
128
254
 
129
255
  ## Development
130
256
 
131
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
257
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rake test` to run the Minitest tests.
132
258
 
133
259
  To install this gem onto your local machine, run `bundle exec rake install`.
134
260
 
data/Rakefile CHANGED
@@ -1,4 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- task default: %i[]
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ t.verbose = true
11
+ end
12
+
13
+ task default: :test
@@ -21,10 +21,18 @@ module Trackdown
21
21
 
22
22
  def display_post_install_message
23
23
  say "\tThe `trackdown` gem has been successfully installed!", :green
24
- say "\nTo complete the setup:"
25
- say " 1. Configure your MaxMind credentials in `config/initializers/trackdown.rb`"
26
- say " 2. Run 'Trackdown.update_database' to get a fresh MaxMind IP database."
27
- say " 3. Make sure you configure your queueing system to run the TrackdownDatabaseRefreshJob regularly so the IP database is updated regularly."
24
+ say "\nChoose your setup path:"
25
+ say "\n Option 1: Cloudflare (Zero Config - Recommended)"
26
+ say " 1. Ensure your app is behind Cloudflare"
27
+ say " 2. Enable 'IP Geolocation' in Cloudflare dashboard (Network settings)"
28
+ say " 3. Use: Trackdown.locate(request.remote_ip, request: request)"
29
+ say " That's it! No API keys, no database needed."
30
+ say "\n Option 2: MaxMind (BYOK)"
31
+ say " 1. Configure your MaxMind credentials in `config/initializers/trackdown.rb`"
32
+ say " 2. Run 'Trackdown.update_database' to download the database"
33
+ say " 3. Schedule TrackdownDatabaseRefreshJob to run weekly"
34
+ say "\n Option 3: Auto (Best of Both)"
35
+ say " The default :auto mode tries Cloudflare first, falls back to MaxMind"
28
36
  say "\nEnjoy `trackdown`!", :green
29
37
  end
30
38
 
@@ -1,21 +1,74 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Trackdown.configure do |config|
4
- # Required: Your MaxMind credentials
4
+ # ========================================
5
+ # Provider Selection
6
+ # ========================================
7
+ # Choose your IP geolocation provider:
8
+ #
9
+ # :auto (recommended, default)
10
+ # - Tries Cloudflare first (instant, zero overhead)
11
+ # - Falls back to MaxMind if Cloudflare not available
12
+ # - Perfect for hybrid deployments
13
+ #
14
+ # :cloudflare
15
+ # - Uses Cloudflare CF-IPCountry header
16
+ # - Requires: App behind Cloudflare + IP Geolocation enabled
17
+ # - Zero additional dependencies!
18
+ # - Must pass request object: Trackdown.locate(ip, request: request)
19
+ #
20
+ # :maxmind
21
+ # - Uses MaxMind GeoLite2 database
22
+ # - Requires: maxmind-db and connection_pool gems
23
+ # - Requires: MaxMind account and database download
24
+ #
25
+ config.provider = :auto
26
+
27
+ # ========================================
28
+ # Cloudflare Setup (for :cloudflare or :auto providers)
29
+ # ========================================
30
+ # 1. Ensure your app is behind Cloudflare
31
+ # 2. In Cloudflare dashboard β†’ Network β†’ Enable "IP Geolocation"
32
+ # OR under Rules β†’ Transform Rules β†’ Managed Transforms β†’ Enable "Add visitor location headers"
33
+ # 3. Use: Trackdown.locate(request.remote_ip, request: request)
34
+ #
35
+ # That's it! No gems, no API keys, no database needed.
36
+
37
+ # ========================================
38
+ # MaxMind Setup (for :maxmind or :auto providers)
39
+ # ========================================
40
+ # Only needed if using MaxMind provider or as fallback
41
+ #
42
+ # 1. Add to Gemfile:
43
+ # gem 'maxmind-db'
44
+ # gem 'connection_pool'
45
+ #
46
+ # 2. Get your MaxMind account: https://www.maxmind.com/
47
+ #
48
+ # 3. Configure credentials (using Rails credentials recommended):
5
49
  config.maxmind_account_id = Rails.application.credentials.dig(:maxmind, :account_id)
6
50
  config.maxmind_license_key = Rails.application.credentials.dig(:maxmind, :license_key)
51
+ #
52
+ # 4. Run: Trackdown.update_database
53
+ #
54
+ # 5. Schedule regular updates (MaxMind updates Tue/Fri):
55
+ # Add to config/recurring.yml (for solid_queue):
56
+ # refresh_trackdown_database:
57
+ # class: TrackdownDatabaseRefreshJob
58
+ # schedule: every Saturday at 4am
7
59
 
8
- # Optional: Configure database location (defaults to db/GeoLite2-City.mmdb)
60
+ # Optional: Database location (defaults to db/GeoLite2-City.mmdb)
9
61
  # config.database_path = Rails.root.join('db', 'GeoLite2-City.mmdb').to_s
10
62
 
11
- # Optional: Configure timeouts and pooling (defaults shown)
12
- # config.timeout = 3 # Timeout for individual lookups
13
- # config.pool_size = 5 # Size of the connection pool
14
- # config.pool_timeout = 3 # Timeout when waiting for a connection from the pool
15
-
16
- # Optional: Configure memory mode (defaults to MODE_MEMORY)
17
- # config.memory_mode = MaxMind::DB::MODE_FILE # Use MODE_FILE to reduce memory usage
63
+ # Optional: MaxMind performance tuning
64
+ # config.timeout = 3 # Lookup timeout (seconds)
65
+ # config.pool_size = 5 # Connection pool size
66
+ # config.pool_timeout = 3 # Pool wait timeout (seconds)
67
+ # config.memory_mode = MaxMind::DB::MODE_MEMORY # or MODE_FILE to reduce memory
18
68
 
19
- # Optional: Configure IP validation (defaults to true)
20
- # config.reject_private_ips = true # Reject private/local IP addresses
69
+ # ========================================
70
+ # General Options
71
+ # ========================================
72
+ # Reject private/local IP addresses (192.168.x.x, 127.0.0.1, etc.)
73
+ # config.reject_private_ips = true
21
74
  end
@@ -1,25 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Conditionally require MaxMind constants if available
4
+ begin
5
+ require 'maxmind/db'
6
+ MAXMIND_AVAILABLE = true
7
+ rescue LoadError
8
+ MAXMIND_AVAILABLE = false
9
+ end
10
+
3
11
  module Trackdown
4
12
  class Configuration
5
- attr_accessor :maxmind_license_key, :maxmind_account_id, :database_path,
13
+ attr_accessor :provider, :maxmind_license_key, :maxmind_account_id, :database_path,
6
14
  :timeout, :pool_size, :pool_timeout, :memory_mode,
7
15
  :reject_private_ips
8
16
 
17
+ # Available provider types:
18
+ # :auto - Try Cloudflare first, fall back to MaxMind (recommended)
19
+ # :cloudflare - Only use Cloudflare headers
20
+ # :maxmind - Only use MaxMind database
21
+ VALID_PROVIDERS = [:auto, :cloudflare, :maxmind].freeze
22
+
9
23
  def initialize
24
+ @provider = :auto # Intelligent default: try Cloudflare first, fall back to MaxMind
10
25
  @maxmind_license_key = nil
11
26
  @maxmind_account_id = nil
12
27
  @database_path = defined?(Rails) ? Rails.root.join('db', 'GeoLite2-City.mmdb').to_s : 'db/GeoLite2-City.mmdb'
13
28
  @timeout = 3 # seconds
14
29
  @pool_size = 5
15
30
  @pool_timeout = 3 # seconds
16
- @memory_mode = MaxMind::DB::MODE_MEMORY
31
+ @memory_mode = MAXMIND_AVAILABLE ? MaxMind::DB::MODE_MEMORY : nil
17
32
  @reject_private_ips = true
18
33
  end
19
34
 
35
+ def provider=(value)
36
+ unless VALID_PROVIDERS.include?(value)
37
+ raise ArgumentError, "Invalid provider: #{value}. Must be one of: #{VALID_PROVIDERS.join(', ')}"
38
+ end
39
+ @provider = value
40
+ end
41
+
20
42
  def reject_private_ips?
21
43
  @reject_private_ips
22
44
  end
23
-
24
45
  end
25
46
  end
@@ -1,83 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'maxmind/db'
4
- require 'connection_pool'
5
3
  require_relative 'location_result'
6
4
  require_relative 'ip_validator'
5
+ require_relative 'providers/auto_provider'
6
+ require_relative 'providers/cloudflare_provider'
7
+ require_relative 'providers/maxmind_provider'
7
8
 
8
9
  module Trackdown
9
10
  class IpLocator
10
- class TimeoutError < Trackdown::Error; end
11
- class DatabaseError < Trackdown::Error; end
12
-
13
11
  class << self
14
- def locate(ip)
12
+ # Locate an IP address using the configured provider
13
+ # @param ip [String] The IP address to locate
14
+ # @param request [ActionDispatch::Request, nil] Optional Rails request object for Cloudflare provider
15
+ # @return [LocationResult] The location information
16
+ def locate(ip, request: nil)
15
17
  IpValidator.validate!(ip)
16
18
 
17
19
  if Trackdown.configuration.reject_private_ips? && IpValidator.private_ip?(ip)
18
20
  raise IpValidator::InvalidIpError, "Private IP addresses are not allowed"
19
21
  end
20
22
 
21
- record = fetch_record(ip)
22
- return LocationResult.new(nil, 'Unknown', 'Unknown', '🏳️') if record.nil?
23
-
24
- country_code = extract_country_code(record)
25
- country_name = extract_country_name(record)
26
- city = extract_city(record)
27
- flag_emoji = get_emoji_flag(country_code)
28
-
29
- LocationResult.new(country_code, country_name, city, flag_emoji)
23
+ provider = get_provider
24
+ provider.locate(ip, request: request)
30
25
  end
31
26
 
32
27
  private
33
28
 
34
- def fetch_record(ip)
35
- Trackdown.ensure_database_exists!
36
-
37
- Timeout.timeout(Trackdown.configuration.timeout) do
38
- reader_pool.with do |reader|
39
- reader.get(ip)
40
- end
29
+ def get_provider
30
+ case Trackdown.configuration.provider
31
+ when :auto
32
+ Providers::AutoProvider
33
+ when :cloudflare
34
+ Providers::CloudflareProvider
35
+ when :maxmind
36
+ Providers::MaxmindProvider
37
+ else
38
+ raise Trackdown::Error, "Unknown provider: #{Trackdown.configuration.provider}"
41
39
  end
42
- rescue Timeout::Error
43
- raise TimeoutError, "MaxMind database lookup timed out after #{Trackdown.configuration.timeout} seconds"
44
- rescue Trackdown::Error => e
45
- raise e
46
- rescue StandardError => e
47
- Rails.logger.error("Error fetching IP data: #{e.message}") if defined?(Rails)
48
- raise DatabaseError, "Database error: #{e.message}"
49
- end
50
-
51
- def reader_pool
52
- @reader_pool ||= ConnectionPool.new(
53
- size: Trackdown.configuration.pool_size,
54
- timeout: Trackdown.configuration.pool_timeout
55
- ) do
56
- MaxMind::DB.new(
57
- Trackdown.configuration.database_path,
58
- mode: Trackdown.configuration.memory_mode
59
- )
60
- end
61
- end
62
-
63
- def extract_country_code(record)
64
- record&.dig('country', 'iso_code')
65
- end
66
-
67
- def extract_country_name(record)
68
- record&.dig('country', 'names', 'en') ||
69
- (record&.dig('country', 'names')&.values&.first) ||
70
- 'Unknown'
71
- end
72
-
73
- def extract_city(record)
74
- record&.dig('city', 'names', 'en') ||
75
- (record&.dig('city', 'names')&.values&.first) ||
76
- 'Unknown'
77
- end
78
-
79
- def get_emoji_flag(country_code)
80
- country_code ? country_code.tr('A-Z', "\u{1F1E6}-\u{1F1FF}") : "🏳️"
81
40
  end
82
41
  end
83
42
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_provider'
4
+ require_relative 'cloudflare_provider'
5
+ require_relative 'maxmind_provider'
6
+
7
+ module Trackdown
8
+ module Providers
9
+ # Intelligent provider that automatically selects the best available provider
10
+ # Priority order:
11
+ # 1. Cloudflare (fastest, zero overhead, no external dependencies)
12
+ # 2. MaxMind (fallback when Cloudflare not available)
13
+ #
14
+ # This is the recommended default for most applications
15
+ class AutoProvider < BaseProvider
16
+ @@warned_no_providers = false
17
+ @@warn_mutex = Mutex.new
18
+
19
+ class << self
20
+ # Auto provider is available if at least one provider is available
21
+ def available?(request: nil)
22
+ CloudflareProvider.available?(request: request) ||
23
+ MaxmindProvider.available?(request: request)
24
+ end
25
+
26
+ # Intelligently locate IP using the best available provider
27
+ # @param ip [String] The IP address to locate
28
+ # @param request [ActionDispatch::Request, nil] Optional Rails request object
29
+ # @return [LocationResult] The location information
30
+ def locate(ip, request: nil)
31
+ # Try Cloudflare first - it's instant and free!
32
+ if CloudflareProvider.available?(request: request)
33
+ return CloudflareProvider.locate(ip, request: request)
34
+ end
35
+
36
+ # Fall back to MaxMind if available
37
+ if MaxmindProvider.available?(request: request)
38
+ return MaxmindProvider.locate(ip, request: request)
39
+ end
40
+
41
+ # No providers available - fail gracefully with a warning
42
+ warn_no_providers
43
+ LocationResult.new(nil, 'Unknown', 'Unknown', '🏳️')
44
+ end
45
+
46
+ private
47
+
48
+ def warn_no_providers
49
+ # Only warn once per process to avoid log spam
50
+ return if @@warned_no_providers
51
+
52
+ @@warn_mutex.synchronize do
53
+ return if @@warned_no_providers
54
+ @@warned_no_providers = true
55
+
56
+ message = "[Trackdown] No IP geolocation provider available. Returning 'Unknown' for all lookups. " \
57
+ "Configure Cloudflare headers or MaxMind to enable geolocation. " \
58
+ "See: https://github.com/rameerez/trackdown"
59
+
60
+ if defined?(Rails)
61
+ Rails.logger.warn(message)
62
+ else
63
+ warn(message)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'countries'
4
+
5
+ module Trackdown
6
+ module Providers
7
+ class BaseProvider
8
+ # Returns true if this provider can handle the given request/context
9
+ def self.available?(request: nil)
10
+ raise NotImplementedError, "#{self} must implement .available?"
11
+ end
12
+
13
+ # Locates the IP and returns a LocationResult
14
+ # @param ip [String] The IP address to locate
15
+ # @param request [ActionDispatch::Request, nil] Optional Rails request object for header access
16
+ # @return [LocationResult] The location information
17
+ def self.locate(ip, request: nil)
18
+ raise NotImplementedError, "#{self} must implement .locate"
19
+ end
20
+
21
+ protected
22
+
23
+ # Helper to get emoji flag from country code
24
+ def self.get_emoji_flag(country_code)
25
+ country_code ? country_code.tr('A-Z', "\u{1F1E6}-\u{1F1FF}") : "🏳️"
26
+ end
27
+
28
+ # Helper to extract country name from country code using countries gem
29
+ def self.get_country_name(country_code)
30
+ return 'Unknown' unless country_code
31
+
32
+ country = ISO3166::Country.new(country_code)
33
+ country&.iso_short_name || country&.name || 'Unknown'
34
+ rescue StandardError
35
+ 'Unknown'
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,70 @@
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 Cloudflare HTTP headers for IP geolocation
9
+ # This is the fastest and most lightweight option when your app is behind Cloudflare
10
+ #
11
+ # Cloudflare must have "IP Geolocation" or "Add visitor location headers" enabled
12
+ # in the dashboard under Network settings or via Managed Transforms
13
+ class CloudflareProvider < BaseProvider
14
+ COUNTRY_HEADER = 'HTTP_CF_IPCOUNTRY'
15
+ CITY_HEADER = 'HTTP_CF_IPCITY'
16
+
17
+ # Special Cloudflare country codes
18
+ UNKNOWN_CODE = 'XX'
19
+ TOR_CODE = 'T1'
20
+
21
+ class << self
22
+ # Check if Cloudflare headers are available in the request
23
+ def available?(request: nil)
24
+ return false unless request
25
+
26
+ country_code = request.env[COUNTRY_HEADER]
27
+ !country_code.nil? && !country_code.empty? && country_code != UNKNOWN_CODE
28
+ end
29
+
30
+ # Locate IP using Cloudflare headers
31
+ # @param ip [String] The IP address (not used, as Cloudflare already resolved it)
32
+ # @param request [ActionDispatch::Request] Rails request object with Cloudflare headers
33
+ # @return [LocationResult] The location information
34
+ def locate(ip, request: nil)
35
+ raise Trackdown::Error, "CloudflareProvider requires a request object with Cloudflare headers" unless request
36
+
37
+ country_code = extract_country_code(request)
38
+
39
+ # If no valid country code, return unknown
40
+ return LocationResult.new(nil, 'Unknown', 'Unknown', '🏳️') if country_code.nil? || country_code == UNKNOWN_CODE
41
+
42
+ country_name = get_country_name(country_code)
43
+ city = extract_city(request)
44
+ flag_emoji = get_emoji_flag(country_code)
45
+
46
+ LocationResult.new(country_code, country_name, city, flag_emoji)
47
+ end
48
+
49
+ private
50
+
51
+ def extract_country_code(request)
52
+ code = request.env[COUNTRY_HEADER]
53
+ return nil if code.nil? || code.empty? || code == UNKNOWN_CODE
54
+
55
+ code.upcase
56
+ end
57
+
58
+ def extract_city(request)
59
+ city = request.env[CITY_HEADER]
60
+
61
+ # Cloudflare city header might not always be present
62
+ # It requires "Add visitor location headers" Managed Transform
63
+ return 'Unknown' if city.nil? || city.empty?
64
+
65
+ city
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+ require_relative 'base_provider'
5
+ require_relative '../location_result'
6
+
7
+ # Conditionally require MaxMind - this is an optional dependency
8
+ begin
9
+ require 'maxmind/db'
10
+ require 'connection_pool'
11
+ rescue LoadError
12
+ # MaxMind gem not available - that's ok, other providers might be used
13
+ end
14
+
15
+ module Trackdown
16
+ module Providers
17
+ # Provider that uses MaxMind GeoLite2 database for IP geolocation
18
+ # Requires the maxmind-db gem and a downloaded database file
19
+ class MaxmindProvider < BaseProvider
20
+ class TimeoutError < Trackdown::Error; end
21
+ class DatabaseError < Trackdown::Error; end
22
+
23
+ @@reader_pool = nil
24
+ @@pool_mutex = Mutex.new
25
+
26
+ class << self
27
+ # Check if MaxMind database is available
28
+ def available?(request: nil)
29
+ return false unless maxmind_available?
30
+ return false unless Trackdown.database_exists?
31
+
32
+ true
33
+ end
34
+
35
+ # Locate IP using MaxMind database
36
+ # @param ip [String] The IP address to locate
37
+ # @param request [ActionDispatch::Request, nil] Not used by MaxMind provider
38
+ # @return [LocationResult] The location information
39
+ def locate(ip, request: nil)
40
+ raise Trackdown::Error, "MaxMind database not found" unless Trackdown.database_exists?
41
+ raise Trackdown::Error, "maxmind-db gem not installed. Add it to your Gemfile: gem 'maxmind-db'" unless maxmind_available?
42
+
43
+ record = fetch_record(ip)
44
+ return LocationResult.new(nil, 'Unknown', 'Unknown', '🏳️') if record.nil?
45
+
46
+ country_code = extract_country_code(record)
47
+ country_name = extract_country_name(record)
48
+ city = extract_city(record)
49
+ flag_emoji = get_emoji_flag(country_code)
50
+
51
+ LocationResult.new(country_code, country_name, city, flag_emoji)
52
+ end
53
+
54
+ private
55
+
56
+ def maxmind_available?
57
+ defined?(MaxMind::DB)
58
+ end
59
+
60
+ def fetch_record(ip)
61
+ Timeout.timeout(Trackdown.configuration.timeout) do
62
+ reader_pool.with do |reader|
63
+ reader.get(ip)
64
+ end
65
+ end
66
+ rescue Timeout::Error
67
+ raise TimeoutError, "MaxMind database lookup timed out after #{Trackdown.configuration.timeout} seconds"
68
+ rescue Trackdown::Error => e
69
+ raise e
70
+ rescue StandardError => e
71
+ Rails.logger.error("Error fetching IP data: #{e.message}") if defined?(Rails)
72
+ raise DatabaseError, "Database error: #{e.message}"
73
+ end
74
+
75
+ def reader_pool
76
+ return @@reader_pool if @@reader_pool
77
+
78
+ @@pool_mutex.synchronize do
79
+ @@reader_pool ||= ConnectionPool.new(
80
+ size: Trackdown.configuration.pool_size,
81
+ timeout: Trackdown.configuration.pool_timeout
82
+ ) do
83
+ MaxMind::DB.new(
84
+ Trackdown.configuration.database_path,
85
+ mode: Trackdown.configuration.memory_mode
86
+ )
87
+ end
88
+ end
89
+ end
90
+
91
+ def extract_country_code(record)
92
+ record&.dig('country', 'iso_code')
93
+ end
94
+
95
+ def extract_country_name(record)
96
+ record&.dig('country', 'names', 'en') ||
97
+ (record&.dig('country', 'names')&.values&.first) ||
98
+ 'Unknown'
99
+ end
100
+
101
+ def extract_city(record)
102
+ record&.dig('city', 'names', 'en') ||
103
+ (record&.dig('city', 'names')&.values&.first) ||
104
+ 'Unknown'
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Trackdown
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/trackdown.rb CHANGED
@@ -7,6 +7,10 @@ require_relative "trackdown/ip_validator"
7
7
  require_relative "trackdown/ip_locator"
8
8
  require_relative "trackdown/database_updater"
9
9
  require_relative "trackdown/location_result"
10
+ require_relative "trackdown/providers/base_provider"
11
+ require_relative "trackdown/providers/cloudflare_provider"
12
+ require_relative "trackdown/providers/maxmind_provider"
13
+ require_relative "trackdown/providers/auto_provider"
10
14
 
11
15
  module Trackdown
12
16
  class << self
@@ -21,11 +25,15 @@ module Trackdown
21
25
  yield(configuration)
22
26
  end
23
27
 
24
- def self.locate(ip)
25
- ensure_database_exists!
26
- IpLocator.locate(ip)
28
+ # Locate an IP address using the configured provider
29
+ # @param ip [String] The IP address to locate
30
+ # @param request [ActionDispatch::Request, nil] Optional Rails request object (required for Cloudflare provider)
31
+ # @return [LocationResult] The location information
32
+ def self.locate(ip, request: nil)
33
+ IpLocator.locate(ip, request: request)
27
34
  end
28
35
 
36
+ # Update the MaxMind database (only needed when using MaxMind provider)
29
37
  def self.update_database
30
38
  DatabaseUpdater.update
31
39
  end
@@ -34,6 +42,8 @@ module Trackdown
34
42
  File.exist?(configuration.database_path)
35
43
  end
36
44
 
45
+ # Legacy method - kept for backwards compatibility
46
+ # New code should handle provider-specific errors instead
37
47
  def self.ensure_database_exists!
38
48
  unless database_exists?
39
49
  raise Error, "MaxMind database not found. Please set your MaxMind keys in config/initializers/trackdown.rb as described in the `trackdown` gem README, and then run Trackdown.update_database to download the MaxMind IP geolocation database."
metadata CHANGED
@@ -1,73 +1,100 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trackdown
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-10-29 00:00:00.000000000 Z
10
+ date: 2026-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
- name: maxmind-db
13
+ name: countries
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
16
  - - "~>"
18
17
  - !ruby/object:Gem::Version
19
- version: '1.2'
18
+ version: '7.0'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - "~>"
25
24
  - !ruby/object:Gem::Version
26
- version: '1.2'
25
+ version: '7.0'
27
26
  - !ruby/object:Gem::Dependency
28
- name: connection_pool
27
+ name: rake
29
28
  requirement: !ruby/object:Gem::Requirement
30
29
  requirements:
31
30
  - - "~>"
32
31
  - !ruby/object:Gem::Version
33
- version: '2.4'
34
- type: :runtime
32
+ version: '13.0'
33
+ type: :development
35
34
  prerelease: false
36
35
  version_requirements: !ruby/object:Gem::Requirement
37
36
  requirements:
38
37
  - - "~>"
39
38
  - !ruby/object:Gem::Version
40
- version: '2.4'
39
+ version: '13.0'
41
40
  - !ruby/object:Gem::Dependency
42
- name: countries
41
+ name: rubocop
43
42
  requirement: !ruby/object:Gem::Requirement
44
43
  requirements:
45
44
  - - "~>"
46
45
  - !ruby/object:Gem::Version
47
- version: '7.0'
48
- type: :runtime
46
+ version: '1.21'
47
+ type: :development
49
48
  prerelease: false
50
49
  version_requirements: !ruby/object:Gem::Requirement
51
50
  requirements:
52
51
  - - "~>"
53
52
  - !ruby/object:Gem::Version
54
- version: '7.0'
53
+ version: '1.21'
55
54
  - !ruby/object:Gem::Dependency
56
- name: rake
55
+ name: minitest
57
56
  requirement: !ruby/object:Gem::Requirement
58
57
  requirements:
59
58
  - - "~>"
60
59
  - !ruby/object:Gem::Version
61
- version: '13.0'
60
+ version: '5.25'
62
61
  type: :development
63
62
  prerelease: false
64
63
  version_requirements: !ruby/object:Gem::Requirement
65
64
  requirements:
66
65
  - - "~>"
67
66
  - !ruby/object:Gem::Version
68
- version: '13.0'
67
+ version: '5.25'
69
68
  - !ruby/object:Gem::Dependency
70
- name: rspec
69
+ name: mocha
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '2.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: simplecov
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.22'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.22'
96
+ - !ruby/object:Gem::Dependency
97
+ name: webmock
71
98
  requirement: !ruby/object:Gem::Requirement
72
99
  requirements:
73
100
  - - "~>"
@@ -81,31 +108,47 @@ dependencies:
81
108
  - !ruby/object:Gem::Version
82
109
  version: '3.0'
83
110
  - !ruby/object:Gem::Dependency
84
- name: rubocop
111
+ name: maxmind-db
85
112
  requirement: !ruby/object:Gem::Requirement
86
113
  requirements:
87
114
  - - "~>"
88
115
  - !ruby/object:Gem::Version
89
- version: '1.21'
116
+ version: '1.2'
90
117
  type: :development
91
118
  prerelease: false
92
119
  version_requirements: !ruby/object:Gem::Requirement
93
120
  requirements:
94
121
  - - "~>"
95
122
  - !ruby/object:Gem::Version
96
- version: '1.21'
123
+ version: '1.2'
124
+ - !ruby/object:Gem::Dependency
125
+ name: connection_pool
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '2.4'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '2.4'
97
138
  description: Trackdown is a Ruby gem that easily allows you to geolocate IP addresses.
98
- It's a simple, convenient wrapper on top of the MaxMind database. Plug your MaxMind
99
- license key and you're good to go. It keeps your MaxMind database updated regularly,
100
- and it offers a handy API for Rails applications to fetch country, city, and emoji
101
- flag information for any IP address.
139
+ It works out of the box with Cloudflare headers if you're using it, or you can use
140
+ MaxMind (BYOK). The gem offers a clean API for Rails applications to fetch country,
141
+ city, and emoji flag information for any IP address. Supports Cloudflare headers
142
+ (instant, zero overhead) and MaxMind GeoLite2 database (offline capable).
102
143
  email:
103
144
  - rubygems@rameerez.com
104
145
  executables: []
105
146
  extensions: []
106
147
  extra_rdoc_files: []
107
148
  files:
149
+ - AGENTS.md
108
150
  - CHANGELOG.md
151
+ - CLAUDE.md
109
152
  - LICENSE.txt
110
153
  - README.md
111
154
  - Rakefile
@@ -119,6 +162,10 @@ files:
119
162
  - lib/trackdown/ip_locator.rb
120
163
  - lib/trackdown/ip_validator.rb
121
164
  - lib/trackdown/location_result.rb
165
+ - lib/trackdown/providers/auto_provider.rb
166
+ - lib/trackdown/providers/base_provider.rb
167
+ - lib/trackdown/providers/cloudflare_provider.rb
168
+ - lib/trackdown/providers/maxmind_provider.rb
122
169
  - lib/trackdown/version.rb
123
170
  - sig/trackdown.rbs
124
171
  homepage: https://github.com/rameerez/trackdown
@@ -129,7 +176,6 @@ metadata:
129
176
  homepage_uri: https://github.com/rameerez/trackdown
130
177
  source_code_uri: https://github.com/rameerez/trackdown
131
178
  changelog_uri: https://github.com/rameerez/trackdown/blob/main/CHANGELOG.md
132
- post_install_message:
133
179
  rdoc_options: []
134
180
  require_paths:
135
181
  - lib
@@ -144,9 +190,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
144
190
  - !ruby/object:Gem::Version
145
191
  version: '0'
146
192
  requirements: []
147
- rubygems_version: 3.5.16
148
- signing_key:
193
+ rubygems_version: 3.6.2
149
194
  specification_version: 4
150
- summary: Get country, city, and emoji flag information for IP addresses using a MaxMind
151
- database
195
+ summary: Get country, city, and emoji flag information for IP addresses using Cloudflare
196
+ or MaxMind
152
197
  test_files: []