feat: IP lookup (#1315)

- feature to store contact IP for accounts
- IP lookup through geocoder gem
- ability to do IP lookup through external APIs
- add commit hook to prevent push to develop and master
- migrations to fix default values for jsonb columns
This commit is contained in:
Sojan Jose 2020-10-28 02:14:36 +05:30 committed by GitHub
parent ff96d43953
commit 1d9debaee0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 163 additions and 9 deletions

View file

@ -117,6 +117,17 @@ IOS_APP_ID=6C953F3RX2.com.chatwoot.app
## Bot Customizations ## Bot Customizations
USE_INBOX_AVATAR_FOR_BOT=true USE_INBOX_AVATAR_FOR_BOT=true
## IP look up configuration
## ref https://github.com/alexreisner/geocoder/blob/master/README_API_GUIDE.md
## works only on accounts with ip look up feature enabled
# IP_LOOKUP_SERVICE=geoip2
# maxmindb api key to use geoip2 service
# IP_LOOKUP_API_KEY=
## Development Only Config ## Development Only Config
# if you want to use letter_opener for local emails # if you want to use letter_opener for local emails
# LETTER_OPENER=true # LETTER_OPENER=true

1
.gitignore vendored
View file

@ -16,6 +16,7 @@
/tmp/* /tmp/*
!/log/.keep !/log/.keep
!/tmp/.keep !/tmp/.keep
*.mmdb
# Ignore Byebug command history file. # Ignore Byebug command history file.
.byebug_history .byebug_history

View file

@ -90,6 +90,12 @@ gem 'sidekiq'
gem 'fcm' gem 'fcm'
gem 'webpush' gem 'webpush'
##-- geocoding / parse location from ip --##
# http://www.rubygeocoder.com/
gem 'geocoder'
# to parse maxmind db
gem 'maxminddb'
group :development do group :development do
gem 'annotate' gem 'annotate'
gem 'bullet' gem 'bullet'

View file

@ -201,6 +201,7 @@ GEM
ffi (1.13.1) ffi (1.13.1)
flag_shih_tzu (0.3.23) flag_shih_tzu (0.3.23)
foreman (0.87.2) foreman (0.87.2)
geocoder (1.6.3)
gli (2.19.2) gli (2.19.2)
globalid (0.4.2) globalid (0.4.2)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
@ -290,6 +291,7 @@ GEM
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
marcel (0.3.3) marcel (0.3.3)
mimemagic (~> 0.3.2) mimemagic (~> 0.3.2)
maxminddb (0.1.22)
memoist (0.16.2) memoist (0.16.2)
method_source (1.0.0) method_source (1.0.0)
mime-types (3.3.1) mime-types (3.3.1)
@ -578,6 +580,7 @@ DEPENDENCIES
fcm fcm
flag_shih_tzu flag_shih_tzu
foreman foreman
geocoder
google-cloud-storage google-cloud-storage
groupdate groupdate
haikunator haikunator
@ -590,6 +593,7 @@ DEPENDENCIES
letter_opener letter_opener
liquid liquid
listen listen
maxminddb
mini_magick mini_magick
mock_redis! mock_redis!
pg pg

View file

@ -19,13 +19,16 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@contact = Current.account.contacts.new(contact_params) @contact = Current.account.contacts.new(contact_params)
set_ip
@contact.save! @contact.save!
@contact_inbox = build_contact_inbox @contact_inbox = build_contact_inbox
end end
end end
def update def update
@contact.update!(contact_update_params) @contact.assign_attributes(contact_update_params)
set_ip
@contact.save!
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
render json: { render json: {
message: e.record.errors.full_messages.join(', '), message: e.record.errors.full_messages.join(', '),
@ -67,4 +70,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def fetch_contact def fetch_contact
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
end end
def set_ip
return if @contact.account.feature_enabled?('ip_lookup')
@contact[:additional_attributes][:created_at_ip] ||= request.remote_ip
@contact[:additional_attributes][:updated_at_ip] = request.remote_ip
end
end end

View file

@ -0,0 +1,57 @@
require 'rubygems/package'
class ContactIpLookupJob < ApplicationJob
queue_as :default
def perform(contact)
return unless ensure_look_up_service
update_contact_location_from_ip(contact)
rescue Errno::ETIMEDOUT => e
Rails.logger.info "Exception: ip resolution failed : #{e.message}"
end
private
def ensure_look_up_service
return if ENV['IP_LOOKUP_SERVICE'].blank? || ENV['IP_LOOKUP_API_KEY'].blank?
return true if ENV['IP_LOOKUP_SERVICE'].to_sym != :geoip2
ensure_look_up_db
end
def update_contact_location_from_ip(contact)
ip = get_contact_ip(contact)
return if ip.blank?
contact.additional_attributes['city'] = Geocoder.search(ip).first.city
contact.additional_attributes['country'] = Geocoder.search(ip).first.country
contact.save!
end
def get_contact_ip(contact)
contact.additional_attributes['updated_at_ip'] || contact.additional_attributes['created_at_ip']
end
def ensure_look_up_db
return true if File.exist?(GeocoderConfiguration::LOOK_UP_DB)
setup_vendor_db
end
def setup_vendor_db
base_url = 'https://download.maxmind.com/app/geoip_download'
source = URI.open("#{base_url}?edition_id=GeoLite2-City&suffix=tar.gz&license_key=#{ENV['IP_LOOKUP_API_KEY']}")
tar_extract = Gem::Package::TarReader.new(Zlib::GzipReader.open(source))
tar_extract.rewind
tar_extract.each do |entry|
next unless entry.full_name.include?('GeoLite2-City.mmdb') && entry.file?
File.open GeocoderConfiguration::LOOK_UP_DB, 'wb' do |f|
f.print entry.read
end
return true
end
end
end

View file

@ -40,6 +40,7 @@ class Contact < ApplicationRecord
before_validation :prepare_email_attribute before_validation :prepare_email_attribute
after_create_commit :dispatch_create_event after_create_commit :dispatch_create_event
after_update_commit :dispatch_update_event after_update_commit :dispatch_update_event
after_commit :ip_lookup
def get_source_id(inbox_id) def get_source_id(inbox_id)
contact_inboxes.find_by!(inbox_id: inbox_id).source_id contact_inboxes.find_by!(inbox_id: inbox_id).source_id
@ -68,6 +69,12 @@ class Contact < ApplicationRecord
} }
end end
def ip_lookup
return unless account.feature_enabled?('ip_lookup')
ContactIpLookupJob.perform_later(self)
end
def prepare_email_attribute def prepare_email_attribute
# So that the db unique constraint won't throw error when email is '' # So that the db unique constraint won't throw error when email is ''
self.email = nil if email.blank? self.email = nil if email.blank?

13
bin/validate_push Normal file
View file

@ -0,0 +1,13 @@
#!/bin/sh
# script prevents pushing to develop and master by mistake
branch="$(git rev-parse --abbrev-ref HEAD)"
if [ "$branch" = "master" ]; then
echo "You can't push directly to master branch"
exit 1
elif [ "$branch" = "develop" ]; then
echo "You can't push directly to develop branch"
exit 1
fi

View file

@ -7,3 +7,5 @@
enabled: true enabled: true
- name: channel_twitter - name: channel_twitter
enabled: true enabled: true
- name: ip_lookup
enabled: false

View file

@ -0,0 +1,32 @@
# Geocoding options
# timeout: 3, # geocoding service timeout (secs)
# lookup: :nominatim, # name of geocoding service (symbol)
# ip_lookup: :ipinfo_io, # name of IP address geocoding service (symbol)
# language: :en, # ISO-639 language code
# use_https: false, # use HTTPS for lookup requests? (if supported)
# http_proxy: nil, # HTTP proxy server (user:pass@host:port)
# https_proxy: nil, # HTTPS proxy server (user:pass@host:port)
# api_key: nil, # API key for geocoding service
# cache: nil, # cache object (must respond to #[], #[]=, and #del)
# cache_prefix: 'geocoder:', # prefix (string) to use for all cache keys
# Exceptions that should not be rescued by default
# (if you want to implement custom error handling);
# supports SocketError and Timeout::Error
# always_raise: [],
# Calculation options
# units: :mi, # :km for kilometers or :mi for miles
# distances: :linear # :spherical or :linear
module GeocoderConfiguration
LOOK_UP_DB = Rails.root.join('vendor/db/GeoLiteCity.mmdb')
end
if ENV['IP_LOOKUP_SERVICE'].present?
if ENV['IP_LOOKUP_SERVICE'] == 'geoip2'
Geocoder.configure(ip_lookup: :geoip2, geoip2: { file: GeocoderConfiguration::LOOK_UP_DB })
else
Geocoder.configure(ip_lookup: ENV['IP_LOOKUP_SERVICE'].to_sym, api_key: ENV['IP_LOOKUP_API_KEY'])
end
end

View file

@ -0,0 +1,8 @@
class AddDefaultValueToJsonbColums < ActiveRecord::Migration[6.0]
def change
change_column_default :contacts, :additional_attributes, from: nil, to: {}
change_column_default :conversations, :additional_attributes, from: nil, to: {}
change_column_default :installation_configs, :serialized_value, from: '{}', to: {}
change_column_default :notification_subscriptions, :subscription_attributes, from: '{}', to: {}
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_10_11_152227) do ActiveRecord::Schema.define(version: 2020_10_19_173944) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
@ -203,7 +203,7 @@ ActiveRecord::Schema.define(version: 2020_10_11_152227) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "pubsub_token" t.string "pubsub_token"
t.jsonb "additional_attributes" t.jsonb "additional_attributes", default: {}
t.string "identifier" t.string "identifier"
t.jsonb "custom_attributes", default: {} t.jsonb "custom_attributes", default: {}
t.index ["account_id"], name: "index_contacts_on_account_id" t.index ["account_id"], name: "index_contacts_on_account_id"
@ -224,7 +224,7 @@ ActiveRecord::Schema.define(version: 2020_10_11_152227) do
t.datetime "contact_last_seen_at" t.datetime "contact_last_seen_at"
t.datetime "agent_last_seen_at" t.datetime "agent_last_seen_at"
t.boolean "locked", default: false t.boolean "locked", default: false
t.jsonb "additional_attributes" t.jsonb "additional_attributes", default: {}
t.bigint "contact_inbox_id" t.bigint "contact_inbox_id"
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
t.string "identifier" t.string "identifier"
@ -285,7 +285,7 @@ ActiveRecord::Schema.define(version: 2020_10_11_152227) do
create_table "installation_configs", force: :cascade do |t| create_table "installation_configs", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.jsonb "serialized_value", default: "{}", null: false t.jsonb "serialized_value", default: {}, null: false
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.index ["name", "created_at"], name: "index_installation_configs_on_name_and_created_at", unique: true t.index ["name", "created_at"], name: "index_installation_configs_on_name_and_created_at", unique: true
@ -399,7 +399,7 @@ ActiveRecord::Schema.define(version: 2020_10_11_152227) do
create_table "notification_subscriptions", force: :cascade do |t| create_table "notification_subscriptions", force: :cascade do |t|
t.bigint "user_id", null: false t.bigint "user_id", null: false
t.integer "subscription_type", null: false t.integer "subscription_type", null: false
t.jsonb "subscription_attributes", default: "{}", null: false t.jsonb "subscription_attributes", default: {}, null: false
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.string "identifier" t.string "identifier"
@ -452,9 +452,11 @@ ActiveRecord::Schema.define(version: 2020_10_11_152227) do
t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context" t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context"
t.index ["taggable_id", "taggable_type", "tagger_id", "context"], name: "taggings_idy" t.index ["taggable_id", "taggable_type", "tagger_id", "context"], name: "taggings_idy"
t.index ["taggable_id"], name: "index_taggings_on_taggable_id" t.index ["taggable_id"], name: "index_taggings_on_taggable_id"
t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable_type_and_taggable_id"
t.index ["taggable_type"], name: "index_taggings_on_taggable_type" t.index ["taggable_type"], name: "index_taggings_on_taggable_type"
t.index ["tagger_id", "tagger_type"], name: "index_taggings_on_tagger_id_and_tagger_type" t.index ["tagger_id", "tagger_type"], name: "index_taggings_on_tagger_id_and_tagger_type"
t.index ["tagger_id"], name: "index_taggings_on_tagger_id" t.index ["tagger_id"], name: "index_taggings_on_tagger_id"
t.index ["tagger_type", "tagger_id"], name: "index_taggings_on_tagger_type_and_tagger_id"
end end
create_table "tags", id: :serial, force: :cascade do |t| create_table "tags", id: :serial, force: :cascade do |t|

View file

@ -90,7 +90,8 @@
}, },
"husky": { "husky": {
"hooks": { "hooks": {
"pre-commit": "lint-staged" "pre-commit": "lint-staged",
"pre-push": "sh bin/validate_push"
} }
}, },
"jest": { "jest": {

View file

@ -377,7 +377,7 @@ RSpec.describe Conversation, type: :model do
let(:conversation) { create(:conversation) } let(:conversation) { create(:conversation) }
let(:expected_data) do let(:expected_data) do
{ {
additional_attributes: nil, additional_attributes: {},
meta: { meta: {
sender: conversation.contact.push_event_data, sender: conversation.contact.push_event_data,
assignee: conversation.assignee assignee: conversation.assignee

View file

@ -13,7 +13,7 @@ RSpec.describe Conversations::EventDataPresenter do
describe '#push_data' do describe '#push_data' do
let(:expected_data) do let(:expected_data) do
{ {
additional_attributes: nil, additional_attributes: {},
meta: { meta: {
sender: conversation.contact.push_event_data, sender: conversation.contact.push_event_data,
assignee: conversation.assignee assignee: conversation.assignee

0
vendor/db/.keep vendored Normal file
View file