Merge pull request 'Set XMPP avatar when new avatar is uploaded' (#224) from feature/ejabberd_pep into master
Some checks are pending
continuous-integration/drone/push Build is running

Reviewed-on: #224
This commit is contained in:
Râu Cao 2025-05-16 11:37:29 +00:00
commit 8049f81b73
4 changed files with 143 additions and 15 deletions

View File

@ -36,6 +36,7 @@ class SettingsController < ApplicationController
if @user.avatar_new.present? if @user.avatar_new.present?
if store_user_avatar if store_user_avatar
LdapManager::UpdateAvatar.call(user: @user) LdapManager::UpdateAvatar.call(user: @user)
XmppSetAvatarJob.perform_later(user: @user)
else else
@validation_errors = @user.errors @validation_errors = @user.errors
render :show, status: :unprocessable_entity and return render :show, status: :unprocessable_entity and return

View File

@ -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)
[
"""
<iq type='set' from='#{@user.address}' id='avatar-data-#{rand(101)}'>
<pubsub xmlns='http://jabber.org/protocol/pubsub'>
<publish node='urn:xmpp:avatar:data'>
<item id='#{sha1_hash}'>
<data xmlns='urn:xmpp:avatar:data'>#{base64_data}</data>
</item>
</publish>
</pubsub>
</iq>
""".strip,
"""
<iq type='set' from='#{@user.address}' id='avatar-metadata-#{rand(101)}'>
<pubsub xmlns='http://jabber.org/protocol/pubsub'>
<publish node='urn:xmpp:avatar:metadata'>
<item id='#{sha1_hash}'>
<metadata xmlns='urn:xmpp:avatar:metadata'>
<info bytes='#{img_data.size}'
id='#{sha1_hash}'
height='256'
type='image/png'
width='256'/>
</metadata>
</item>
</publish>
</pubsub>
</iq>
""".strip,
]
end
end

View File

@ -4,15 +4,13 @@ class EjabberdApiClient
end end
def post(endpoint, payload) def post(endpoint, payload)
res = Faraday.post("#{@base_url}/#{endpoint}", payload.to_json, Faraday.post "#{@base_url}/#{endpoint}", payload.to_json,
"Content-Type" => "application/json") "Content-Type" => "application/json"
end
if res.status != 200 #
Rails.logger.error "[ejabberd] API request failed:" # API endpoints
Rails.logger.error res.body #
#TODO Send custom event to Sentry
end
end
def add_rosteritem(payload) def add_rosteritem(payload)
post "add_rosteritem", payload post "add_rosteritem", payload
@ -22,8 +20,31 @@ class EjabberdApiClient
post "send_message", payload post "send_message", payload
end 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) 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 post "private_set", payload
end end
end end

View File

@ -46,6 +46,8 @@ RSpec.describe 'Profile settings', type: :feature do
feature "Update avatar" do feature "Update avatar" do
scenario "fails with validation error for wrong content type" do scenario "fails with validation error for wrong content type" do
expect(LdapManager::UpdateAvatar).not_to receive(:call)
visit setting_path(:profile) visit setting_path(:profile)
attach_file "Avatar", "#{Rails.root}/spec/fixtures/files/bitcoin.pdf" attach_file "Avatar", "#{Rails.root}/spec/fixtures/files/bitcoin.pdf"
click_button "Save" click_button "Save"
@ -57,8 +59,7 @@ RSpec.describe 'Profile settings', type: :feature do
end end
scenario "fails with validation error for file size too large" do scenario "fails with validation error for file size too large" do
expect_any_instance_of(LdapManager::UpdateAvatar) expect(LdapManager::UpdateAvatar).not_to receive(:call)
.not_to receive(:replace_attribute).and_return(true)
visit setting_path(:profile) visit setting_path(:profile)
attach_file "Avatar", "#{Rails.root}/spec/fixtures/files/fsociety-irc.png" 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 scenario 'works with valid JPG file' do
file_path = "#{Rails.root}/spec/fixtures/files/taipei.jpg" file_path = "#{Rails.root}/spec/fixtures/files/taipei.jpg"
expect_any_instance_of(LdapManager::UpdateAvatar) expect(LdapManager::UpdateAvatar)
.to receive(:replace_attribute).and_return(true) .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) visit setting_path(:profile)
attach_file "Avatar", file_path attach_file "Avatar", file_path
@ -89,8 +94,12 @@ RSpec.describe 'Profile settings', type: :feature do
scenario 'works with valid PNG file' do scenario 'works with valid PNG file' do
file_path = "#{Rails.root}/spec/fixtures/files/bender.png" file_path = "#{Rails.root}/spec/fixtures/files/bender.png"
expect_any_instance_of(LdapManager::UpdateAvatar) expect(LdapManager::UpdateAvatar)
.to receive(:replace_attribute).and_return(true) .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) visit setting_path(:profile)
attach_file "Avatar", file_path attach_file "Avatar", file_path