From c2bdf5247e669d7622fbe34b9e7c8278205963f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sat, 17 May 2025 16:44:28 +0400 Subject: [PATCH] Sync Mastodon IDs/profiles to local accounts Add a new service to import some data from Mastodon accounts: * Find users by username, store Mastodon account ID in local db when found * Import display name (don't overwrite existing) * Import avatar (don't overwrite existing) --- app/controllers/settings_controller.rb | 20 ++----- app/services/mastodon_manager/fetch_user.rb | 12 ++++ app/services/mastodon_manager/find_user.rb | 14 +++++ .../mastodon_manager/sync_account_profiles.rb | 57 +++++++++++++++++++ .../user_manager/import_remote_avatar.rb | 41 +++++++++++++ app/services/user_manager/process_avatar.rb | 21 +++++++ 6 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 app/services/mastodon_manager/fetch_user.rb create mode 100644 app/services/mastodon_manager/find_user.rb create mode 100644 app/services/mastodon_manager/sync_account_profiles.rb create mode 100644 app/services/user_manager/import_remote_avatar.rb create mode 100644 app/services/user_manager/process_avatar.rb diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index a44f78c..87a3e11 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -193,7 +193,11 @@ class SettingsController < ApplicationController def store_user_avatar io = @user.avatar_new.tempfile - img_data = process_avatar(io) + img_data = UserManager::ProcessAvatar.call(io: io) + if img_data.blank? + @user.errors.add(:avatar, "failed to process file") + false + end tempfile = Tempfile.create tempfile.binmode tempfile.write(img_data) @@ -212,18 +216,4 @@ class SettingsController < ApplicationController @user.save end end - - def process_avatar(io) - processed = ImageProcessing::Vips - .source(io) - .resize_to_fill(400, 400) - .saver(strip: true) - .call - io.rewind - processed.read - rescue Vips::Error => e - Sentry.capture_exception(e) if Setting.sentry_enabled? - Rails.logger.error { "Image processing failed for avatar: #{e.message}" } - nil - end end diff --git a/app/services/mastodon_manager/fetch_user.rb b/app/services/mastodon_manager/fetch_user.rb new file mode 100644 index 0000000..3d7d3d0 --- /dev/null +++ b/app/services/mastodon_manager/fetch_user.rb @@ -0,0 +1,12 @@ +module MastodonManager + class FetchUser < MastodonManagerService + def initialize(mastodon_id:) + @mastodon_id = mastodon_id + end + + def call + user = get "v1/admin/accounts/#{@mastodon_id}" + user.with_indifferent_access + end + end +end diff --git a/app/services/mastodon_manager/find_user.rb b/app/services/mastodon_manager/find_user.rb new file mode 100644 index 0000000..f483bc0 --- /dev/null +++ b/app/services/mastodon_manager/find_user.rb @@ -0,0 +1,14 @@ +module MastodonManager + class FindUser < MastodonManagerService + def initialize(username:) + @username = username + end + + def call + users = get "v2/admin/accounts?username=#{@username}&origin=local" + users = users.map { |u| u.with_indifferent_access } + # Results may contain partial matches + users.find { |u| u.dig(:username).downcase == @username.downcase } + end + end +end diff --git a/app/services/mastodon_manager/sync_account_profiles.rb b/app/services/mastodon_manager/sync_account_profiles.rb new file mode 100644 index 0000000..71b08cf --- /dev/null +++ b/app/services/mastodon_manager/sync_account_profiles.rb @@ -0,0 +1,57 @@ +module MastodonManager + class SyncAccountProfiles < MastodonManagerService + def initialize(direction: "down", overwrite: false) + @direction = direction + @overwrite = overwrite + + if @direction != "down" + raise NotImplementedError + end + end + + def call + Rails.logger.debug { "Syncing account profiles (direction: #{@direction}, overwrite: #{@overwrite})"} + + User.find_each do |user| + if user.mastodon_id.blank? + mastodon_user = MastodonManager::FindUser.call username: user.cn + if mastodon_user + Rails.logger.debug { "Setting mastodon_id for user #{user.cn}" } + user.update! mastodon_id: @user[:id] + else + Rails.logger.debug { "No Mastodon user found for username #{user.cn}" } + next + end + end + + next if user.avatar.attached? && user.display_name.present? + + unless mastodon_user + Rails.logger.debug { "Fetching Mastodon account with ID #{user.mastodon_id} for #{user.cn}" } + mastodon_user = MastodonManager::FetchUser.call mastodon_id: user.mastodon_id + end + + if user.display_name.blank? + if mastodon_display_name = mastodon_user.dig(:account, :display_name) + Rails.logger.debug { "Setting display name for user #{user.cn} from Mastodon" } + LdapManager::UpdateDisplayName.call( + dn: user.dn, display_name: mastodon_display_name + ) + end + end + + if !user.avatar.attached? + if avatar_url = mastodon_user.dig(:account, :avatar_static) + Rails.logger.debug { "Importing Mastodon avatar for user #{user.cn}" } + UserManager::ImportRemoteAvatar.call( + user: user, avatar_url: avatar_url + ) + end + end + rescue => e + Sentry.capture_exception(e) if Setting.sentry_enabled? + Rails.logger.error e + end + end + end +end diff --git a/app/services/user_manager/import_remote_avatar.rb b/app/services/user_manager/import_remote_avatar.rb new file mode 100644 index 0000000..daf9b13 --- /dev/null +++ b/app/services/user_manager/import_remote_avatar.rb @@ -0,0 +1,41 @@ +module UserManager + class ImportRemoteAvatar < UserManagerService + def initialize(user:, avatar_url:) + @user = user + @avatar_url = avatar_url + end + + def call + if import_remote_avatar + LdapManager::UpdateAvatar.call(user: @user) + XmppSetAvatarJob.perform_later(user: @user) if Setting.ejabberd_enabled? + end + end + + def import_remote_avatar + tempfile = Down.download(@avatar_url) + content_type = tempfile.content_type + unless %w[image/jpeg image/png].include?(content_type) + Rails.logger.warn { "Wrong content type of remote avatar for user #{user.cn}: '#{content_type}'" } + return false + end + + img_data = UserManager::ProcessAvatar.call(io: tempfile) + tempfile = Tempfile.create + tempfile.binmode + tempfile.write(img_data) + tempfile.rewind + + hash = Digest::SHA256.hexdigest(img_data) + ext = content_type == "image/png" ? "png" : "jpg" + filename = "#{hash}.#{ext}" + key = "users/#{@user.cn}/avatars/#{filename}" + + @user.avatar.attach io: tempfile, key: key, filename: filename + rescue => e + Sentry.capture_exception(e) if Setting.sentry_enabled? + Rails.logger.warn "Importing remote avatar failed: \"#{e.message}\"" + false + end + end +end diff --git a/app/services/user_manager/process_avatar.rb b/app/services/user_manager/process_avatar.rb new file mode 100644 index 0000000..cf47828 --- /dev/null +++ b/app/services/user_manager/process_avatar.rb @@ -0,0 +1,21 @@ +module UserManager + class ProcessAvatar < UserManagerService + def initialize(io:) + @io = io + end + + def call + processed = ImageProcessing::Vips + .source(@io) + .resize_to_fill(400, 400) + .saver(strip: true) + .call + @io.rewind + processed.read + rescue Vips::Error => e + Sentry.capture_exception(e) if Setting.sentry_enabled? + Rails.logger.warn { "Image processing failed for avatar: #{e.message}" } + nil + end + end +end