From 06d2705c4c251db5eee8b5e6b084dd02c27d2a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 15 May 2025 11:47:23 +0400 Subject: [PATCH 1/6] Add private_get to ejabberd service --- app/services/ejabberd_api_client.rb | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/services/ejabberd_api_client.rb b/app/services/ejabberd_api_client.rb index a0a8b87..786af02 100644 --- a/app/services/ejabberd_api_client.rb +++ b/app/services/ejabberd_api_client.rb @@ -22,8 +22,19 @@ class EjabberdApiClient post "send_message", 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 From fc36fbf10cfbdc32b2d5f01c09df7bdb0ef5dbee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 15 May 2025 12:16:53 +0400 Subject: [PATCH 2/6] Add get_vcard2 to ejabberd client --- app/services/ejabberd_api_client.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/services/ejabberd_api_client.rb b/app/services/ejabberd_api_client.rb index 786af02..91e741e 100644 --- a/app/services/ejabberd_api_client.rb +++ b/app/services/ejabberd_api_client.rb @@ -37,4 +37,12 @@ class EjabberdApiClient } post "private_set", payload end + + def get_vcard2(user, name, subname) + payload = { + user: user.cn, host: user.ou, + name: name, subname: subname + } + post "get_vcard2", payload + end end From 8b3243af6b9216790e309f1902af36103b2993db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 15 May 2025 12:19:09 +0400 Subject: [PATCH 3/6] Sort API methods alphabetically --- app/services/ejabberd_api_client.rb | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/services/ejabberd_api_client.rb b/app/services/ejabberd_api_client.rb index 91e741e..fed8705 100644 --- a/app/services/ejabberd_api_client.rb +++ b/app/services/ejabberd_api_client.rb @@ -14,12 +14,20 @@ class EjabberdApiClient end end + # + # API endpoints + # + def add_rosteritem(payload) post "add_rosteritem", payload end - def send_message(payload) - post "send_message", payload + 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) @@ -38,11 +46,7 @@ class EjabberdApiClient post "private_set", payload end - def get_vcard2(user, name, subname) - payload = { - user: user.cn, host: user.ou, - name: name, subname: subname - } - post "get_vcard2", payload + def send_message(payload) + post "send_message", payload end end From 382c5ad10e7bb06a05268c4824c65b064576b1ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 15 May 2025 12:38:40 +0400 Subject: [PATCH 4/6] Return response for ejabberd API calls --- app/services/ejabberd_api_client.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/services/ejabberd_api_client.rb b/app/services/ejabberd_api_client.rb index fed8705..a002d7f 100644 --- a/app/services/ejabberd_api_client.rb +++ b/app/services/ejabberd_api_client.rb @@ -8,10 +8,12 @@ class EjabberdApiClient "Content-Type" => "application/json") if res.status != 200 + #TODO Send custom event to Sentry Rails.logger.error "[ejabberd] API request failed:" Rails.logger.error res.body - #TODO Send custom event to Sentry end + + res end # From 5916969447afd068501bdd4deda55e47b964771d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 15 May 2025 19:49:08 +0400 Subject: [PATCH 5/6] Add job for setting avatar via XMPP --- app/jobs/xmpp_set_avatar_job.rb | 95 +++++++++++++++++++++++++++++ app/services/ejabberd_api_client.rb | 24 +++----- 2 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 app/jobs/xmpp_set_avatar_job.rb diff --git a/app/jobs/xmpp_set_avatar_job.rb b/app/jobs/xmpp_set_avatar_job.rb new file mode 100644 index 0000000..b5bc1f0 --- /dev/null +++ b/app/jobs/xmpp_set_avatar_job.rb @@ -0,0 +1,95 @@ +require 'digest' +require "image_processing/vips" + +class XmppSetAvatarJob < ApplicationJob + queue_as :default + + def perform(user:, overwrite: false) + @user = user + + unless overwrite + current_avatar = get_current_avatar + Rails.logger.debug { "User #{user.cn} already has an avatar set. Nothing to do." } + 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 + + 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 a002d7f..df22d25 100644 --- a/app/services/ejabberd_api_client.rb +++ b/app/services/ejabberd_api_client.rb @@ -4,16 +4,8 @@ class EjabberdApiClient end def post(endpoint, payload) - res = Faraday.post("#{@base_url}/#{endpoint}", payload.to_json, - "Content-Type" => "application/json") - - if res.status != 200 - #TODO Send custom event to Sentry - Rails.logger.error "[ejabberd] API request failed:" - Rails.logger.error res.body - end - - res + Faraday.post "#{@base_url}/#{endpoint}", payload.to_json, + "Content-Type" => "application/json" end # @@ -24,6 +16,14 @@ class EjabberdApiClient post "add_rosteritem", payload end + def send_message(payload) + 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, @@ -47,8 +47,4 @@ class EjabberdApiClient } post "private_set", payload end - - def send_message(payload) - post "send_message", payload - end end From 5f276ff3492b9e034eba7abf43ecbdb3b47d75b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 15 May 2025 22:04:25 +0400 Subject: [PATCH 6/6] Queue XmppSetAvatarJob when new avatar is uploaded And let job do nothing in development for now --- app/controllers/settings_controller.rb | 1 + app/jobs/xmpp_set_avatar_job.rb | 4 +++- spec/features/settings/profile_spec.rb | 21 +++++++++++++++------ 3 files changed, 19 insertions(+), 7 deletions(-) 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 index b5bc1f0..642225c 100644 --- a/app/jobs/xmpp_set_avatar_job.rb +++ b/app/jobs/xmpp_set_avatar_job.rb @@ -5,11 +5,12 @@ 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.debug { "User #{user.cn} already has an avatar set. Nothing to do." } + Rails.logger.info { "User #{user.cn} already has an avatar set" } return if current_avatar.present? end @@ -56,6 +57,7 @@ class XmppSetAvatarJob < ApplicationJob 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) 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