Hvor
Hvor ("where", in Danish) is a mountable Rails engine that imports
IP2Location LITE CSV data (IPv4 and IPv6
ranges mapped to country_code) into an Hvor::IPRange table, and answers
"what country is this IP in?" via a cached lookup and a small JSON API.
Installation
Add to your Gemfile:
gem "hvor"
Then:
bundle
bin/rails generate hvor:install
bin/rails hvor:install:migrations
bin/rails db:migrate
The install generator writes config/initializers/hvor.rb. At minimum, set an
IP2Location LITE token there (or via the IP2LOCATION_TOKEN env var) — get
one free at https://lite.ip2location.com/.
Populating IPRange
Enqueue Hvor::ImportIpRangesJob (on a daily/weekly schedule via whatever job
scheduler your app uses — IP2Location LITE data updates roughly monthly). It
downloads the IPv4 and IPv6 country CSV zips, and does an atomic
dump-and-repopulate of Hvor::IPRange — the table is only ever replaced once
both files have downloaded and parsed successfully.
Hvor::ImportIpRangesJob.perform_later
Looking up an IP
Hvor.lookup("1.0.0.1") # => "AU"
Hvor.lookup("::ffff:8.8.8.8") # => "US" (ipv4-mapped ipv6 is unwrapped to plain ipv4)
Results (including misses) are cached via Rails.cache for
Hvor.configuration.cache_expires_in (default 24 hours). A country_code of
"-" means the IP falls in an unallocated/private/reserved range (this is
IP2Location's own placeholder — e.g. Hvor.lookup("127.0.0.1") will
typically return "-", not nil; nil means the IP simply isn't covered by
any imported range, which shouldn't happen once the table is populated).
Mounted API
Mount the engine in your host app's routes:
mount Hvor::Engine => "/hvor"
This exposes:
GET /hvor/lookup?ip=1.2.3.4—{"ip":"1.2.3.4","country_code":"AU"}GET /hvor/lookup/me— same, but for the requesting client's own IP (request.remote_ip)
An invalid ip param returns 400 with {"error": "..."}.
Configuration
# config/initializers/hvor.rb
Hvor.configure do |config|
config.api_token = ENV["IP2LOCATION_TOKEN"]
config.ipv4_product_code = "DB1LITECSV" # country-only LITE database
config.ipv6_product_code = "DB1LITECSVIPV6"
config.cache_store = -> { Rails.cache }
config.cache_expires_in = 24.hours
config.import_batch_size = 5_000
config.download_timeout = 60
config.queue_name = :hvor
end
Database notes
hvor_ip_ranges stores IPv4 bounds as bigint and IPv6 bounds as a
zero-padded 32-character lowercase hex string (IPv6's 128-bit range doesn't
fit in a bigint), so lookups work identically on PostgreSQL, MySQL, and
SQLite. The migration pins a binary-order collation on the IPv6 columns
(C on Postgres, ascii_bin on MySQL) so string comparison matches numeric
ordering regardless of the database's default locale — if you're running
CI against a database other than SQLite, make sure that migration path gets
exercised at least once, since SQLite's default collation is already binary
and won't catch a regression here.
License
MIT.