Compare commits

...

8 Commits

Author SHA1 Message Date
4545c3ba19
Merge branch 'feature/mastodon_api' into live
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-17 18:12:57 +04:00
bfa1514181
Update doc
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-17 18:11:17 +04:00
f0846308da
Only update other avatars in one place
All checks were successful
continuous-integration/drone/push Build is passing
Prevent future mistakes
2025-05-17 18:02:13 +04:00
b3b7fe6359
Don't queue job when service isn't enabled 2025-05-17 18:02:13 +04:00
f0b541ee50
Add avatar to admin user page 2025-05-17 18:02:13 +04:00
df9077e3c1
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)
2025-05-17 18:02:07 +04:00
c291765777
Add mastodon_id to users 2025-05-17 16:44:13 +04:00
f0cfde560b
Add Mastodon API service class, auth token config
Add a new REST API service class to keep things DRY
2025-05-17 14:18:16 +04:00
16 changed files with 288 additions and 48 deletions

View File

@ -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)
UserManager::UpdateAvatar.call(user: @user)
else
@validation_errors = @user.errors
render :show, status: :unprocessable_entity and return
@ -193,7 +192,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 +215,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

View File

@ -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

View File

@ -1,36 +1,22 @@
#
# API Docs: https://docs.btcpayserver.org/API/Greenfield/v1/
#
class BtcpayManagerService < ApplicationService
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 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
def headers
{
"Content-Type" => "application/json",
"Accept" => "application/json",
"Authorization" => "token #{auth_token}"
}
end
end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,42 @@
module UserManager
class ImportRemoteAvatar < UserManagerService
def initialize(user:, avatar_url:)
@user = user
@avatar_url = avatar_url
end
def call
if import_remote_avatar
UserManager::UpdateAvatar.call(user: @user)
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

View File

@ -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

View File

@ -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

View File

@ -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 %>
</ul>

View File

@ -89,14 +89,42 @@
</section>
<section class="sm:flex-1 sm:pt-0">
<h3>LDAP</h3>
<h3>Avatar</h3>
<% if @user.avatar.attached? %>
<table class="divided">
<tbody>
<tr>
<th class="align-top">Image</th>
<td class="align-top">
<%= image_tag image_url_for(@user.avatar), class: "h-20 w-20 rounded-lg" %>
</td>
</tr>
<tr>
<th>Content type</th>
<td>
<%= @user.avatar.content_type %>
</td>
</tr>
<tr>
<th>Size</th>
<td>
<%= number_to_human_size(@user.avatar.blob.byte_size) %>
</td>
</tr>
</tbody>
</table>
<% else %>
<p class="text-gray-500">No avatar uploaded</p>
<% end %>
<h3 class="mt-12">LDAP</h3>
<table class="divided">
<tbody>
<tr>
<th>Avatar</th>
<td>
<% if @ldap_avatar.present? %>
JPEG size: <%= @ldap_avatar.size %>
JPEG size: <%= number_to_human_size(@ldap_avatar.size) %>
<% else %>
&mdash;
<% end %>

View File

@ -0,0 +1,5 @@
class AddMastodonIdToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :mastodon_id, :bigint
end
end

View File

@ -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

13
docs/dev/mastodon.md Normal file
View File

@ -0,0 +1,13 @@
# Mastodon
## API access
(Optional)
* 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