WIP Store avatars as ActiveStorage attachments
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing

Also push to LDAP as jpegPhoto
This commit is contained in:
Râu Cao 2025-05-11 18:43:21 +04:00
parent 9e2210c45b
commit 17ffbde03a
Signed by: raucao
GPG Key ID: 37036C356E56CC51
8 changed files with 80 additions and 42 deletions

View File

@ -22,7 +22,7 @@ class Admin::UsersController < Admin::BaseController
@services_enabled = @user.services_enabled @services_enabled = @user.services_enabled
@avatar = LdapManager::FetchAvatar.call(cn: @user.cn) @ldap_avatar = LdapManager::FetchAvatar.call(cn: @user.cn)
end end
# POST /admin/users/:username/invitations # POST /admin/users/:username/invitations

View File

@ -25,7 +25,7 @@ class SettingsController < ApplicationController
def update def update
@user.preferences.merge!(user_params[:preferences] || {}) @user.preferences.merge!(user_params[:preferences] || {})
@user.display_name = user_params[:display_name] @user.display_name = user_params[:display_name]
@user.avatar_new = user_params[:avatar] @user.avatar_new = user_params[:avatar_new]
@user.pgp_pubkey = user_params[:pgp_pubkey] @user.pgp_pubkey = user_params[:pgp_pubkey]
if @user.save if @user.save
@ -34,7 +34,10 @@ class SettingsController < ApplicationController
end end
if @user.avatar_new.present? if @user.avatar_new.present?
LdapManager::UpdateAvatar.call(dn: @user.dn, file: @user.avatar_new) @user.avatar.attach(@user.avatar_new)
@user.avatar.blob.update(filename: @user.avatar_filename)
@user.save!
LdapManager::UpdateAvatar.call(user: @user)
end end
if @user.pgp_pubkey && (@user.pgp_pubkey != @user.ldap_entry[:pgp_key]) if @user.pgp_pubkey && (@user.pgp_pubkey != @user.ldap_entry[:pgp_key])
@ -162,7 +165,7 @@ class SettingsController < ApplicationController
def user_params def user_params
params.require(:user).permit( params.require(:user).permit(
:display_name, :avatar, :pgp_pubkey, :display_name, :avatar_new, :pgp_pubkey,
preferences: UserPreferences.pref_keys preferences: UserPreferences.pref_keys
) )
end end

View File

@ -4,8 +4,8 @@ class User < ApplicationRecord
include EmailValidatable include EmailValidatable
attr_accessor :current_password attr_accessor :current_password
attr_accessor :avatar_new
attr_accessor :display_name attr_accessor :display_name
attr_accessor :avatar_new
attr_accessor :pgp_pubkey attr_accessor :pgp_pubkey
serialize :preferences, coder: UserPreferences serialize :preferences, coder: UserPreferences
@ -27,6 +27,12 @@ class User < ApplicationRecord
has_many :accounts, through: :lndhub_user has_many :accounts, through: :lndhub_user
#
# Attachments
#
has_one_attached :avatar
# #
# Validations # Validations
# #
@ -159,13 +165,30 @@ class User < ApplicationRecord
@display_name ||= ldap_entry[:display_name] @display_name ||= ldap_entry[:display_name]
end end
def avatar def avatar_base64(size: :medium)
@avatar ||= LdapManager::FetchAvatar.call(cn: cn) return nil unless avatar.attached?
variant = avatar_variant(size: size)
data = ActiveStorage::Blob.service.download(variant.key)
Base64.strict_encode64(data)
end end
def avatar_base64 def avatar_filename
return nil if avatar.nil? return nil unless avatar.attached?
@avatar_base64 ||= Base64.strict_encode64(avatar) data = ActiveStorage::Blob.service.download(avatar.key)
hash = Digest::SHA256.hexdigest(data)
ext = avatar.content_type == "image/png" ? "png" : "jpg"
"#{hash}.#{ext}"
end
def avatar_variant(size: :medium)
dimensions = case size
when :large then [400, 400]
when :medium then [256, 256]
when :small then [64, 64]
else [256, 256]
end
format = avatar.content_type == "image/png" ? :png : :jpeg
avatar.variant(resize_to_fill: dimensions, format: format)
end end
def nostr_pubkey def nostr_pubkey
@ -232,7 +255,7 @@ class User < ApplicationRecord
return unless avatar_new.present? return unless avatar_new.present?
if avatar_new.size > 1.megabyte if avatar_new.size > 1.megabyte
errors.add(:avatar, "file size is too large") errors.add(:avatar, "must be less than 1MB file size")
end end
acceptable_types = ["image/jpeg", "image/png"] acceptable_types = ["image/jpeg", "image/png"]

View File

@ -10,7 +10,7 @@ module LdapManager
filter = Net::LDAP::Filter.eq("cn", @cn) filter = Net::LDAP::Filter.eq("cn", @cn)
entry = client.search(base: treebase, filter: filter, attributes: attributes).first entry = client.search(base: treebase, filter: filter, attributes: attributes).first
entry&.jpegPhoto ? entry.jpegPhoto.first : nil entry[:jpegPhoto].present? ? entry.jpegPhoto.first : nil
end end
end end
end end

View File

@ -2,26 +2,40 @@ require "image_processing/vips"
module LdapManager module LdapManager
class UpdateAvatar < LdapManagerService class UpdateAvatar < LdapManagerService
def initialize(dn:, file:) def initialize(user:)
@dn = dn @user = user
@img_data = process(file) @dn = user.dn
end end
def call def call
result = replace_attribute @dn, :jpegPhoto, @img_data unless @user.avatar.attached?
result Rails.logger.error { "Cannot store empty jpegPhoto for user #{@user.cn}" }
return false
end
img_data = @user.avatar.blob.download
jpg_data = process(img_data)
result = replace_attribute(@dn, :jpegPhoto, jpg_data)
result == 0
end end
private private
def process(file) def process(data)
processed = ImageProcessing::Vips @user.avatar.blob.open do |file|
.resize_to_fill(256, 256) processed = ImageProcessing::Vips
.source(file) .source(file)
.convert("jpeg") .resize_to_fill(256, 256)
.saver(strip: true) .convert("jpeg")
.call .saver(strip: true)
processed.read .call
processed.read
end
rescue Vips::Error => e
Sentry.capture_exception(e) if Setting.sentry_enabled?
Rails.logger.error { "Image processing failed for LDAP avatar: #{e.message}" }
nil
end end
end end
end end

View File

@ -95,8 +95,8 @@
<tr> <tr>
<th>Avatar</th> <th>Avatar</th>
<td> <td>
<% if @avatar.present? %> <% if @ldap_avatar.present? %>
<img src="data:image/jpeg;base64,<%= @avatar %>" class="h-48 w-48" /> JPEG size: <%= @ldap_avatar.size %>
<% else %> <% else %>
&mdash; &mdash;
<% end %> <% end %>

View File

@ -33,22 +33,19 @@
<% if Flipper.enabled?(:avatar_upload, current_user) %> <% if Flipper.enabled?(:avatar_upload, current_user) %>
<label class="block"> <label class="block">
<p class="font-bold mb-1"> <p class="font-bold mb-1">Avatar</p>
Avatar <p class="text-gray-500">Default profile picture</p>
</p>
<p class="text-gray-500">
Default profile picture
</p>
<div class="flex items-center gap-6"> <div class="flex items-center gap-6">
<% unless current_user.avatar.nil? %> <% if @user.avatar.attached? %>
<p class="flex-none"> <p class="flex-none">
<%= image_tag "data:image/jpeg;base64,#{current_user.avatar_base64}", <%= image_tag @user.avatar_variant(size: :medium),
class: "h-24 w-24 rounded-lg" %> class: "h-24 w-24 rounded-lg" %>
</p> </p>
<% end %> <% end %>
<div class="grow"> <div class="grow">
<p class="mb-2"> <p class="mb-2">
<%= f.file_field :avatar, class: "" %> <%= f.file_field :avatar_new, accept: "image/jpeg,image/png" %>
</p>
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
JPEG or PNG image, not larger than 1 megabyte JPEG or PNG image, not larger than 1 megabyte
</p> </p>

View File

@ -16,8 +16,6 @@ RSpec.describe 'Profile settings', type: :feature do
.and_return({ .and_return({
uid: user.cn, ou: user.ou, display_name: "Mark", pgp_key: nil uid: user.cn, ou: user.ou, display_name: "Mark", pgp_key: nil
}) })
allow_any_instance_of(User).to receive(:avatar)
.and_return(avatar_jpeg)
end end
feature "Update display name" do feature "Update display name" do
@ -61,13 +59,16 @@ 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)
.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"
click_button "Save" click_button "Save"
expect(current_url).to eq(setting_url(:profile)) expect(current_url).to eq(setting_url(:profile))
within ".error-msg" do within ".error-msg" do
expect(page).to have_content("file size is too large") expect(page).to have_content("must be less than 1MB")
end end
end end