diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4691cac --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: Tests + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + run: + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ["3.2", "3.3", "3.4"] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Ruby ${{ matrix.ruby-version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + + - name: Install dependencies + run: bundle install + + - name: Run tests + run: bundle exec rake diff --git a/.ruby-version b/.ruby-version index 37c2961..be94e6f 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.7.2 +3.2.2 diff --git a/Gemfile b/Gemfile index c6e73e0..89f216c 100644 --- a/Gemfile +++ b/Gemfile @@ -8,5 +8,6 @@ group :development do gem 'bundler' gem 'minitest' gem 'minitest-reporters' + gem 'rake' gem 'rubocop' end diff --git a/README.md b/README.md index 96db530..81f7a71 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ You'll need an IPinfo API access token, which you can get by signing up for a fr The free plan is limited to 50,000 requests per month, and doesn't include some of the data fields such as IP type and company data. To enable all the data fields and additional request volumes see [https://ipinfo.io/pricing](https://ipinfo.io/pricing) -⚠️ Note: This library does not currently support our newest free API https://ipinfo.io/lite. If you’d like to use IPinfo Lite, you can call the [endpoint directly](https://ipinfo.io/developers/lite-api) using your preferred HTTP client. Developers are also welcome to contribute support for Lite by submitting a pull request. +The library also supports the Lite API, see the [Lite API section](#lite-api) for more info. ### Installation @@ -246,6 +246,19 @@ config.middleware.use(IPinfoMiddleware, { This simple lambda function will filter out requests coming from your local computer. +## Lite API + +The library gives the possibility to use the [Lite API](https://ipinfo.io/developers/lite-api) too, authentication with your token is still required. + +The returned details are slightly different from the Core API, but it has the same configurations options. + +You can use it like so: + +```ruby +require 'ipinfo-rails' +config.middleware.use(IPinfoLiteMiddleware, {token: ""}) +``` + ## Other Libraries There are official IPinfo client libraries available for many languages including PHP, Go, Java, Ruby, and many popular frameworks such as Django, Rails, and Laravel. There are also many third-party libraries and integrations available for our API. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..4789edb --- /dev/null +++ b/Rakefile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'bundler/gem_tasks' +require 'rake/testtask' + +Rake::TestTask.new(:test) do |t| + t.libs << 'test' + t.libs << 'lib' + t.test_files = FileList['test/**/*_test.rb'] +end + +task default: :test diff --git a/ipinfo-rails.gemspec b/ipinfo-rails.gemspec index 2b7897e..f74f927 100644 --- a/ipinfo-rails.gemspec +++ b/ipinfo-rails.gemspec @@ -9,7 +9,6 @@ Gem::Specification.new do |s| s.name = 'ipinfo-rails' s.version = IPinfoRails::VERSION s.required_ruby_version = '>= 2.5.0' - s.date = '2018-12-10' s.summary = 'The official Rails gem for IPinfo. IPinfo prides itself on ' \ 'being the most reliable, accurate, and in-depth source of ' \ 'IP address data available anywhere. We process terabytes ' \ @@ -22,11 +21,16 @@ Gem::Specification.new do |s| s.homepage = 'https://ipinfo.io' s.license = 'Apache-2.0' - s.add_runtime_dependency 'IPinfo', '~> 1.0.1' - s.add_runtime_dependency 'rack', '~> 2.0' + s.add_dependency 'IPinfo', '~> 2.3' + s.add_dependency 'rack', '~> 2.0' + + s.add_development_dependency 'mocha', '~> 2.7' s.files = `git ls-files -z`.split("\x0").reject do |f| f.match(%r{^(test|spec|features)/}) end s.require_paths = ['lib'] + s.metadata = { + 'rubygems_mfa_required' => 'true' + } end diff --git a/lib/ipinfo-rails.rb b/lib/ipinfo-rails.rb index 9c73c59..ecb5cbd 100644 --- a/lib/ipinfo-rails.rb +++ b/lib/ipinfo-rails.rb @@ -2,8 +2,18 @@ require 'rack' require 'ipinfo' +require 'ipinfo_lite' require 'ipinfo-rails/ip_selector/default_ip_selector' +def is_bot(request) + if request.user_agent + user_agent = request.user_agent.downcase + user_agent.include?('bot') || user_agent.include?('spider') + else + false + end +end + class IPinfoMiddleware def initialize(app, options = {}) @app = app @@ -32,15 +42,34 @@ def call(env) @app.call(env) end +end - private +class IPinfoLiteMiddleware + def initialize(app, options = {}) + @app = app + @token = options.fetch(:token, nil) + @ipinfo = IPinfoLite.create(@token, options) + @filter = options.fetch(:filter, nil) + @ip_selector = options.fetch(:ip_selector, DefaultIPSelector) + end - def is_bot(request) - if request.user_agent - user_agent = request.user_agent.downcase - user_agent.include?('bot') || user_agent.include?('spider') - else - false - end + def call(env) + env['called'] = 'yes' + request = Rack::Request.new(env) + ip_selector = @ip_selector.new(request) + filtered = if @filter.nil? + is_bot(request) + else + @filter.call(request) + end + + if filtered + env['ipinfo'] = nil + else + ip = ip_selector.get_ip + env['ipinfo'] = @ipinfo.details(ip) end + + @app.call(env) + end end diff --git a/test/ipinfo_lite_middleware_test.rb b/test/ipinfo_lite_middleware_test.rb new file mode 100644 index 0000000..2a146b0 --- /dev/null +++ b/test/ipinfo_lite_middleware_test.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require 'minitest/mock' +require 'mocha/minitest' +require 'rack/mock' +require 'ostruct' +require 'ipinfo_lite' +require 'ipinfo/errors' +require_relative '../lib/ipinfo-rails' + + +# Simple Rack app +class TestApp + attr_reader :last_env + + def call(env) + @last_env = env + [200, { 'Content-Type' => 'text/plain' }, ['Hello from TestApp!']] + end +end + +class IPinfoLiteMiddlewareTest < Minitest::Test + def setup + @app = TestApp.new + @middleware = nil + @mock_ipinfo_client = mock('IPinfoClient') + IPinfoLite.stubs(:create).returns(@mock_ipinfo_client) + + @mock_details = OpenStruct.new( + ip: '1.2.3.4', + city: 'New York', + country: 'US', + hostname: 'example.com', + org: 'Example Org' + ) + end + + # Custom IP Selector + class CustomIPSelector + def initialize(request) + @request = request + end + + def get_ip + '9.10.11.12' + end + end + + def test_should_use_default_ip_selector_when_no_custom_selector_is_provided + @mock_ipinfo_client.expects(:details).with('1.2.3.4').returns(@mock_details) + + @middleware = IPinfoLiteMiddleware.new(@app, token: 'test_token') + request = Rack::MockRequest.new(@middleware) + + # Simulate a request with REMOTE_ADDR + env = { 'REMOTE_ADDR' => '1.2.3.4' } + response = request.get('/', env) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_equal '1.2.3.4', @app.last_env['ipinfo'].ip + assert_equal 'New York', @app.last_env['ipinfo'].city + end + + def test_should_use_custom_ip_selector_when_provided + @mock_ipinfo_client.expects(:details).with('9.10.11.12') + .returns(@mock_details.dup.tap { |d| d.ip = '9.10.11.12' }) + + @middleware = IPinfoLiteMiddleware.new(@app, + token: 'test_token', + ip_selector: CustomIPSelector) + request = Rack::MockRequest.new(@middleware) + + response = request.get('/', {}) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_equal '9.10.11.12', @app.last_env['ipinfo'].ip + end + + def test_middleware_skips_processing_if_filter_returns_true + always_filter = ->(_request) { true } + + @middleware = IPinfoLiteMiddleware.new(@app, + token: 'test_token', + filter: always_filter) + request = Rack::MockRequest.new(@middleware) + + @mock_ipinfo_client.expects(:details).never + + response = request.get('/', { 'REMOTE_ADDR' => '8.8.8.8' }) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_nil @app.last_env['ipinfo'], + 'ipinfo should be nil when filtered' + end + + def test_middleware_processes_if_filter_returns_false + never_filter = ->(_request) { false } + @mock_ipinfo_client.expects(:details).with('1.2.3.4').returns(@mock_details) + + @middleware = IPinfoLiteMiddleware.new(@app, + token: 'test_token', + filter: never_filter) + request = Rack::MockRequest.new(@middleware) + + response = request.get('/', { 'REMOTE_ADDR' => '1.2.3.4' }) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_equal '1.2.3.4', @app.last_env['ipinfo'].ip + end + + def test_middleware_filters_bots_by_default + @mock_ipinfo_client.expects(:details).never # Should not call if bot + + @middleware = IPinfoLiteMiddleware.new(@app, token: 'test_token') + request = Rack::MockRequest.new(@middleware) + + # Test with common bot user agents + bot_env = { 'HTTP_USER_AGENT' => 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' } + response = request.get('/', bot_env) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_nil @app.last_env['ipinfo'], + 'ipinfo should be nil for bot user agent' + + spider_env = { 'HTTP_USER_AGENT' => 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)' } + response = request.get('/', spider_env) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_nil @app.last_env['ipinfo'], + 'ipinfo should be nil for spider user agent' + end + + def test_middleware_does_not_filter_non_bots_by_default + @mock_ipinfo_client.expects(:details).with('1.2.3.4').returns(@mock_details) + + @middleware = IPinfoLiteMiddleware.new(@app, token: 'test_token') + request = Rack::MockRequest.new(@middleware) + + # Test with a regular user agent + user_env = { 'REMOTE_ADDR' => '1.2.3.4', 'HTTP_USER_AGENT' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' } + response = request.get('/', user_env) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_equal '1.2.3.4', @app.last_env['ipinfo'].ip + end + + def test_middleware_handles_missing_user_agent + @mock_ipinfo_client.expects(:details).with('1.2.3.4').returns(@mock_details) + + @middleware = IPinfoLiteMiddleware.new(@app, token: 'test_token') + request = Rack::MockRequest.new(@middleware) + + # Test with no user agent provided + no_ua_env = { 'REMOTE_ADDR' => '1.2.3.4' } + response = request.get('/', no_ua_env) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_equal '1.2.3.4', @app.last_env['ipinfo'].ip + end + + def test_middleware_handles_ipinfo_api_errors + @mock_ipinfo_client.expects(:details).raises(StandardError, + 'API rate limit exceeded') + + @middleware = IPinfoLiteMiddleware.new(@app, token: 'test_token') + request = Rack::MockRequest.new(@middleware) + + assert_raises StandardError do + request.get('/', { 'REMOTE_ADDR' => '1.2.3.4' }) + end + end +end diff --git a/test/ipinfo_middleware_test.rb b/test/ipinfo_middleware_test.rb new file mode 100644 index 0000000..bd9b7a5 --- /dev/null +++ b/test/ipinfo_middleware_test.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require 'minitest/mock' +require 'mocha/minitest' +require 'rack/mock' +require 'ostruct' +require 'ipinfo' +require 'ipinfo/errors' +require_relative '../lib/ipinfo-rails' + + +# Simple Rack app +class TestApp + attr_reader :last_env + + def call(env) + @last_env = env + [200, { 'Content-Type' => 'text/plain' }, ['Hello from TestApp!']] + end +end + +class IPinfoMiddlewareTest < Minitest::Test + def setup + @app = TestApp.new + @middleware = nil + @mock_ipinfo_client = mock('IPinfoClient') + IPinfo.stubs(:create).returns(@mock_ipinfo_client) + + @mock_details = OpenStruct.new( + ip: '1.2.3.4', + city: 'New York', + country: 'US', + hostname: 'example.com', + org: 'Example Org' + ) + end + + # Custom IP Selector + class CustomIPSelector + def initialize(request) + @request = request + end + + def get_ip + '9.10.11.12' + end + end + + def test_should_use_default_ip_selector_when_no_custom_selector_is_provided + @mock_ipinfo_client.expects(:details).with('1.2.3.4').returns(@mock_details) + + @middleware = IPinfoMiddleware.new(@app, token: 'test_token') + request = Rack::MockRequest.new(@middleware) + + # Simulate a request with REMOTE_ADDR + env = { 'REMOTE_ADDR' => '1.2.3.4' } + response = request.get('/', env) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_equal '1.2.3.4', @app.last_env['ipinfo'].ip + assert_equal 'New York', @app.last_env['ipinfo'].city + end + + def test_should_use_custom_ip_selector_when_provided + @mock_ipinfo_client.expects(:details).with('9.10.11.12') + .returns(@mock_details.dup.tap { |d| d.ip = '9.10.11.12' }) + + @middleware = IPinfoMiddleware.new(@app, + token: 'test_token', + ip_selector: CustomIPSelector) + request = Rack::MockRequest.new(@middleware) + + response = request.get('/', {}) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_equal '9.10.11.12', @app.last_env['ipinfo'].ip + end + + def test_middleware_skips_processing_if_filter_returns_true + always_filter = ->(_request) { true } + + @middleware = IPinfoMiddleware.new(@app, + token: 'test_token', + filter: always_filter) + request = Rack::MockRequest.new(@middleware) + + @mock_ipinfo_client.expects(:details).never + + response = request.get('/', { 'REMOTE_ADDR' => '8.8.8.8' }) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_nil @app.last_env['ipinfo'], + 'ipinfo should be nil when filtered' + end + + def test_middleware_processes_if_filter_returns_false + never_filter = ->(_request) { false } + @mock_ipinfo_client.expects(:details).with('1.2.3.4').returns(@mock_details) + + @middleware = IPinfoMiddleware.new(@app, + token: 'test_token', + filter: never_filter) + request = Rack::MockRequest.new(@middleware) + + response = request.get('/', { 'REMOTE_ADDR' => '1.2.3.4' }) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_equal '1.2.3.4', @app.last_env['ipinfo'].ip + end + + def test_middleware_filters_bots_by_default + @mock_ipinfo_client.expects(:details).never # Should not call if bot + + @middleware = IPinfoMiddleware.new(@app, token: 'test_token') + request = Rack::MockRequest.new(@middleware) + + # Test with common bot user agents + bot_env = { 'HTTP_USER_AGENT' => 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' } + response = request.get('/', bot_env) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_nil @app.last_env['ipinfo'], + 'ipinfo should be nil for bot user agent' + + spider_env = { 'HTTP_USER_AGENT' => 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)' } + response = request.get('/', spider_env) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_nil @app.last_env['ipinfo'], + 'ipinfo should be nil for spider user agent' + end + + def test_middleware_does_not_filter_non_bots_by_default + @mock_ipinfo_client.expects(:details).with('1.2.3.4').returns(@mock_details) + + @middleware = IPinfoMiddleware.new(@app, token: 'test_token') + request = Rack::MockRequest.new(@middleware) + + # Test with a regular user agent + user_env = { 'REMOTE_ADDR' => '1.2.3.4', 'HTTP_USER_AGENT' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' } + response = request.get('/', user_env) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_equal '1.2.3.4', @app.last_env['ipinfo'].ip + end + + def test_middleware_handles_missing_user_agent + @mock_ipinfo_client.expects(:details).with('1.2.3.4').returns(@mock_details) + + @middleware = IPinfoMiddleware.new(@app, token: 'test_token') + request = Rack::MockRequest.new(@middleware) + + # Test with no user agent provided + no_ua_env = { 'REMOTE_ADDR' => '1.2.3.4' } + response = request.get('/', no_ua_env) + + assert_equal 200, response.status + assert_equal 'yes', @app.last_env['called'] + assert_equal '1.2.3.4', @app.last_env['ipinfo'].ip + end + + def test_middleware_handles_ipinfo_api_errors + @mock_ipinfo_client.expects(:details).raises(StandardError, + 'API rate limit exceeded') + + @middleware = IPinfoMiddleware.new(@app, token: 'test_token') + request = Rack::MockRequest.new(@middleware) + + assert_raises StandardError do + request.get('/', { 'REMOTE_ADDR' => '1.2.3.4' }) + end + end +end