chore: Ability to Disable Gravatars (#5027)

fixes: #3853

- Introduced DISABLE_GRAVATAR Global Config, which will stop chatwoot from making API requests to gravatar
- Cleaned up avatar-related logic and centralized it into the avatarable concern
- Added specs for the missing cases
- Added migration for existing installations to move the avatar to attachment, rather than making the API that results in 404.
This commit is contained in:
Sojan Jose 2022-07-21 19:27:12 +02:00 committed by GitHub
parent 6105567238
commit 6a6a37a67b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 225 additions and 83 deletions

View file

@ -104,7 +104,7 @@ class ContactIdentifyAction
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs @contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save! @contact.save!
ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end end
def merge_contact(base_contact, merge_contact) def merge_contact(base_contact, merge_contact)

View file

@ -23,7 +23,7 @@ class ContactBuilder
end end
def update_contact_avatar(contact) def update_contact_avatar(contact)
::ContactAvatarJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url] ::Avatar::AvatarFromUrlJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url]
end end
def create_contact def create_contact

View file

@ -58,7 +58,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
return if contact_params[:remote_avatar_url].blank? return if contact_params[:remote_avatar_url].blank?
return if @contact.avatar.attached? return if @contact.avatar.attached?
ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url]) Avatar::AvatarFromUrlJob.perform_later(@contact, contact_params[:remote_avatar_url])
end end
def conversation def conversation

View file

@ -90,9 +90,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
end end
def set_avatar(facebook_inbox, page_id) def set_avatar(facebook_inbox, page_id)
avatar_file = Down.download( avatar_url = "https://graph.facebook.com/#{page_id}/picture?type=large"
"http://graph.facebook.com/#{page_id}/picture?type=large" Avatar::AvatarFromUrlJob.perform_later(facebook_inbox, avatar_url)
)
facebook_inbox.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type)
end end
end end

View file

@ -166,13 +166,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end end
def process_avatar def process_avatar
if permitted_params[:avatar].blank? && permitted_params[:avatar_url].present? ::Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
::ContactAvatarJob.perform_later(@contact, params[:avatar_url])
elsif permitted_params[:avatar].blank? && permitted_params[:email].present?
hash = Digest::MD5.hexdigest(params[:email])
gravatar_url = "https://www.gravatar.com/avatar/#{hash}?d=404"
::ContactAvatarJob.perform_later(@contact, gravatar_url)
end
end end
def render_error(error, error_status) def render_error(error, error_status)

View file

@ -0,0 +1,13 @@
class Avatar::AvatarFromGravatarJob < ApplicationJob
queue_as :low
def perform(avatarable, email)
return if GlobalConfigService.load('DISABLE_GRAVATAR', '').present?
return if email.blank?
return if avatarable.avatar_url.present?
hash = Digest::MD5.hexdigest(email)
gravatar_url = "https://www.gravatar.com/avatar/#{hash}?d=404"
Avatar::AvatarFromUrlJob.perform_later(avatarable, gravatar_url)
end
end

View file

@ -0,0 +1,15 @@
class Avatar::AvatarFromUrlJob < ApplicationJob
queue_as :default
def perform(avatarable, avatar_url)
return unless avatarable.respond_to?(:avatar)
avatar_file = Down.download(
avatar_url,
max_size: 15 * 1024 * 1024
)
avatarable.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type)
rescue Down::NotFound, Down::Error => e
Rails.logger.error "Exception: invalid avatar url #{avatar_url} : #{e.message}"
end
end

View file

@ -1,15 +0,0 @@
class ContactAvatarJob < ApplicationJob
queue_as :default
def perform(contact, avatar_url)
avatar_file = Down.download(
avatar_url,
max_size: 15 * 1024 * 1024
)
contact.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type)
rescue Down::NotFound
contact.avatar.attachment.destroy! if contact.avatar.attached?
rescue Down::Error => e
Rails.logger.error "Exception: invalid avatar url #{avatar_url} : #{e.message}"
end
end

View file

@ -7,17 +7,22 @@ module Avatarable
included do included do
has_one_attached :avatar has_one_attached :avatar
validate :acceptable_avatar, if: -> { avatar.changed? } validate :acceptable_avatar, if: -> { avatar.changed? }
after_save :fetch_avatar_from_gravatar
end end
def avatar_url def avatar_url
return url_for(avatar.representation(resize: '250x250')) if avatar.attached? && avatar.representable? return url_for(avatar.representation(resize: '250x250')) if avatar.attached? && avatar.representable?
if [SuperAdmin, User, Contact].include?(self.class) && email.present? ''
hash = Digest::MD5.hexdigest(email)
return "https://www.gravatar.com/avatar/#{hash}?d=404"
end end
'' def fetch_avatar_from_gravatar
return unless saved_changes.key?(:email)
return if email.blank?
# Incase avatar_url is supplied, we don't want to fetch avatar from gravatar
# So we will wait for it to be processed
Avatar::AvatarFromGravatarJob.set(wait: 30.seconds).perform_later(self, email)
end end
def acceptable_avatar def acceptable_avatar

View file

@ -16,6 +16,6 @@ class Instagram::WebhooksBaseService
) )
@contact = @contact_inbox.contact @contact = @contact_inbox.contact
ContactAvatarJob.perform_later(@contact, user['profile_pic']) if user['profile_pic'] Avatar::AvatarFromUrlJob.perform_later(@contact, user['profile_pic']) if user['profile_pic']
end end
end end

View file

@ -45,7 +45,7 @@ class Telegram::IncomingMessageService
return if @contact.avatar.attached? return if @contact.avatar.attached?
avatar_url = inbox.channel.get_telegram_profile_image(params[:message][:from][:id]) avatar_url = inbox.channel.get_telegram_profile_image(params[:message][:from][:id])
::ContactAvatarJob.perform_later(@contact, avatar_url) if avatar_url ::Avatar::AvatarFromUrlJob.perform_later(@contact, avatar_url) if avatar_url
end end
def conversation_params def conversation_params

View file

@ -30,6 +30,6 @@ class Twitter::WebhooksBaseService
user['id'], user['name'], additional_contact_attributes(user) user['id'], user['name'], additional_contact_attributes(user)
) )
@contact = @contact_inbox.contact @contact = @contact_inbox.contact
ContactAvatarJob.perform_later(@contact, user['profile_image_url']) if user['profile_image_url'] Avatar::AvatarFromUrlJob.perform_later(@contact, user['profile_image_url']) if user['profile_image_url']
end end
end end

View file

@ -0,0 +1,26 @@
class SyncGravatarForExistingAvatarables < ActiveRecord::Migration[6.1]
def change
return if GlobalConfigService.load('DISABLE_GRAVATAR', '').present?
sync_user_avatars
sync_contact_avatars
end
private
def sync_user_avatars
::User.find_in_batches do |users_batch|
users_batch.each do |user|
Avatar::AvatarFromGravatarJob.perform_later(user, user.email)
end
end
end
def sync_contact_avatars
::Contact.where.not(email: nil).find_in_batches do |contacts_batch|
contacts_batch.each do |contact|
Avatar::AvatarFromGravatarJob.perform_later(contact, contact.email)
end
end
end
end

View file

@ -13,7 +13,7 @@ describe ::ContactIdentifyAction do
describe '#perform' do describe '#perform' do
it 'updates the contact' do it 'updates the contact' do
expect(ContactAvatarJob).not_to receive(:perform_later).with(contact, params[:avatar_url]) expect(Avatar::AvatarFromUrlJob).not_to receive(:perform_later).with(contact, params[:avatar_url])
contact_identify contact_identify
expect(contact.reload.name).to eq 'test' expect(contact.reload.name).to eq 'test'
# custom attributes are merged properly without overwriting existing ones # custom attributes are merged properly without overwriting existing ones
@ -32,7 +32,7 @@ describe ::ContactIdentifyAction do
it 'enques avatar job when avatar url parameter is passed' do it 'enques avatar job when avatar url parameter is passed' do
params = { name: 'test', avatar_url: 'https://chatwoot-assets.local/sample.png' } params = { name: 'test', avatar_url: 'https://chatwoot-assets.local/sample.png' }
expect(ContactAvatarJob).to receive(:perform_later).with(contact, params[:avatar_url]).once expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(contact, params[:avatar_url]).once
described_class.new(contact: contact, params: params).perform described_class.new(contact: contact, params: params).perform
end end

View file

@ -1,6 +1,7 @@
require 'rails_helper' require 'rails_helper'
describe ::V2::ReportBuilder do describe ::V2::ReportBuilder do
include ActiveJob::TestHelper
let!(:account) { create(:account) } let!(:account) { create(:account) }
let!(:user) { create(:user, account: account) } let!(:user) { create(:user, account: account) }
let!(:inbox) { create(:inbox, account: account) } let!(:inbox) { create(:inbox, account: account) }
@ -8,18 +9,12 @@ describe ::V2::ReportBuilder do
let!(:label_1) { create(:label, title: 'Label_1', account: account) } let!(:label_1) { create(:label, title: 'Label_1', account: account) }
let!(:label_2) { create(:label, title: 'Label_2', account: account) } let!(:label_2) { create(:label, title: 'Label_2', account: account) }
# Running jobs inline to calculate the exact metrics
around do |test|
current_adapter = ActiveJob::Base.queue_adapter
ActiveJob::Base.queue_adapter = :inline
test.run
ensure
ActiveJob::Base.queue_adapter = current_adapter
end
describe '#timeseries' do describe '#timeseries' do
before do before do
gravatar_url = 'https://www.gravatar.com'
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
perform_enqueued_jobs do
10.times do 10.times do
conversation = create(:conversation, account: account, conversation = create(:conversation, account: account,
inbox: inbox, assignee: user, inbox: inbox, assignee: user,
@ -53,6 +48,7 @@ describe ::V2::ReportBuilder do
conversation.save! conversation.save!
end end
end end
end
context 'when report type is account' do context 'when report type is account' do
it 'return conversations count' do it 'return conversations count' do

View file

@ -15,7 +15,7 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
describe 'POST /api/v1/accounts/{account.id}/bulk_action' do describe 'POST /api/v1/accounts/{account.id}/bulk_action' do
context 'when it is an unauthenticated user' do context 'when it is an unauthenticated user' do
let(:agent) { create(:user) } let!(:agent) { create(:user) }
it 'returns unauthorized' do it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/bulk_actions", post "/api/v1/accounts/#{account.id}/bulk_actions",
@ -27,7 +27,7 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
end end
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) } let!(:agent) { create(:user, account: account, role: :agent) }
it 'Ignores bulk_actions for wrong type' do it 'Ignores bulk_actions for wrong type' do
post "/api/v1/accounts/#{account.id}/bulk_actions", post "/api/v1/accounts/#{account.id}/bulk_actions",
@ -117,7 +117,7 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
describe 'POST /api/v1/accounts/{account.id}/bulk_actions' do describe 'POST /api/v1/accounts/{account.id}/bulk_actions' do
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) } let!(:agent) { create(:user, account: account, role: :agent) }
it 'Bulk delete conversation labels' do it 'Bulk delete conversation labels' do
Conversation.first.add_labels(%w[support priority_customer]) Conversation.first.add_labels(%w[support priority_customer])

View file

@ -487,6 +487,14 @@ RSpec.describe 'Contacts API', type: :request do
contact.reload contact.reload
expect(contact.avatar.attached?).to be(true) expect(contact.avatar.attached?).to be(true)
end end
it 'updated avatar with avatar_url' do
patch "/api/v1/accounts/#{account.id}/contacts/#{contact.id}",
params: valid_params.merge(avatar_url: 'http://example.com/avatar.png'),
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(Avatar::AvatarFromUrlJob).to have_been_enqueued.with(contact, 'http://example.com/avatar.png')
end
end end
end end

View file

@ -2,7 +2,7 @@
FactoryBot.define do FactoryBot.define do
factory :inbox_member do factory :inbox_member do
user user { create(:user, :with_avatar) }
inbox inbox
end end
end end

View file

@ -0,0 +1,29 @@
require 'rails_helper'
RSpec.describe Avatar::AvatarFromGravatarJob, type: :job do
let(:avatarable) { create(:contact) }
let(:email) { 'test@test.com' }
let(:gravatar_url) { "https://www.gravatar.com/avatar/#{Digest::MD5.hexdigest(email)}?d=404" }
it 'enqueues the job' do
expect { described_class.perform_later(avatarable, email) }.to have_enqueued_job(described_class)
.on_queue('low')
end
it 'will call AvatarFromUrlJob with gravatar url' do
expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(avatarable, gravatar_url)
described_class.perform_now(avatarable, email)
end
it 'will not call AvatarFromUrlJob if DISABLE_GRAVATAR is configured' do
with_modified_env DISABLE_GRAVATAR: 'true' do
expect(Avatar::AvatarFromUrlJob).not_to receive(:perform_later).with(avatarable, gravatar_url)
described_class.perform_now(avatarable, '')
end
end
it 'will not call AvatarFromUrlJob if email is blank' do
expect(Avatar::AvatarFromUrlJob).not_to receive(:perform_later).with(avatarable, gravatar_url)
described_class.perform_now(avatarable, '')
end
end

View file

@ -0,0 +1,20 @@
require 'rails_helper'
RSpec.describe Avatar::AvatarFromUrlJob, type: :job do
let(:avatarable) { create(:contact) }
let(:avatar_url) { 'https://example.com/avatar.png' }
it 'enqueues the job' do
expect { described_class.perform_later(avatarable, avatar_url) }.to have_enqueued_job(described_class)
.on_queue('default')
end
it 'will attach avatar from url' do
expect(avatarable.avatar).not_to be_attached
expect(Down).to receive(:download).with(avatar_url,
max_size: 15 * 1024 * 1024).and_return(fixture_file_upload(Rails.root.join('spec/assets/avatar.png'),
'image/png'))
described_class.perform_now(avatarable, avatar_url)
expect(avatarable.avatar).to be_attached
end
end

View file

@ -1,5 +1,6 @@
require 'rails_helper' require 'rails_helper'
require Rails.root.join 'spec/models/concerns/access_tokenable_shared.rb' require Rails.root.join 'spec/models/concerns/access_tokenable_shared.rb'
require Rails.root.join 'spec/models/concerns/avatarable_shared.rb'
RSpec.describe AgentBot, type: :model do RSpec.describe AgentBot, type: :model do
describe 'associations' do describe 'associations' do
@ -9,5 +10,6 @@ RSpec.describe AgentBot, type: :model do
describe 'concerns' do describe 'concerns' do
it_behaves_like 'access_tokenable' it_behaves_like 'access_tokenable'
it_behaves_like 'avatarable'
end end
end end

View file

@ -0,0 +1,41 @@
require 'rails_helper'
shared_examples_for 'avatarable' do
let(:avatarable) { create(described_class.to_s.underscore) }
it { is_expected.to have_one_attached(:avatar) }
it 'add avatar_url method' do
expect(avatarable.respond_to?(:avatar_url)).to be true
end
context 'when avatarable has an email attribute' do
it 'enques job when email is changed on avatarable create' do
avatarable = build(described_class.to_s.underscore, account: create(:account))
if avatarable.respond_to?(:email)
avatarable.email = 'test@test.com'
avatarable.skip_reconfirmation! if avatarable.is_a? User
expect(Avatar::AvatarFromGravatarJob).to receive(:set).with(wait: 30.seconds).and_call_original
end
avatarable.save!
expect(Avatar::AvatarFromGravatarJob).to have_been_enqueued.with(avatarable, avatarable.email) if avatarable.respond_to?(:email)
end
it 'enques job when email is changes on avatarable update' do
if avatarable.respond_to?(:email)
avatarable.email = 'xyc@test.com'
avatarable.skip_reconfirmation! if avatarable.is_a? User
expect(Avatar::AvatarFromGravatarJob).to receive(:set).with(wait: 30.seconds).and_call_original
end
avatarable.save!
expect(Avatar::AvatarFromGravatarJob).to have_been_enqueued.with(avatarable, avatarable.email) if avatarable.respond_to?(:email)
end
it 'will not enqueu when email is not changed on avatarable update' do
avatarable.updated_at = Time.now.utc
expect do
avatarable.save!
end.not_to have_enqueued_job(Avatar::AvatarFromGravatarJob)
end
end
end

View file

@ -2,6 +2,8 @@
require 'rails_helper' require 'rails_helper'
require Rails.root.join 'spec/models/concerns/avatarable_shared.rb'
RSpec.describe Contact do RSpec.describe Contact do
context 'validations' do context 'validations' do
it { is_expected.to validate_presence_of(:account_id) } it { is_expected.to validate_presence_of(:account_id) }
@ -12,6 +14,10 @@ RSpec.describe Contact do
it { is_expected.to have_many(:conversations).dependent(:destroy_async) } it { is_expected.to have_many(:conversations).dependent(:destroy_async) }
end end
describe 'concerns' do
it_behaves_like 'avatarable'
end
context 'prepare contact attributes before validation' do context 'prepare contact attributes before validation' do
it 'sets email to lowercase' do it 'sets email to lowercase' do
contact = create(:contact, email: 'Test@test.com') contact = create(:contact, email: 'Test@test.com')

View file

@ -2,6 +2,7 @@
require 'rails_helper' require 'rails_helper'
require Rails.root.join 'spec/models/concerns/out_of_offisable_shared.rb' require Rails.root.join 'spec/models/concerns/out_of_offisable_shared.rb'
require Rails.root.join 'spec/models/concerns/avatarable_shared.rb'
RSpec.describe Inbox do RSpec.describe Inbox do
describe 'validations' do describe 'validations' do
@ -37,6 +38,7 @@ RSpec.describe Inbox do
describe 'concerns' do describe 'concerns' do
it_behaves_like 'out_of_offisable' it_behaves_like 'out_of_offisable'
it_behaves_like 'avatarable'
end end
describe '#add_member' do describe '#add_member' do

View file

@ -2,6 +2,7 @@
require 'rails_helper' require 'rails_helper'
require Rails.root.join 'spec/models/concerns/access_tokenable_shared.rb' require Rails.root.join 'spec/models/concerns/access_tokenable_shared.rb'
require Rails.root.join 'spec/models/concerns/avatarable_shared.rb'
RSpec.describe User do RSpec.describe User do
let!(:user) { create(:user) } let!(:user) { create(:user) }
@ -25,6 +26,7 @@ RSpec.describe User do
describe 'concerns' do describe 'concerns' do
it_behaves_like 'access_tokenable' it_behaves_like 'access_tokenable'
it_behaves_like 'avatarable'
end end
describe 'pubsub_token' do describe 'pubsub_token' do