# frozen_string_literal: true require 'maxmind/db' require 'httparty' require 'digest' require 'tmpdir' require 'fileutils' class GeoIpService include HTTParty class DatabaseNotAvailable < StandardError; end class InvalidIpAddress < StandardError; end attr_reader :database_reader, :database_path def initialize(database_path: nil) @database_path = database_path || default_database_path @database_reader = nil load_database if File.exist?(@database_path) end # Main lookup method - returns country code for IP address def lookup_country(ip_address) return fallback_country unless database_available? return fallback_country unless valid_ip?(ip_address) result = database_reader.get(ip_address) return fallback_country if result.nil? || result.empty? # Extract country code from MaxMind result result['country']&.[]('iso_code') || fallback_country rescue => e Rails.logger.error "GeoIP lookup failed for #{ip_address}: #{e.message}" fallback_country end # Check if database is available and loaded def database_available? return false unless File.exist?(@database_path) load_database unless database_reader database_reader.present? end # Get database information def database_info return nil unless File.exist?(@database_path) file_stat = File.stat(@database_path) metadata = database_reader&.metadata if metadata { type: 'GeoLite2-Country', version: "#{metadata.binary_format_major_version}.#{metadata.binary_format_minor_version}", size: file_stat.size, modified_at: file_stat.mtime, age_days: ((Time.current - file_stat.mtime) / 1.day).round, file_path: @database_path } else { type: 'GeoLite2-Country', version: 'Unknown', size: file_stat.size, modified_at: file_stat.mtime, age_days: ((Time.current - file_stat.mtime) / 1.day).round, file_path: @database_path } end end # Class method for convenience lookup def self.lookup_country(ip_address) new.lookup_country(ip_address) end # Update database from remote source def self.update_database! new.update_from_remote! end # Download and install database from remote URL def update_from_remote! config = Rails.application.config.maxmind database_url = config.database_url storage_path = config.storage_path database_filename = config.database_filename temp_file = nil Rails.logger.info "Starting GeoIP database download from #{database_url}" begin # Ensure storage directory exists FileUtils.mkdir_p(storage_path) unless Dir.exist?(storage_path) # Download to temporary file Dir.mktmpdir do |temp_dir| temp_file = File.join(temp_dir, database_filename) response = HTTParty.get(database_url, timeout: 60) raise "Failed to download database: #{response.code}" unless response.success? File.binwrite(temp_file, response.body) # Validate downloaded file validate_downloaded_file(temp_file) # Move to final location final_path = File.join(storage_path, database_filename) File.rename(temp_file, final_path) # Reload the database with new file @database_reader = nil load_database Rails.logger.info "GeoIP database successfully updated: #{final_path}" return true end rescue => e Rails.logger.error "Failed to update GeoIP database: #{e.message}" File.delete(temp_file) if temp_file && File.exist?(temp_file) false end end private def load_database return unless File.exist?(@database_path) @database_reader = MaxMind::DB.new(@database_path) rescue => e Rails.logger.error "Failed to load GeoIP database: #{e.message}" @database_reader = nil end def default_database_path config = Rails.application.config.maxmind File.join(config.storage_path, config.database_filename) end def valid_ip?(ip_address) IPAddr.new(ip_address) true rescue IPAddr::InvalidAddressError false end def fallback_country config = Rails.application.config.maxmind return nil unless config.enable_fallback config.fallback_country end def cache_size return 0 unless Rails.application.config.maxmind.cache_enabled Rails.application.config.maxmind.cache_size end def validate_downloaded_file(file_path) # Basic file existence and size check raise "Downloaded file is empty" unless File.exist?(file_path) raise "Downloaded file is too small" if File.size(file_path) < 1_000_000 # ~1MB minimum # Try to open with MaxMind reader begin MaxMind::DB.new(file_path) rescue => e raise "Invalid MaxMind database format: #{e.message}" end end end