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:
parent
ff96d43953
commit
1d9debaee0
16 changed files with 163 additions and 9 deletions
11
.env.example
11
.env.example
|
@ -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
1
.gitignore
vendored
|
@ -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
|
||||||
|
|
6
Gemfile
6
Gemfile
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
57
app/jobs/contact_ip_lookup_job.rb
Normal file
57
app/jobs/contact_ip_lookup_job.rb
Normal 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
|
|
@ -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
13
bin/validate_push
Normal 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
|
|
@ -7,3 +7,5 @@
|
||||||
enabled: true
|
enabled: true
|
||||||
- name: channel_twitter
|
- name: channel_twitter
|
||||||
enabled: true
|
enabled: true
|
||||||
|
- name: ip_lookup
|
||||||
|
enabled: false
|
32
config/initializers/geocoder.rb
Normal file
32
config/initializers/geocoder.rb
Normal 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
|
|
@ -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
|
12
db/schema.rb
12
db/schema.rb
|
@ -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|
|
||||||
|
|
|
@ -90,7 +90,8 @@
|
||||||
},
|
},
|
||||||
"husky": {
|
"husky": {
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"pre-commit": "lint-staged"
|
"pre-commit": "lint-staged",
|
||||||
|
"pre-push": "sh bin/validate_push"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
0
vendor/db/.keep
vendored
Normal file
Loading…
Reference in a new issue