app/models/user.rb
# frozen_string_literal: true
# Authentication object for the application. Also holds personal details
# like display name and private key information for identifying over
# ActivityPub/Webfinger.
class User < ApplicationRecord
class ResourceNotFound < ActiveRecord::RecordNotFound
def initialize(resource)
super "Couldn't find User from Webfinger resource #{resource}"
end
end
extend FriendlyId
include Federatable
include Searchable
# Generate 2048-bit keys
KEY_LENGTH = 2048
# Use the `des-ede3-cbc` cipher
KEY_CIPHER = 'des3'
FIELDS = %w(name display_name email)
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
:confirmable, :lockable, :timeoutable, :trackable,
:omniauthable
acts_as_follower
acts_as_liker
acts_as_followable
acts_as_mentionable
has_one_attached :avatar
has_secure_token
has_many :tracks
has_many :releases
has_many :access_grants, class_name: 'Doorkeeper::AccessGrant',
foreign_key: :resource_owner_id,
dependent: :delete_all
has_many :access_tokens, class_name: 'Doorkeeper::AccessToken',
foreign_key: :resource_owner_id,
dependent: :delete_all
delegate :as_webfinger, to: :actor
friendly_id :name, use: %i[slugged finders]
before_validation :generate_key, if: :encrypted_password_changed?
before_validation :ensure_host
before_validation :ensure_display_name
validates :name, presence: true, uniqueness: { if: :local? }
validates :host, presence: true
validates :display_name, presence: true
validates :key_pem, presence: true, uniqueness: true
validates :avatar, content_type: {
allow_blank: true,
in: %w(
image/jpeg
image/png
image/gif
)
}
alias_attribute :likes_count, :likees_count
settings index: { number_of_shards: 1 } do
mappings dynamic: 'false' do
indexes :name
indexes :display_name, analyzer: 'english'
end
end
# Find a given +User+ record by its Webfinger resource string. Does
# not attempt to find users that are not from the local server.
#
# @return [User] or +nil+ if it cannot be found.
def self.find_by_resource(resource)
name, host = resource.gsub(/acct:/, '').split('@')
find_by(name: name, host: host)
end
# Create a new +User+ record from an actor ID.
#
# @return [User]
def self.find_or_create_by_actor_id(actor_id)
actor = ActivityPub::Actor.find(actor_id)
find_or_create_by(name: actor.name, host: actor.host) do |user|
user.password = SecureRandom.hex
user.display_name = actor.summary
user.confirmed_at = Time.current
user.save!
end
end
# Find or create a User record from OAuth details.
#
# @param [OmniAuth::AuthHash]
# @return [User]
def self.from_omniauth(auth_hash)
user = find_or_create_by(provider: auth.provider, uid: auth.uid) do
user.email = auth.info.email
user.password = Devise.friendly_token[0, 20]
user.name = auth.info.name
user.display_name = auth.info.display_name
user.skip_confirmation!
end
end
# Append additional data to the session when logged in from OAuth.
#
# @param [ActionController::Parameters] params - Request params
# @param [ActionDispatch::Session] session - Session data for the request
# @return [User]
def self.new_with_session(params, session)
super.tap do |user|
if data = session['devise.mastodon_data'] && session['devise.mastodon_data']['extra']['raw_info']
user.email = data['email'] if user.email.blank?
user.name = data['name'] if user.email.blank?
user.display_name = data['display_name'] if user.email.blank?
end
end
end
# All tracks that this User has liked.
#
# @return [Array<Track>]
def likes
likees(Track)
end
# External ActivityPub ID for this User.
#
# @return [String] +username@domain.host+
def handle
"#{name}@#{host}"
end
# ActivityPub representation of this User.
#
# @return [ActivityPub::Actor] Object for representing this User in
# ActivityPub requests.
def actor
ActivityPub::Actor.new(
name: name,
summary: display_name,
host: host,
key: key_pem,
secret: encrypted_password
)
end
# Email is only required for local users when they are created.
def email_required?
host == Rails.configuration.host
end
# Express activity related to this user as a "Profile" object.
#
# @return [Hash]
def as_activity
super.merge(
type: 'Profile',
summary: display_name,
describes: {
type: 'Person',
name: name
}
)
end
# Send email notifications with ActiveJob.
def send_devise_notification(notification, *args)
devise_mailer.send(notification, self, *args).deliver_later
end
def avatar_image(resize: 100)
unless avatar.attached?
return "https://api.adorable.io/avatars/#{resize}/#{name}.png"
end
avatar.variant(resize: resize)
end
# All activity by users this user follows.
#
# @return [ActiveRecord::Relation]
def activities
User::Timeline.new(self)
end
def to_param
name
end
def following_users
followees(User)
end
def follower_users
followers(User)
end
def followers_count
follower_users.count
end
def following_count
following_users.count
end
def local?
host == Rails.configuration.host
end
def activity_id
Rails.application.routes.url_helpers.user_url(self, host: host)
end
private
# Generate a private key for this +User+ when created or when the
# password changes. Uses +User::KEY_LENGTH+ to determine length.
#
# @private
def generate_key
self.key_pem = OpenSSL::PKey::RSA.new(KEY_LENGTH)
.to_pem(cipher, encrypted_password)
end
# Cipher used to generate RSA keys, configured by +User::KEY_CIPHER+.
#
# @private
# @return [OpenSSL::Cipher]
def cipher
@cipher ||= OpenSSL::Cipher.new(KEY_CIPHER)
end
# Ensure +:host+ is set to the local host if blank. Blank hosts mean
# the user was created locally, and thus should be assigned the local
# host in the DB.
#
# @private
def ensure_host
self.host = Rails.configuration.host if host.blank?
end
# Set the +display_name+ to the user's +name+ if not explicitly set.
#
# @private
def ensure_display_name
self.display_name ||= name
end
end