From f0cfde560ba03351e6d8642a9ef5ff7a2a860d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sat, 17 May 2025 14:17:57 +0400 Subject: [PATCH 1/7] Add Mastodon API service class, auth token config Add a new REST API service class to keep things DRY --- .../concerns/settings/mastodon_settings.rb | 3 +++ app/services/btcpay_manager_service.rb | 16 +---------- app/services/mastodon_manager_service.rb | 22 +++++++++++++++ app/services/rest_api_service.rb | 27 +++++++++++++++++++ .../settings/services/_mastodon.html.erb | 5 ++++ docs/dev/mastodon.md | 10 +++++++ 6 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 app/services/mastodon_manager_service.rb create mode 100644 app/services/rest_api_service.rb create mode 100644 docs/dev/mastodon.md diff --git a/app/models/concerns/settings/mastodon_settings.rb b/app/models/concerns/settings/mastodon_settings.rb index 7532c15..41a079c 100644 --- a/app/models/concerns/settings/mastodon_settings.rb +++ b/app/models/concerns/settings/mastodon_settings.rb @@ -11,6 +11,9 @@ module Settings field :mastodon_address_domain, type: :string, default: ENV["MASTODON_ADDRESS_DOMAIN"].presence || self.primary_domain + + field :mastodon_auth_token, type: :string, + default: ENV["MASTODON_AUTH_TOKEN"].presence end end end diff --git a/app/services/btcpay_manager_service.rb b/app/services/btcpay_manager_service.rb index 4dedb61..245d784 100644 --- a/app/services/btcpay_manager_service.rb +++ b/app/services/btcpay_manager_service.rb @@ -1,7 +1,7 @@ # # API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/ # -class BtcpayManagerService < ApplicationService +class BtcpayManagerService < RestApiService private def base_url @@ -19,18 +19,4 @@ class BtcpayManagerService < ApplicationService "Authorization" => "token #{auth_token}" } end - - def endpoint_url(path) - "#{base_url}/#{path.gsub(/^\//, '')}" - end - - def get(path, params = {}) - res = Faraday.get endpoint_url(path), params, headers - JSON.parse(res.body) - end - - def post(path, payload) - res = Faraday.post endpoint_url(path), payload.to_json, headers - JSON.parse(res.body) - end end diff --git a/app/services/mastodon_manager_service.rb b/app/services/mastodon_manager_service.rb new file mode 100644 index 0000000..9c7fac9 --- /dev/null +++ b/app/services/mastodon_manager_service.rb @@ -0,0 +1,22 @@ +# +# API Docs: https://docs.joinmastodon.org/methods/ +# +class MastodonManagerService < RestApiService + private + + def base_url + @base_url ||= "#{Setting.mastodon_public_url}/api" + end + + def auth_token + @auth_token ||= Setting.mastodon_auth_token + end + + def headers + { + "Content-Type" => "application/json", + "Accept" => "application/json", + "Authorization" => "Bearer #{auth_token}" + } + end +end diff --git a/app/services/rest_api_service.rb b/app/services/rest_api_service.rb new file mode 100644 index 0000000..c0bd0c6 --- /dev/null +++ b/app/services/rest_api_service.rb @@ -0,0 +1,27 @@ +class RestApiService < ApplicationService + private + + def base_url + raise NotImplementedError + end + + def headers + raise NotImplementedError + end + + def endpoint_url(path) + "#{base_url}/#{path.gsub(/^\//, '')}" + end + + def get(path, params = {}) + res = Faraday.get endpoint_url(path), params, headers + # TODO handle unsuccessful responses with no valid JSON body + JSON.parse(res.body) + end + + def post(path, payload) + res = Faraday.post endpoint_url(path), payload.to_json, headers + # TODO handle unsuccessful responses with no valid JSON body + JSON.parse(res.body) + end +end diff --git a/app/views/admin/settings/services/_mastodon.html.erb b/app/views/admin/settings/services/_mastodon.html.erb index 1266db1..482197f 100644 --- a/app/views/admin/settings/services/_mastodon.html.erb +++ b/app/views/admin/settings/services/_mastodon.html.erb @@ -16,5 +16,10 @@ key: :mastodon_address_domain, title: "User address domain" ) %> + <%= render FormElements::FieldsetResettableSettingComponent.new( + key: :mastodon_auth_token, + type: :password, + title: "API auth token" + ) %> <% end %> diff --git a/docs/dev/mastodon.md b/docs/dev/mastodon.md new file mode 100644 index 0000000..d010da3 --- /dev/null +++ b/docs/dev/mastodon.md @@ -0,0 +1,10 @@ +# Mastodon + +## API access + +(Optional) + +Log in to your Mastodon instance with an admin account and create a new +OAuth application: + +https://kosmos.social/settings/applications/new From c29176577786c48b718eb4f5fcaf087ea9193fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sat, 17 May 2025 16:44:13 +0400 Subject: [PATCH 2/7] Add mastodon_id to users --- db/migrate/20250517105755_add_mastodon_id_to_users.rb | 5 +++++ db/schema.rb | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20250517105755_add_mastodon_id_to_users.rb diff --git a/db/migrate/20250517105755_add_mastodon_id_to_users.rb b/db/migrate/20250517105755_add_mastodon_id_to_users.rb new file mode 100644 index 0000000..b6160c2 --- /dev/null +++ b/db/migrate/20250517105755_add_mastodon_id_to_users.rb @@ -0,0 +1,5 @@ +class AddMastodonIdToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :mastodon_id, :bigint + end +end diff --git a/db/schema.rb b/db/schema.rb index 32ff099..1140431 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_05_06_154628) do +ActiveRecord::Schema[8.0].define(version: 2025_05_17_105755) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -133,6 +133,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_06_154628) do t.string "pgp_fpr" t.string "lndhub_username" t.text "lndhub_password" + t.bigint "mastodon_id" t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end From df9077e3c17a1fade295a69e3600c19ae409c14c 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 3/7] 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/btcpay_manager_service.rb | 26 ++++----- app/services/mastodon_manager/fetch_user.rb | 12 ++++ app/services/mastodon_manager/find_user.rb | 14 +++++ .../mastodon_manager/sync_account_profiles.rb | 57 +++++++++++++++++++ app/services/mastodon_manager_service.rb | 26 ++++----- app/services/rest_api_service.rb | 38 ++++++------- .../user_manager/import_remote_avatar.rb | 43 ++++++++++++++ app/services/user_manager/process_avatar.rb | 21 +++++++ 9 files changed, 197 insertions(+), 60 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/btcpay_manager_service.rb b/app/services/btcpay_manager_service.rb index 245d784..146475a 100644 --- a/app/services/btcpay_manager_service.rb +++ b/app/services/btcpay_manager_service.rb @@ -4,19 +4,19 @@ class BtcpayManagerService < RestApiService private - def base_url - @base_url ||= "#{Setting.btcpay_api_url}/stores/#{Setting.btcpay_store_id}" - end + def base_url + @base_url ||= "#{Setting.btcpay_api_url}/stores/#{Setting.btcpay_store_id}" + end - def auth_token - @auth_token ||= Setting.btcpay_auth_token - end + def auth_token + @auth_token ||= Setting.btcpay_auth_token + end - def headers - { - "Content-Type" => "application/json", - "Accept" => "application/json", - "Authorization" => "token #{auth_token}" - } - end + def headers + { + "Content-Type" => "application/json", + "Accept" => "application/json", + "Authorization" => "token #{auth_token}" + } + 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/mastodon_manager_service.rb b/app/services/mastodon_manager_service.rb index 9c7fac9..6291a79 100644 --- a/app/services/mastodon_manager_service.rb +++ b/app/services/mastodon_manager_service.rb @@ -4,19 +4,19 @@ class MastodonManagerService < RestApiService private - def base_url - @base_url ||= "#{Setting.mastodon_public_url}/api" - end + def base_url + @base_url ||= "#{Setting.mastodon_public_url}/api" + end - def auth_token - @auth_token ||= Setting.mastodon_auth_token - end + def auth_token + @auth_token ||= Setting.mastodon_auth_token + end - def headers - { - "Content-Type" => "application/json", - "Accept" => "application/json", - "Authorization" => "Bearer #{auth_token}" - } - end + def headers + { + "Content-Type" => "application/json", + "Accept" => "application/json", + "Authorization" => "Bearer #{auth_token}" + } + end end diff --git a/app/services/rest_api_service.rb b/app/services/rest_api_service.rb index c0bd0c6..e46cfb3 100644 --- a/app/services/rest_api_service.rb +++ b/app/services/rest_api_service.rb @@ -1,27 +1,27 @@ class RestApiService < ApplicationService private - def base_url - raise NotImplementedError - end + def base_url + raise NotImplementedError + end - def headers - raise NotImplementedError - end + def headers + raise NotImplementedError + end - def endpoint_url(path) - "#{base_url}/#{path.gsub(/^\//, '')}" - end + def endpoint_url(path) + "#{base_url}/#{path.gsub(/^\//, '')}" + end - def get(path, params = {}) - res = Faraday.get endpoint_url(path), params, headers - # TODO handle unsuccessful responses with no valid JSON body - JSON.parse(res.body) - end + def get(path, params = {}) + res = Faraday.get endpoint_url(path), params, headers + # TODO handle unsuccessful responses with no valid JSON body + JSON.parse(res.body) + end - def post(path, payload) - res = Faraday.post endpoint_url(path), payload.to_json, headers - # TODO handle unsuccessful responses with no valid JSON body - JSON.parse(res.body) - end + def post(path, payload) + res = Faraday.post endpoint_url(path), payload.to_json, headers + # TODO handle unsuccessful responses with no valid JSON body + JSON.parse(res.body) + 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..64ca436 --- /dev/null +++ b/app/services/user_manager/import_remote_avatar.rb @@ -0,0 +1,43 @@ +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 + + private + + 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 From f0b541ee5031ffecffe810f008b93a100bee638e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sat, 17 May 2025 17:46:05 +0400 Subject: [PATCH 4/7] Add avatar to admin user page --- app/views/admin/users/show.html.erb | 32 +++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb index 1007d4a..2c101b8 100644 --- a/app/views/admin/users/show.html.erb +++ b/app/views/admin/users/show.html.erb @@ -89,14 +89,42 @@
-

LDAP

+

Avatar

+ <% if @user.avatar.attached? %> + + + + + + + + + + + + + + + +
Image + <%= image_tag image_url_for(@user.avatar), class: "h-20 w-20 rounded-lg" %> +
Content type + <%= @user.avatar.content_type %> +
Size + <%= number_to_human_size(@user.avatar.blob.byte_size) %> +
+ <% else %> +

No avatar uploaded

+ <% end %> + +

LDAP

Avatar <% if @ldap_avatar.present? %> - JPEG size: <%= @ldap_avatar.size %> + JPEG size: <%= number_to_human_size(@ldap_avatar.size) %> <% else %> — <% end %> From b3b7fe63596c2cc8e1e8076baa9970f950e45801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sat, 17 May 2025 17:46:22 +0400 Subject: [PATCH 5/7] Don't queue job when service isn't enabled --- app/controllers/settings_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 87a3e11..3bf05ae 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -36,7 +36,7 @@ class SettingsController < ApplicationController if @user.avatar_new.present? if store_user_avatar LdapManager::UpdateAvatar.call(user: @user) - XmppSetAvatarJob.perform_later(user: @user) + XmppSetAvatarJob.perform_later(user: @user) if Setting.ejabberd_enabled? else @validation_errors = @user.errors render :show, status: :unprocessable_entity and return From f0846308daf0997d1f54aa4933fb78b302bba978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sat, 17 May 2025 17:55:14 +0400 Subject: [PATCH 6/7] Only update other avatars in one place Prevent future mistakes --- app/controllers/settings_controller.rb | 3 +-- app/services/user_manager/import_remote_avatar.rb | 3 +-- app/services/user_manager/update_avatar.rb | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 app/services/user_manager/update_avatar.rb diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 3bf05ae..57691ad 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -35,8 +35,7 @@ class SettingsController < ApplicationController if @user.avatar_new.present? if store_user_avatar - LdapManager::UpdateAvatar.call(user: @user) - XmppSetAvatarJob.perform_later(user: @user) if Setting.ejabberd_enabled? + UserManager::UpdateAvatar.call(user: @user) else @validation_errors = @user.errors render :show, status: :unprocessable_entity and return diff --git a/app/services/user_manager/import_remote_avatar.rb b/app/services/user_manager/import_remote_avatar.rb index 64ca436..eb6e744 100644 --- a/app/services/user_manager/import_remote_avatar.rb +++ b/app/services/user_manager/import_remote_avatar.rb @@ -7,8 +7,7 @@ module UserManager def call if import_remote_avatar - LdapManager::UpdateAvatar.call(user: @user) - XmppSetAvatarJob.perform_later(user: @user) if Setting.ejabberd_enabled? + UserManager::UpdateAvatar.call(user: @user) end end diff --git a/app/services/user_manager/update_avatar.rb b/app/services/user_manager/update_avatar.rb new file mode 100644 index 0000000..d03f37b --- /dev/null +++ b/app/services/user_manager/update_avatar.rb @@ -0,0 +1,15 @@ +module UserManager + class UpdateAvatar < UserManagerService + def initialize(user:) + @user = user + end + + def call + LdapManager::UpdateAvatar.call(user: @user) + + if Setting.ejabberd_enabled? + XmppSetAvatarJob.perform_later(user: @user) + end + end + end +end From bfa151418113cb0579f2a101e15d802432f36a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sat, 17 May 2025 18:11:17 +0400 Subject: [PATCH 7/7] Update doc --- docs/dev/mastodon.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/dev/mastodon.md b/docs/dev/mastodon.md index d010da3..1ec24cd 100644 --- a/docs/dev/mastodon.md +++ b/docs/dev/mastodon.md @@ -4,7 +4,10 @@ (Optional) -Log in to your Mastodon instance with an admin account and create a new -OAuth application: - -https://kosmos.social/settings/applications/new +* Log in to your Mastodon instance with an admin account +* Create a new OAuth application (Settings -> Development) +* Select `admin:read` access in the permissions +* After confirming, click on the application name in the list, and copy the + access token +* Configure it as `MASTODON_AUTH_TOKEN` via environment variable, or on + Admin -> Settings -> Services -> Mastodon