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 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,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -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,4 @@
1
+ module Hvor
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -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,4 @@
1
+ module Hvor
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Hvor
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ 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,6 @@
1
+ module Hvor
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module Hvor
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ 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,4 @@
1
+ Hvor::Engine.routes.draw do
2
+ get "lookup", to: "lookups#show"
3
+ get "lookup/me", to: "lookups#me"
4
+ end
@@ -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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Hvor
2
+ VERSION = "0.1.1"
3
+ end
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
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :hvor do
3
+ # # Task goes here
4
+ # 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: []