hvor 0.1.1
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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +101 -0
- data/Rakefile +8 -0
- data/app/assets/stylesheets/hvor/application.css +15 -0
- data/app/controllers/hvor/application_controller.rb +4 -0
- data/app/controllers/hvor/lookups_controller.rb +19 -0
- data/app/helpers/hvor/application_helper.rb +4 -0
- data/app/jobs/hvor/application_job.rb +4 -0
- data/app/jobs/hvor/import_ip_ranges_job.rb +20 -0
- data/app/mailers/hvor/application_mailer.rb +6 -0
- data/app/models/hvor/application_record.rb +5 -0
- data/app/models/hvor/ip_range.rb +21 -0
- data/app/services/hvor/downloader.rb +57 -0
- data/app/services/hvor/importer.rb +70 -0
- data/app/services/hvor/ip_normalizer.rb +46 -0
- data/app/views/layouts/hvor/application.html.erb +17 -0
- data/config/routes.rb +4 -0
- data/db/migrate/20260727225339_create_hvor_ip_ranges.rb +30 -0
- data/lib/generators/hvor/install/install_generator.rb +27 -0
- data/lib/generators/hvor/install/templates/initializer.rb +17 -0
- data/lib/hvor/configuration.rb +22 -0
- data/lib/hvor/engine.rb +13 -0
- data/lib/hvor/version.rb +3 -0
- data/lib/hvor.rb +21 -0
- data/lib/tasks/hvor_tasks.rake +4 -0
- metadata +156 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dbd8b653c8cf22786cc82ea2d5576a4b9cc1068f3fa464fd24757cd559bda69b
|
|
4
|
+
data.tar.gz: 86ff53ec10227b1348108d728f0ab747dfd66371eed97a5f4948333888a046ac
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0016eff6b286c15d0714f647df9e40c2733819ff62c084ce9b353f4f015d27917dd6ad1eedf0574954acc966cd696be524154620faad432c64d82975037445bb
|
|
7
|
+
data.tar.gz: efd340a860b6653739f19e6d27ed6e1afe3af80bbaadb6e4d9f5eddda79c82b8b2c6faf1119aa87b953a505ff1f98589bddf5214060e86794fd9b9100684e716
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright RobL
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Hvor
|
|
2
|
+
|
|
3
|
+
*Hvor* ("where", in Danish) is a mountable Rails engine that imports
|
|
4
|
+
[IP2Location](https://lite.ip2location.com/) LITE CSV data (IPv4 and IPv6
|
|
5
|
+
ranges mapped to `country_code`) into an `Hvor::IPRange` table, and answers
|
|
6
|
+
"what country is this IP in?" via a cached lookup and a small JSON API.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Add to your Gemfile:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem "hvor"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Then:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bundle
|
|
20
|
+
bin/rails generate hvor:install
|
|
21
|
+
bin/rails hvor:install:migrations
|
|
22
|
+
bin/rails db:migrate
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The install generator writes `config/initializers/hvor.rb`. At minimum, set an
|
|
26
|
+
IP2Location LITE token there (or via the `IP2LOCATION_TOKEN` env var) — get
|
|
27
|
+
one free at https://lite.ip2location.com/.
|
|
28
|
+
|
|
29
|
+
## Populating IPRange
|
|
30
|
+
|
|
31
|
+
Enqueue `Hvor::ImportIpRangesJob` (on a daily/weekly schedule via whatever job
|
|
32
|
+
scheduler your app uses — IP2Location LITE data updates roughly monthly). It
|
|
33
|
+
downloads the IPv4 and IPv6 country CSV zips, and does an atomic
|
|
34
|
+
dump-and-repopulate of `Hvor::IPRange` — the table is only ever replaced once
|
|
35
|
+
both files have downloaded and parsed successfully.
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
Hvor::ImportIpRangesJob.perform_later
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Looking up an IP
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
Hvor.lookup("1.0.0.1") # => "AU"
|
|
45
|
+
Hvor.lookup("::ffff:8.8.8.8") # => "US" (ipv4-mapped ipv6 is unwrapped to plain ipv4)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Results (including misses) are cached via `Rails.cache` for
|
|
49
|
+
`Hvor.configuration.cache_expires_in` (default 24 hours). A `country_code` of
|
|
50
|
+
`"-"` means the IP falls in an unallocated/private/reserved range (this is
|
|
51
|
+
IP2Location's own placeholder — e.g. `Hvor.lookup("127.0.0.1")` will
|
|
52
|
+
typically return `"-"`, not `nil`; `nil` means the IP simply isn't covered by
|
|
53
|
+
any imported range, which shouldn't happen once the table is populated).
|
|
54
|
+
|
|
55
|
+
## Mounted API
|
|
56
|
+
|
|
57
|
+
Mount the engine in your host app's routes:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
mount Hvor::Engine => "/hvor"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
This exposes:
|
|
64
|
+
|
|
65
|
+
- `GET /hvor/lookup?ip=1.2.3.4` — `{"ip":"1.2.3.4","country_code":"AU"}`
|
|
66
|
+
- `GET /hvor/lookup/me` — same, but for the requesting client's own IP
|
|
67
|
+
(`request.remote_ip`)
|
|
68
|
+
|
|
69
|
+
An invalid `ip` param returns `400` with `{"error": "..."}`.
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
# config/initializers/hvor.rb
|
|
75
|
+
Hvor.configure do |config|
|
|
76
|
+
config.api_token = ENV["IP2LOCATION_TOKEN"]
|
|
77
|
+
config.ipv4_product_code = "DB1LITECSV" # country-only LITE database
|
|
78
|
+
config.ipv6_product_code = "DB1LITECSVIPV6"
|
|
79
|
+
config.cache_store = -> { Rails.cache }
|
|
80
|
+
config.cache_expires_in = 24.hours
|
|
81
|
+
config.import_batch_size = 5_000
|
|
82
|
+
config.download_timeout = 60
|
|
83
|
+
config.queue_name = :hvor
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Database notes
|
|
88
|
+
|
|
89
|
+
`hvor_ip_ranges` stores IPv4 bounds as `bigint` and IPv6 bounds as a
|
|
90
|
+
zero-padded 32-character lowercase hex string (IPv6's 128-bit range doesn't
|
|
91
|
+
fit in a bigint), so lookups work identically on PostgreSQL, MySQL, and
|
|
92
|
+
SQLite. The migration pins a binary-order collation on the IPv6 columns
|
|
93
|
+
(`C` on Postgres, `ascii_bin` on MySQL) so string comparison matches numeric
|
|
94
|
+
ordering regardless of the database's default locale — if you're running
|
|
95
|
+
CI against a database other than SQLite, make sure that migration path gets
|
|
96
|
+
exercised at least once, since SQLite's default collation is already binary
|
|
97
|
+
and won't catch a regression here.
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
|
7
|
+
*
|
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
|
11
|
+
* It is generally better to create a new file per style scope.
|
|
12
|
+
*
|
|
13
|
+
*= require_tree .
|
|
14
|
+
*= require_self
|
|
15
|
+
*/
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Hvor
|
|
2
|
+
class LookupsController < ApplicationController
|
|
3
|
+
def show
|
|
4
|
+
render_lookup(params[:ip])
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def me
|
|
8
|
+
render_lookup(request.remote_ip)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def render_lookup(ip)
|
|
14
|
+
render json: { ip: ip, country_code: Hvor.lookup(ip) }
|
|
15
|
+
rescue Hvor::InvalidIpError => e
|
|
16
|
+
render json: { error: e.message }, status: :bad_request
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Hvor
|
|
2
|
+
class ImportIpRangesJob < ApplicationJob
|
|
3
|
+
queue_as { Hvor.configuration.queue_name }
|
|
4
|
+
|
|
5
|
+
LOCK_KEY = "hvor/import_ip_ranges_job/lock"
|
|
6
|
+
|
|
7
|
+
def perform
|
|
8
|
+
acquired_lock = Hvor.configuration.cache.write(LOCK_KEY, true, unless_exist: true)
|
|
9
|
+
return unless acquired_lock
|
|
10
|
+
|
|
11
|
+
ipv4_zip = Hvor::Downloader.new(Hvor.configuration.ipv4_product_code).call
|
|
12
|
+
ipv6_zip = Hvor::Downloader.new(Hvor.configuration.ipv6_product_code).call
|
|
13
|
+
|
|
14
|
+
Hvor::Importer.new.import(ipv4_zip: ipv4_zip, ipv6_zip: ipv6_zip)
|
|
15
|
+
ensure
|
|
16
|
+
[ ipv4_zip, ipv6_zip ].each { |file| file&.close! }
|
|
17
|
+
Hvor.configuration.cache.delete(LOCK_KEY) if acquired_lock
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Hvor
|
|
2
|
+
class IPRange < ApplicationRecord
|
|
3
|
+
enum :ip_version, { ipv4: 0, ipv6: 1 }
|
|
4
|
+
|
|
5
|
+
def self.lookup_country_code(ip)
|
|
6
|
+
find_containing(ip)&.country_code
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.find_containing(ip)
|
|
10
|
+
normalized = Hvor::IpNormalizer.normalize(ip)
|
|
11
|
+
from_column, to_column = normalized.version == :ipv4 ? %w[ipv4_from ipv4_to] : %w[ipv6_from ipv6_to]
|
|
12
|
+
|
|
13
|
+
range = where(ip_version: normalized.version)
|
|
14
|
+
.where("#{from_column} <= ?", normalized.value)
|
|
15
|
+
.order(from_column => :desc)
|
|
16
|
+
.first
|
|
17
|
+
|
|
18
|
+
range if range && range[to_column] && range[to_column] >= normalized.value
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module Hvor
|
|
6
|
+
class DownloadError < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Downloads an IP2Location LITE CSV zip archive for the given product code
|
|
9
|
+
# (e.g. "DB1LITECSV", "DB1LITECSVIPV6") and returns it as a Tempfile. The
|
|
10
|
+
# caller is responsible for closing/unlinking the Tempfile.
|
|
11
|
+
class Downloader
|
|
12
|
+
MAX_REDIRECTS = 5
|
|
13
|
+
|
|
14
|
+
def initialize(product_code, timeout: Hvor.configuration.download_timeout)
|
|
15
|
+
@product_code = product_code
|
|
16
|
+
@timeout = timeout
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
fetch(download_uri, MAX_REDIRECTS)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader :product_code, :timeout
|
|
26
|
+
|
|
27
|
+
def download_uri
|
|
28
|
+
URI("https://www.ip2location.com/download?token=#{Hvor.configuration.api_token}&file=#{product_code}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def fetch(uri, redirects_left)
|
|
32
|
+
raise DownloadError, "too many redirects downloading #{uri}" if redirects_left < 0
|
|
33
|
+
|
|
34
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
35
|
+
open_timeout: timeout, read_timeout: timeout) do |http|
|
|
36
|
+
http.request(Net::HTTP::Get.new(uri)) do |response|
|
|
37
|
+
case response
|
|
38
|
+
when Net::HTTPRedirection
|
|
39
|
+
return fetch(uri + response["location"], redirects_left - 1)
|
|
40
|
+
when Net::HTTPSuccess
|
|
41
|
+
return stream_to_tempfile(response)
|
|
42
|
+
else
|
|
43
|
+
raise DownloadError, "failed to download #{uri}: #{response.code} #{response.message}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def stream_to_tempfile(response)
|
|
50
|
+
file = Tempfile.new([ product_code, ".zip" ], binmode: true)
|
|
51
|
+
response.read_body { |chunk| file.write(chunk) }
|
|
52
|
+
file.flush
|
|
53
|
+
file.rewind
|
|
54
|
+
file
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "zip"
|
|
2
|
+
require "csv"
|
|
3
|
+
|
|
4
|
+
module Hvor
|
|
5
|
+
class ImportError < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Streams IP2Location LITE CSV rows out of a downloaded zip archive and
|
|
8
|
+
# performs an atomic dump-and-repopulate of Hvor::IPRange.
|
|
9
|
+
#
|
|
10
|
+
# Both files are fully parsed into batches *before* the database is
|
|
11
|
+
# touched, so a failure downloading/parsing the second file never leaves
|
|
12
|
+
# the table half-populated, and the transaction itself holds no network
|
|
13
|
+
# I/O (just delete_all + insert_all).
|
|
14
|
+
class Importer
|
|
15
|
+
def initialize(batch_size: Hvor.configuration.import_batch_size)
|
|
16
|
+
@batch_size = batch_size
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def import(ipv4_zip:, ipv6_zip:)
|
|
20
|
+
timestamp = Time.current
|
|
21
|
+
ipv4_batches = read_batches(ipv4_zip, ip_version: 0, timestamp: timestamp) { |row| ipv4_attributes(row) }
|
|
22
|
+
ipv6_batches = read_batches(ipv6_zip, ip_version: 1, timestamp: timestamp) { |row| ipv6_attributes(row) }
|
|
23
|
+
|
|
24
|
+
Hvor::IPRange.transaction do
|
|
25
|
+
Hvor::IPRange.delete_all
|
|
26
|
+
ipv4_batches.each { |batch| Hvor::IPRange.insert_all(batch) }
|
|
27
|
+
ipv6_batches.each { |batch| Hvor::IPRange.insert_all(batch) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :batch_size
|
|
34
|
+
|
|
35
|
+
def read_batches(zip_file, ip_version:, timestamp:)
|
|
36
|
+
batches = []
|
|
37
|
+
|
|
38
|
+
each_csv_row(zip_file) do |slice|
|
|
39
|
+
batches << slice.map do |row|
|
|
40
|
+
yield(row).merge(ip_version: ip_version, created_at: timestamp, updated_at: timestamp)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
batches
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def each_csv_row(zip_file)
|
|
48
|
+
Zip::File.open(zip_file.path) do |zip|
|
|
49
|
+
entry = zip.entries.find { |e| e.name.downcase.end_with?(".csv") } || zip.entries.first
|
|
50
|
+
raise ImportError, "#{zip_file.path} does not contain a CSV entry" unless entry
|
|
51
|
+
|
|
52
|
+
entry.get_input_stream do |io|
|
|
53
|
+
CSV.new(io, headers: false).each_slice(batch_size) { |slice| yield slice }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ipv4_attributes(row)
|
|
59
|
+
{ ipv4_from: Integer(row[0], 10), ipv4_to: Integer(row[1], 10), country_code: row[2] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def ipv6_attributes(row)
|
|
63
|
+
{ ipv6_from: hex(row[0]), ipv6_to: hex(row[1]), country_code: row[2] }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def hex(decimal_string)
|
|
67
|
+
Integer(decimal_string, 10).to_s(16).rjust(32, "0")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
require "ipaddr"
|
|
2
|
+
|
|
3
|
+
module Hvor
|
|
4
|
+
class InvalidIpError < StandardError; end
|
|
5
|
+
|
|
6
|
+
module IpNormalizer
|
|
7
|
+
Result = Struct.new(:version, :value)
|
|
8
|
+
|
|
9
|
+
IPV6_HEX_WIDTH = 32
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Returns a Result with:
|
|
14
|
+
# version: :ipv4 or :ipv6
|
|
15
|
+
# value: Integer (0..2**32-1) for ipv4, zero-padded 32-char lowercase
|
|
16
|
+
# hex String for ipv6 -- the same representation IPRange uses
|
|
17
|
+
# for its ipv4_from/to and ipv6_from/to columns, so callers can
|
|
18
|
+
# compare/query directly against it.
|
|
19
|
+
def normalize(ip)
|
|
20
|
+
raise InvalidIpError, "ip is blank" if ip.nil? || ip.to_s.strip.empty?
|
|
21
|
+
|
|
22
|
+
address = parse(strip_zone_id(ip.to_s.strip))
|
|
23
|
+
address = address.native if address.ipv4_mapped?
|
|
24
|
+
|
|
25
|
+
if address.ipv4?
|
|
26
|
+
Result.new(:ipv4, address.to_i)
|
|
27
|
+
else
|
|
28
|
+
Result.new(:ipv6, address.to_i.to_s(16).rjust(IPV6_HEX_WIDTH, "0"))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def parse(ip)
|
|
33
|
+
IPAddr.new(ip)
|
|
34
|
+
rescue IPAddr::Error => e
|
|
35
|
+
raise InvalidIpError, "#{ip.inspect} is not a valid IP address (#{e.message})"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A zone ID (e.g. "fe80::1%eth0") is only meaningful for scoping a link-local
|
|
39
|
+
# address to a network interface -- IPAddr#to_i folds it into the integer
|
|
40
|
+
# value in a way that no longer represents the plain 128-bit address, so it
|
|
41
|
+
# must be dropped before converting to our lookup representation.
|
|
42
|
+
def strip_zone_id(ip)
|
|
43
|
+
ip.sub(/%.*\z/, "")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Hvor</title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<%= csp_meta_tag %>
|
|
7
|
+
|
|
8
|
+
<%= yield :head %>
|
|
9
|
+
|
|
10
|
+
<%= stylesheet_link_tag "hvor/application", media: "all" %>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
|
|
14
|
+
<%= yield %>
|
|
15
|
+
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class CreateHvorIpRanges < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :hvor_ip_ranges do |t|
|
|
4
|
+
t.integer :ip_version, null: false
|
|
5
|
+
t.bigint :ipv4_from
|
|
6
|
+
t.bigint :ipv4_to
|
|
7
|
+
t.string :ipv6_from, limit: 32, collation: ipv6_collation
|
|
8
|
+
t.string :ipv6_to, limit: 32, collation: ipv6_collation
|
|
9
|
+
t.string :country_code, limit: 2, null: false
|
|
10
|
+
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :hvor_ip_ranges, [ :ip_version, :ipv4_from ]
|
|
15
|
+
add_index :hvor_ip_ranges, [ :ip_version, :ipv6_from ]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# IPv6 bounds are stored as zero-padded lowercase hex strings so plain
|
|
21
|
+
# string comparison matches numeric ordering. That only holds if the
|
|
22
|
+
# column collates by byte value, so pin a binary-order collation on
|
|
23
|
+
# adapters whose default collation could reorder alphanumerics.
|
|
24
|
+
def ipv6_collation
|
|
25
|
+
case connection.adapter_name.downcase
|
|
26
|
+
when /postg/ then "C"
|
|
27
|
+
when /mysql/ then "ascii_bin"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Hvor
|
|
2
|
+
module Generators
|
|
3
|
+
class InstallGenerator < Rails::Generators::Base
|
|
4
|
+
source_root File.expand_path("templates", __dir__)
|
|
5
|
+
|
|
6
|
+
desc "Creates a Hvor initializer and prints setup instructions."
|
|
7
|
+
|
|
8
|
+
def copy_initializer
|
|
9
|
+
template "initializer.rb", "config/initializers/hvor.rb"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def show_readme
|
|
13
|
+
say ""
|
|
14
|
+
say "Hvor is installed. Next steps:", :green
|
|
15
|
+
say " 1. Mount the engine, e.g. in config/routes.rb:"
|
|
16
|
+
say ' mount Hvor::Engine => "/hvor"'
|
|
17
|
+
say " 2. Install and run its migration:"
|
|
18
|
+
say " bin/rails hvor:install:migrations"
|
|
19
|
+
say " bin/rails db:migrate"
|
|
20
|
+
say " 3. Set an IP2Location LITE token in config/initializers/hvor.rb"
|
|
21
|
+
say " (or the IP2LOCATION_TOKEN env var), then enqueue"
|
|
22
|
+
say " Hvor::ImportIpRangesJob to populate Hvor::IPRange."
|
|
23
|
+
say ""
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Hvor.configure do |config|
|
|
2
|
+
# Token from your IP2Location LITE account: https://lite.ip2location.com/
|
|
3
|
+
config.api_token = Rails.application.credentials.dig(:hvor, :ip2location_token) || ENV["IP2LOCATION_TOKEN"]
|
|
4
|
+
|
|
5
|
+
# LITE product codes for the country-only database (DB1). See
|
|
6
|
+
# https://lite.ip2location.com/database/db1-ip-country for other databases.
|
|
7
|
+
# config.ipv4_product_code = "DB1LITECSV"
|
|
8
|
+
# config.ipv6_product_code = "DB1LITECSVIPV6"
|
|
9
|
+
|
|
10
|
+
# Hvor.lookup(ip) caches results here.
|
|
11
|
+
# config.cache_store = -> { Rails.cache }
|
|
12
|
+
# config.cache_expires_in = 24.hours
|
|
13
|
+
|
|
14
|
+
# config.import_batch_size = 5_000
|
|
15
|
+
# config.download_timeout = 60
|
|
16
|
+
# config.queue_name = :hvor
|
|
17
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Hvor
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :api_token, :ipv4_product_code, :ipv6_product_code,
|
|
4
|
+
:cache_store, :cache_expires_in, :import_batch_size,
|
|
5
|
+
:download_timeout, :queue_name
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@api_token = nil
|
|
9
|
+
@ipv4_product_code = "DB1LITECSV"
|
|
10
|
+
@ipv6_product_code = "DB1LITECSVIPV6"
|
|
11
|
+
@cache_store = -> { Rails.cache }
|
|
12
|
+
@cache_expires_in = 24.hours
|
|
13
|
+
@import_batch_size = 5_000
|
|
14
|
+
@download_timeout = 60
|
|
15
|
+
@queue_name = :hvor
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def cache
|
|
19
|
+
cache_store.call
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/hvor/engine.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Hvor
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace Hvor
|
|
4
|
+
|
|
5
|
+
# "ip_range.rb" would otherwise camelize to "IpRange" -- register the
|
|
6
|
+
# acronym so Zeitwerk resolves it to the IPRange model.
|
|
7
|
+
initializer "hvor.inflections", before: :set_autoload_paths do
|
|
8
|
+
Rails.autoloaders.each do |autoloader|
|
|
9
|
+
autoloader.inflector.inflect("ip_range" => "IPRange")
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/hvor/version.rb
ADDED
data/lib/hvor.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "hvor/version"
|
|
2
|
+
require "hvor/configuration"
|
|
3
|
+
require "hvor/engine"
|
|
4
|
+
|
|
5
|
+
module Hvor
|
|
6
|
+
class << self
|
|
7
|
+
def configuration
|
|
8
|
+
@configuration ||= Configuration.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def configure
|
|
12
|
+
yield configuration
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def lookup(ip)
|
|
16
|
+
configuration.cache.fetch("hvor/lookup/#{ip}", expires_in: configuration.cache_expires_in) do
|
|
17
|
+
Hvor::IPRange.lookup_country_code(ip)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: hvor
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- RobL
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 8.0.4
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 8.0.4
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rubyzip
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.3'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.3'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: csv
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rspec-rails
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '7.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '7.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: webmock
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.19'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '3.19'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: sqlite3
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '2.0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '2.0'
|
|
96
|
+
description: Hvor is a mountable Rails engine providing an ActiveJob that downloads
|
|
97
|
+
and imports IP2Location LITE CSV zip archives (IPv4 + IPv6 ranges mapped to country_code)
|
|
98
|
+
into an IPRange model, a cached Hvor.lookup(ip) API, and a lightweight mounted JSON
|
|
99
|
+
API for looking up an arbitrary IP or the requesting client's own IP.
|
|
100
|
+
email:
|
|
101
|
+
- contact@robl.me
|
|
102
|
+
executables: []
|
|
103
|
+
extensions: []
|
|
104
|
+
extra_rdoc_files: []
|
|
105
|
+
files:
|
|
106
|
+
- MIT-LICENSE
|
|
107
|
+
- README.md
|
|
108
|
+
- Rakefile
|
|
109
|
+
- app/assets/stylesheets/hvor/application.css
|
|
110
|
+
- app/controllers/hvor/application_controller.rb
|
|
111
|
+
- app/controllers/hvor/lookups_controller.rb
|
|
112
|
+
- app/helpers/hvor/application_helper.rb
|
|
113
|
+
- app/jobs/hvor/application_job.rb
|
|
114
|
+
- app/jobs/hvor/import_ip_ranges_job.rb
|
|
115
|
+
- app/mailers/hvor/application_mailer.rb
|
|
116
|
+
- app/models/hvor/application_record.rb
|
|
117
|
+
- app/models/hvor/ip_range.rb
|
|
118
|
+
- app/services/hvor/downloader.rb
|
|
119
|
+
- app/services/hvor/importer.rb
|
|
120
|
+
- app/services/hvor/ip_normalizer.rb
|
|
121
|
+
- app/views/layouts/hvor/application.html.erb
|
|
122
|
+
- config/routes.rb
|
|
123
|
+
- db/migrate/20260727225339_create_hvor_ip_ranges.rb
|
|
124
|
+
- lib/generators/hvor/install/install_generator.rb
|
|
125
|
+
- lib/generators/hvor/install/templates/initializer.rb
|
|
126
|
+
- lib/hvor.rb
|
|
127
|
+
- lib/hvor/configuration.rb
|
|
128
|
+
- lib/hvor/engine.rb
|
|
129
|
+
- lib/hvor/version.rb
|
|
130
|
+
- lib/tasks/hvor_tasks.rake
|
|
131
|
+
homepage: https://github.com/braindeaf/hvor
|
|
132
|
+
licenses:
|
|
133
|
+
- MIT
|
|
134
|
+
metadata:
|
|
135
|
+
homepage_uri: https://github.com/braindeaf/hvor
|
|
136
|
+
source_code_uri: https://github.com/braindeaf/hvor
|
|
137
|
+
changelog_uri: https://github.com/braindeaf/hvor/blob/main/CHANGELOG.md
|
|
138
|
+
rdoc_options: []
|
|
139
|
+
require_paths:
|
|
140
|
+
- lib
|
|
141
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
142
|
+
requirements:
|
|
143
|
+
- - ">="
|
|
144
|
+
- !ruby/object:Gem::Version
|
|
145
|
+
version: '3.2'
|
|
146
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
147
|
+
requirements:
|
|
148
|
+
- - ">="
|
|
149
|
+
- !ruby/object:Gem::Version
|
|
150
|
+
version: '0'
|
|
151
|
+
requirements: []
|
|
152
|
+
rubygems_version: 4.0.6
|
|
153
|
+
specification_version: 4
|
|
154
|
+
summary: Rails engine that imports IP2Location LITE CSV data and answers IP -> country_code
|
|
155
|
+
lookups.
|
|
156
|
+
test_files: []
|