Skip to content

Commit

Permalink
✨ Add WebFinger / ActivityPub avatar provider
Browse files Browse the repository at this point in the history
This patch introduces a new avatar provider based on the changes from
the previous commits.

The WebFinger / ActivityPub provider ("webfinger" internally)
loosely interprets the avatar field in a user as an RFC 7565 acct URI,
the library used also allows only specifiying a domain name though.

Based on this it will contact the host and query the WebFinger endpoint.

If the WebFinger response contains an avatar relation, the link contained
in that relation will used.

Only some fediverse software supports this however, as such we also
implement a tiny bit of ActivityPub and directly query the user using
its "application/activity+json" relation.

Given a Mastodon url (and optionally an access token), mete can also
use the Mastodon client api to resolve the actor returned by WebFinger,
which is generally more reliable than speaking ActivityPub directly, as
requests can be signed by the Mastodon server.

The results of these lookups will be cached for one day and can be
invalidated by updating the user from the UI.
  • Loading branch information
networkException committed Oct 25, 2024
1 parent aab3e66 commit e0c9078
Show file tree
Hide file tree
Showing 10 changed files with 128 additions and 8 deletions.
Binary file added app/assets/images/webfinger-missing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions app/assets/stylesheets/application.css.scss
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ img.disabled {
opacity: 0.3;
}

.webfinger-missing {
background-image: image_url('webfinger-missing.png');
background-size: contain;
background-repeat: no-repeat;
width: 80px !important;
height: 80px !important;
}

.drink-panel img {
height: 10rem;
width: 10rem;
Expand Down
5 changes: 5 additions & 0 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ def update
flash[:info] = "Deleted all your logs."
end
end

if @user.avatar_provider = "webfinger"
Rails.cache.delete "fetch_avatar_url_from_webfinger_or_activitypub #{@user.avatar}"
end

flash[:success] = "User was successfully updated."
no_resp_redir @user
else
Expand Down
107 changes: 105 additions & 2 deletions app/helpers/users_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,114 @@ module UsersHelper

# see also: /config/initializers/gravatar_image_tag.rb

def gravatar(user)
gravatar_image_tag user.email, class: user.active? ? "" : "disabled"
def avatar(user)
case user.avatar_provider
when 'gravatar'
gravatar_image_tag user.avatar, class: user.active? ? '' : 'disabled'
when 'webfinger'
webfinger_activitypub_image_tag user.avatar
end
end

def redirect_path(user)
return users_path + '/#' + user.initial
end

private

def http_client
Faraday.new Mete::Application.config.avatar_mastodon_client_url do |faraday|
faraday.headers[:user_agent] = "Metekasse (#{Faraday::Connection::USER_AGENT})"
faraday.response :json
faraday.response :follow_redirects
faraday.adapter Faraday.default_adapter
end
end

# NOTE: This function attempts to retrieve an avatar url by directly
# talking ActivityPub to an endpoint discovered by Webfinger.
# This will fail in case servers require signed requests.
def fetch_avatar_url_from_activitypub_server(activity_json_url)
activity_json_response = http_client.get(activity_json_url) do |request|
request.headers[:accept] = 'application/activity+json'
end

Rails.logger.debug "Using activity+json '#{activity_json_url}' fetched directly, resolving to '#{activity_json_response.body.dig('icon', 'url')}'"

activity_json_response.body.dig('icon', 'url')
end

# NOTE: This function attempts to retrieve an avatar url by talking to a Mastodon
# server and looking up an account. This request will optionally include
# authentication against the Mastodon API.
def fetch_avatar_url_using_mastodon_client(subject)
http_client.url_prefix = Mete::Application.config.avatar_mastodon_client_url

mastodon_response = http_client.get('/api/v1/accounts/lookup') do |request|
request.params[:acct] = subject.delete_prefix('acct:')
request.headers[:authorization] = "Bearer #{Mete::Application.config.avatar_mastodon_client_token}"
end

Rails.logger.debug "Using subject '#{subject}' fetched via Mastodon API on #{Mete::Application.config.avatar_mastodon_client_url}, resolving to '#{mastodon_response.body.dig('avatar')}'"

mastodon_response.body.dig('avatar')
end

def fetch_avatar_url_from_webfinger_or_activitypub(identifier)
# NOTE: The WebFinger gem supports identifiers like "gnom.is" without a "user@",
# allowing the somewhat common practice of serving a static webfinger file.
# It does however not support something like "@ordnung@chaos.social", only
# "ordnung@chaos.social", as such we trim an initial "@" here.
webfinger_response = WebFinger.discover! identifier.delete_prefix('@') rescue return

return unless webfinger_response.is_a? Hash

webfinger_subject = webfinger_response[:subject]
webfinger_links = webfinger_response[:links]

return unless webfinger_subject.is_a? String
return unless webfinger_links.is_a? Enumerable

# NOTE: This is a standard relation that not much fediverse software seems to implement currently.
# Since first working on this Mete feature, Mastodon 4.2.0 and newer now have support!
webfinger_avatar_relation = webfinger_links.find do |entry|
entry.is_a?(Hash) &&
entry[:rel] == 'http://webfinger.net/rel/avatar' &&
entry[:href].is_a?(String)
end

activity_json_relation = webfinger_links.find do |entry|
entry.is_a?(Hash) &&
entry[:rel] == 'self' &&
entry[:type] == 'application/activity+json' &&
entry[:href].is_a?(String)
end

Rails.logger.debug "Resolved identifier '#{identifier}' with webfinger to subject '#{webfinger_subject}', avatar relation '#{webfinger_avatar_relation&.fetch(:href)}' and self activity+json relation '#{activity_json_relation&.fetch(:href)}'"

if webfinger_avatar_relation
Rails.logger.debug "Using http://webfinger.net/rel/avatar '#{webfinger_avatar_relation[:href]}'"
webfinger_avatar_relation[:href]
elsif Mete::Application.config.avatar_mastodon_client_url
fetch_avatar_url_using_mastodon_client webfinger_subject rescue return
else
fetch_avatar_url_from_activitypub_server activity_json_relation[:href] rescue return
end
end

def webfinger_activitypub_image_tag(identifier)
avatar_url = Rails.cache.fetch("fetch_avatar_url_from_webfinger_or_activitypub #{identifier}", expires_in: 1.day) do
fetch_avatar_url_from_webfinger_or_activitypub identifier
end

if not avatar_url
return tag 'img', class: [ 'webfinger-missing' ]
end

options = {}
options[:src] = avatar_url
options[:alt] = "Profile picture for #{identifier}"
options[:height] = options[:width] = 80
tag 'img', options, false, false
end
end
2 changes: 1 addition & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
class User < ApplicationRecord
validates :name, presence: true

enum :avatar_provider, [ :gravatar ]
enum :avatar_provider, [ :gravatar, :webfinger ]

scope :order_by_name_asc, -> {
order(arel_table['name'].lower.asc)
Expand Down
4 changes: 2 additions & 2 deletions app/views/users/_form.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
.form-inputs
= f.input :name, hint: 'your nickname'
= f.input :avatar_provider,
:collection => [['Gravatar', 'gravatar']],
:collection => [['Gravatar', 'gravatar'], ['WebFinger / ActivityPub', 'webfinger']],
:as => :radio_buttons,
:hint => "This determines how the identifier below will be interpreted. Use an email for Gravatar"
:hint => "This determines how the identifier below will be interpreted. Use an email for Gravatar and a fediverse username for WebFinger / ActivityPub"
= f.input :avatar, hint: 'An identifier for your avatar'
= f.input :balance, :as => :decimal, hint: 'just in case you need to correct this'
= f.input :active
Expand Down
2 changes: 1 addition & 1 deletion app/views/users/_user.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
.inner-header
%h6= user.name
.card-body
= gravatar(user)
= avatar(user)
4 changes: 2 additions & 2 deletions app/views/users/show.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@

.row
.col-4.col-sm-3.col-md-2.col-lg-2.col-xl-1#user-page-avatar-holder
= gravatar(@user)
= avatar(@user)
.col-8.col-sm-9.col-md-10.col-lg-10.col-xl-11
%dl.dl-horizontal
%dt
= @user.avatar_provider == "gravatar" ? "Gravatar" : nil
= @user.avatar_provider == "gravatar" ? "Gravatar" : "WebFinger / ActivityPub"
%dd
= @user.avatar
%dt
Expand Down
2 changes: 2 additions & 0 deletions config/initializers/secret_token.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
# Make sure the secret is at least 30 characters and all random,
# no regular words or you'll be exposed to dictionary attacks.
Mete::Application.config.secret_key_base = '48eb56a7caee5ffb645221a26e85901d57ce45f4c74b54a1723d4062c7a60e29f0c46999e571e45a5562a13f0fcd20010137f019e37f12e88622a0d62b900a4f'
Mete::Application.config.avatar_mastodon_client_token = nil
Mete::Application.config.avatar_mastodon_client_url = nil
2 changes: 2 additions & 0 deletions config/secrets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ test:
# instead read values from the environment.
production:
secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>
avatar_mastodon_client_token: <%= ENV["AVATAR_MASTODON_CLIENT_TOKEN"] %>
avatar_mastodon_client_url: <%= ENV["AVATAR_MASTODON_CLIENT_URL"] %>

0 comments on commit e0c9078

Please # to comment.