diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index ef03455..a44f78c 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -36,6 +36,7 @@ class SettingsController < ApplicationController if @user.avatar_new.present? if store_user_avatar LdapManager::UpdateAvatar.call(user: @user) + XmppSetAvatarJob.perform_later(user: @user) else @validation_errors = @user.errors render :show, status: :unprocessable_entity and return diff --git a/app/jobs/xmpp_set_avatar_job.rb b/app/jobs/xmpp_set_avatar_job.rb new file mode 100644 index 0000000..642225c --- /dev/null +++ b/app/jobs/xmpp_set_avatar_job.rb @@ -0,0 +1,97 @@ +require 'digest' +require "image_processing/vips" + +class XmppSetAvatarJob < ApplicationJob + queue_as :default + + def perform(user:, overwrite: false) + return if Rails.env.development? + @user = user + + unless overwrite + current_avatar = get_current_avatar + Rails.logger.info { "User #{user.cn} already has an avatar set" } + return if current_avatar.present? + end + + Rails.logger.debug { "Setting XMPP avatar for user #{user.cn}" } + + stanzas = build_xep0084_stanzas + + stanzas.each do |stanza| + payload = { from: @user.address, to: @user.address, stanza: stanza } + res = ejabberd.send_stanza payload + raise res.inspect if res.status != 200 + end + end + + private + + def ejabberd + @ejabberd ||= EjabberdApiClient.new + end + + def get_current_avatar + res = ejabberd.get_vcard2 @user, "PHOTO", "BINVAL" + + if res.status == 200 + # VCARD PHOTO/BINVAL prop exists + res.body + elsif res.status == 400 + # VCARD or PHOTO/BINVAL prop does not exist + nil + else + # Unexpected error, let job fail + raise res.inspect + end + end + + def process_avatar + @user.avatar.blob.open do |file| + processed = ImageProcessing::Vips + .source(file) + .resize_to_fill(256, 256) + .convert("png") + .call + processed.read + end + end + + # See https://xmpp.org/extensions/xep-0084.html + def build_xep0084_stanzas + img_data = process_avatar + sha1_hash = Digest::SHA1.hexdigest(img_data) + base64_data = Base64.strict_encode64(img_data) + + [ + """ + + + + + #{base64_data} + + + + + """.strip, + """ + + + + + + + + + + + + """.strip, + ] + end +end diff --git a/app/services/ejabberd_api_client.rb b/app/services/ejabberd_api_client.rb index a0a8b87..df22d25 100644 --- a/app/services/ejabberd_api_client.rb +++ b/app/services/ejabberd_api_client.rb @@ -4,16 +4,14 @@ class EjabberdApiClient end def post(endpoint, payload) - res = Faraday.post("#{@base_url}/#{endpoint}", payload.to_json, - "Content-Type" => "application/json") - - if res.status != 200 - Rails.logger.error "[ejabberd] API request failed:" - Rails.logger.error res.body - #TODO Send custom event to Sentry - end + Faraday.post "#{@base_url}/#{endpoint}", payload.to_json, + "Content-Type" => "application/json" end + # + # API endpoints + # + def add_rosteritem(payload) post "add_rosteritem", payload end @@ -22,8 +20,31 @@ class EjabberdApiClient post "send_message", payload end + def send_stanza(payload) + post "send_stanza", payload + end + + def get_vcard2(user, name, subname) + payload = { + user: user.cn, host: user.ou, + name: name, subname: subname + } + post "get_vcard2", payload + end + + def private_get(user, element_name, namespace) + payload = { + user: user.cn, host: user.ou, + element: element_name, ns: namespace + } + post "private_get", payload + end + def private_set(user, content) - payload = { user: user.cn, host: user.ou, element: content } + payload = { + user: user.cn, host: user.ou, + element: content + } post "private_set", payload end end diff --git a/spec/features/settings/profile_spec.rb b/spec/features/settings/profile_spec.rb index 95a4798..52932b4 100644 --- a/spec/features/settings/profile_spec.rb +++ b/spec/features/settings/profile_spec.rb @@ -46,6 +46,8 @@ RSpec.describe 'Profile settings', type: :feature do feature "Update avatar" do scenario "fails with validation error for wrong content type" do + expect(LdapManager::UpdateAvatar).not_to receive(:call) + visit setting_path(:profile) attach_file "Avatar", "#{Rails.root}/spec/fixtures/files/bitcoin.pdf" click_button "Save" @@ -57,8 +59,7 @@ RSpec.describe 'Profile settings', type: :feature do end scenario "fails with validation error for file size too large" do - expect_any_instance_of(LdapManager::UpdateAvatar) - .not_to receive(:replace_attribute).and_return(true) + expect(LdapManager::UpdateAvatar).not_to receive(:call) visit setting_path(:profile) attach_file "Avatar", "#{Rails.root}/spec/fixtures/files/fsociety-irc.png" @@ -73,8 +74,12 @@ RSpec.describe 'Profile settings', type: :feature do scenario 'works with valid JPG file' do file_path = "#{Rails.root}/spec/fixtures/files/taipei.jpg" - expect_any_instance_of(LdapManager::UpdateAvatar) - .to receive(:replace_attribute).and_return(true) + expect(LdapManager::UpdateAvatar) + .to receive(:call).with(user: user) + .and_return(true) + expect(XmppSetAvatarJob) + .to receive(:perform_later).with(user: user) + .and_return(true) visit setting_path(:profile) attach_file "Avatar", file_path @@ -89,8 +94,12 @@ RSpec.describe 'Profile settings', type: :feature do scenario 'works with valid PNG file' do file_path = "#{Rails.root}/spec/fixtures/files/bender.png" - expect_any_instance_of(LdapManager::UpdateAvatar) - .to receive(:replace_attribute).and_return(true) + expect(LdapManager::UpdateAvatar) + .to receive(:call).with(user: user) + .and_return(true) + expect(XmppSetAvatarJob) + .to receive(:perform_later).with(user: user) + .and_return(true) visit setting_path(:profile) attach_file "Avatar", file_path