docbrown/app/models/user.rb
Jeremy Friesen a3441a318d
Adding ugc role to untrusted profile URLs (#15520)
Prior to this commit, we trusted all user profiles.  There are folks
using DEV.to profiles to help with their spam efforts.

With this commit, we're trying to thwart their efforts.

Closes forem/rfcs#334
2021-11-29 17:54:09 -05:00

676 lines
24 KiB
Ruby

class User < ApplicationRecord
resourcify
rolify
include CloudinaryHelper
include Storext.model
# NOTE: we are using an inline module to keep profile related things together.
concerning :Profiles do
included do
has_one :profile, dependent: :delete
# NOTE: There are rare cases were we want to skip this callback, primarily
# in tests. `skip_callback` modifies global state, which is not thread-safe
# and can cause hard to track down bugs. We use an instance-level attribute
# instead. See `spec/factories/profiles.rb` for an example.
attr_accessor :_skip_creating_profile
# All new users should automatically have a profile
after_create_commit -> { Profile.create(user: self) }, unless: :_skip_creating_profile
end
end
ANY_ADMIN_ROLES = %i[admin super_admin].freeze
USERNAME_MAX_LENGTH = 30
USERNAME_REGEXP = /\A[a-zA-Z0-9_]+\z/
MESSAGES = {
reserved_username: "username is reserved"
}.freeze
# follow the syntax in https://interledger.org/rfcs/0026-payment-pointers/#payment-pointer-syntax
PAYMENT_POINTER_REGEXP = %r{
\A # start
\$ # starts with a dollar sign
([a-zA-Z0-9\-.])+ # matches the hostname (ex ilp.uphold.com)
(/[\x20-\x7F]+)? # optional forward slash and identifier with printable ASCII characters
\z
}x
attr_accessor :scholar_email, :new_note, :note_for_current_role, :user_status, :merge_user_id,
:add_credits, :remove_credits, :add_org_credits, :remove_org_credits, :ip_address,
:current_password
acts_as_followable
acts_as_follower
has_one :notification_setting, class_name: "Users::NotificationSetting", dependent: :delete
has_one :setting, class_name: "Users::Setting", dependent: :delete
has_many :access_grants, class_name: "Doorkeeper::AccessGrant", foreign_key: :resource_owner_id,
inverse_of: :resource_owner, dependent: :delete_all
has_many :access_tokens, class_name: "Doorkeeper::AccessToken", foreign_key: :resource_owner_id,
inverse_of: :resource_owner, dependent: :delete_all
has_many :affected_feedback_messages, class_name: "FeedbackMessage",
inverse_of: :affected, foreign_key: :affected_id, dependent: :nullify
has_many :ahoy_events, class_name: "Ahoy::Event", dependent: :delete_all
has_many :ahoy_visits, class_name: "Ahoy::Visit", dependent: :delete_all
has_many :api_secrets, dependent: :delete_all
has_many :articles, dependent: :destroy
has_many :audit_logs, dependent: :nullify
has_many :authored_notes, inverse_of: :author, class_name: "Note", foreign_key: :author_id, dependent: :delete_all
has_many :badge_achievements, dependent: :delete_all
has_many :badge_achievements_rewarded, class_name: "BadgeAchievement", foreign_key: :rewarder_id,
inverse_of: :rewarder, dependent: :nullify
has_many :badges, through: :badge_achievements
has_many :banished_users, class_name: "BanishedUser", foreign_key: :banished_by_id,
inverse_of: :banished_by, dependent: :nullify
has_many :blocked_blocks, class_name: "UserBlock", foreign_key: :blocked_id,
inverse_of: :blocked, dependent: :delete_all
has_many :blocker_blocks, class_name: "UserBlock", foreign_key: :blocker_id,
inverse_of: :blocker, dependent: :delete_all
has_many :collections, dependent: :destroy
has_many :comments, dependent: :destroy
has_many :created_podcasts, class_name: "Podcast", foreign_key: :creator_id, inverse_of: :creator, dependent: :nullify
has_many :credits, dependent: :destroy
has_many :discussion_locks, dependent: :delete_all, inverse_of: :locking_user, foreign_key: :locking_user_id
has_many :display_ad_events, dependent: :delete_all
has_many :email_authorizations, dependent: :delete_all
has_many :email_messages, class_name: "Ahoy::Message", dependent: :destroy
has_many :field_test_memberships, class_name: "FieldTest::Membership", as: :participant, dependent: :destroy
# Consider that we might be able to use dependent: :delete_all as the GithubRepo busts the user cache
has_many :github_repos, dependent: :destroy
has_many :html_variants, dependent: :destroy
has_many :identities, dependent: :delete_all
has_many :identities_enabled, -> { enabled }, class_name: "Identity", inverse_of: false
has_many :listings, dependent: :destroy
has_many :mentions, dependent: :delete_all
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :delete_all
has_many :notification_subscriptions, dependent: :delete_all
has_many :notifications, dependent: :delete_all
has_many :offender_feedback_messages, class_name: "FeedbackMessage",
inverse_of: :offender, foreign_key: :offender_id, dependent: :nullify
has_many :organization_memberships, dependent: :delete_all
has_many :organizations, through: :organization_memberships
# we keep page views as they belong to the article, not to the user who viewed it
has_many :page_views, dependent: :nullify
has_many :podcast_episode_appearances, dependent: :delete_all, inverse_of: :user
has_many :podcast_episodes, through: :podcast_episode_appearances, source: :podcast_episode
has_many :podcast_ownerships, dependent: :delete_all, inverse_of: :owner
has_many :podcasts_owned, through: :podcast_ownerships, source: :podcast
has_many :poll_skips, dependent: :delete_all
has_many :poll_votes, dependent: :delete_all
has_many :profile_pins, as: :profile, inverse_of: :profile, dependent: :delete_all
# we keep rating votes as they belong to the article, not to the user who viewed it
has_many :rating_votes, dependent: :nullify
has_many :reactions, dependent: :destroy
has_many :reporter_feedback_messages, class_name: "FeedbackMessage",
inverse_of: :reporter, foreign_key: :reporter_id, dependent: :nullify
has_many :response_templates, inverse_of: :user, dependent: :delete_all
has_many :source_authored_user_subscriptions, class_name: "UserSubscription",
foreign_key: :author_id, inverse_of: :author, dependent: :destroy
has_many :subscribed_to_user_subscriptions, class_name: "UserSubscription",
foreign_key: :subscriber_id, inverse_of: :subscriber, dependent: :destroy
has_many :subscribers, through: :source_authored_user_subscriptions, dependent: :destroy
has_many :tweets, dependent: :nullify
has_many :webhook_endpoints, class_name: "Webhook::Endpoint", inverse_of: :user, dependent: :delete_all
has_many :devices, dependent: :delete_all
has_many :sponsorships, dependent: :delete_all
mount_uploader :profile_image, ProfileImageUploader
devise :invitable, :omniauthable, :registerable, :database_authenticatable, :confirmable, :rememberable,
:recoverable, :lockable
validates :articles_count, presence: true
validates :badge_achievements_count, presence: true
validates :blocked_by_count, presence: true
validates :blocking_others_count, presence: true
validates :comments_count, presence: true
validates :credits_count, presence: true
validates :email, length: { maximum: 50 }, email: true, allow_nil: true
validates :email, uniqueness: { allow_nil: true, case_sensitive: false }, if: :email_changed?
validates :following_orgs_count, presence: true
validates :following_tags_count, presence: true
validates :following_users_count, presence: true
validates :name, length: { in: 1..100 }
validates :password, length: { in: 8..100 }, allow_nil: true
validates :payment_pointer, format: PAYMENT_POINTER_REGEXP, allow_blank: true
validates :rating_votes_count, presence: true
validates :reactions_count, presence: true
validates :sign_in_count, presence: true
validates :spent_credits_count, presence: true
validates :subscribed_to_user_subscriptions_count, presence: true
validates :unspent_credits_count, presence: true
validates :username, length: { in: 2..USERNAME_MAX_LENGTH }, format: USERNAME_REGEXP
validates :username, presence: true, exclusion: { in: ReservedWords.all, message: MESSAGES[:invalid_username] }
validates :username, uniqueness: { case_sensitive: false, message: lambda do |_obj, data|
"#{data[:value]} is taken."
end }, if: :username_changed?
# add validators for provider related usernames
Authentication::Providers.username_fields.each do |username_field|
# make sure usernames are not empty string, to be able to use the database unique index
clean_provider_username = proc do |record|
cleaned_username = record.attributes[username_field.to_s].presence
record.assign_attributes(username_field => cleaned_username)
end
before_validation clean_provider_username
validates username_field, uniqueness: { allow_nil: true }, if: :"#{username_field}_changed?"
end
validate :non_banished_username, :username_changed?
validate :unique_including_orgs_and_podcasts, if: :username_changed?
validate :can_send_confirmation_email
validate :update_rate_limit
# NOTE: when updating the password on a Devise enabled model, the :encrypted_password
# field will be marked as dirty, not :password.
validate :password_matches_confirmation, if: :encrypted_password_changed?
alias_attribute :public_reactions_count, :reactions_count
scope :eager_load_serialized_data, -> { includes(:roles) }
scope :registered, -> { where(registered: true) }
# Unfortunately pg_search's default SQL query is not performant enough in this
# particular case (~ 500ms). There are multiple reasons:
# => creates a complex query like `SELECT FROM users INNER JOIN users` to compute ranking.
# See https://github.com/Casecommons/pg_search/issues/292#issuecomment-202604151
# => it concatenates the content of `name` and the content of `username` to match
# against the search term. By doing that, it can't use `tsvector` indexes correctly
#
# For these reasons we need to build a query manually using an `OR` condition,
# thus allowing the database to use the indexes properly. With this the SQL time is ~ 8-10ms.
#
# NOTE: we can't use unaccent() on the `tsvector` document because `unaccent()` can't be
# used in expression indexes as it's a mutable function and depends on server settings
# => https://stackoverflow.com/a/11007216/4186181
#
scope :search_by_name_and_username, lambda { |term|
where(
sanitize_sql_array(
[
"to_tsvector('simple', coalesce(name::text, '')) @@ to_tsquery('simple', ? || ':*')",
connection.quote(term),
],
),
).or(
where(
sanitize_sql_array(
[
"to_tsvector('simple', coalesce(username::text, '')) @@ to_tsquery('simple', ? || ':*')",
connection.quote(term),
],
),
),
)
}
before_validation :check_for_username_change
before_validation :downcase_email
# make sure usernames are not empty, to be able to use the database unique index
before_validation :verify_email
before_validation :set_username
before_validation :strip_payment_pointer
before_create :create_users_settings_and_notification_settings_records
before_destroy :unsubscribe_from_newsletters, prepend: true
before_destroy :destroy_follows, prepend: true
after_create_commit :send_welcome_notification
after_save :create_conditional_autovomits
after_commit :subscribe_to_mailchimp_newsletter
after_commit :bust_cache
def self.staff_account
find_by(id: Settings::Community.staff_user_id)
end
def self.mascot_account
find_by(id: Settings::General.mascot_user_id)
end
def tag_line
profile.summary
end
def twitter_url
"https://twitter.com/#{twitter_username}" if twitter_username.present?
end
def github_url
"https://github.com/#{github_username}" if github_username.present?
end
def set_remember_fields
self.remember_token ||= self.class.remember_token if respond_to?(:remember_token)
self.remember_created_at ||= Time.now.utc
end
def calculate_score
# User score is used to mitigate spam by reducing visibility of flagged users
# It can generally be used as a baseline for affecting certain functionality which
# relies on trust gray area.
# Current main use: If score is below zero, the user's profile page will render noindex
# meta tag. This is a subtle anti-spam mechanism.
# It can be changed as frequently as needed to do a better job reflecting its purpose
# Changes should generally keep the score within the same order of magnitude so that
# mass re-calculation is needed.
user_reaction_points = Reaction.user_vomits.where(reactable_id: id).sum(:points)
calculated_score = (badge_achievements_count * 10) + user_reaction_points
update_column(:score, calculated_score)
end
def path
"/#{username}"
end
# NOTE: This method is only used in the EmailDigestArticleCollector and does
# not perform particularly well. It should most likely not be used in other
# parts of the app.
def followed_articles
relation = Article
if cached_antifollowed_tag_names.any?
relation = relation.not_cached_tagged_with_any(cached_antifollowed_tag_names)
end
relation
.cached_tagged_with_any(cached_followed_tag_names)
.unscope(:select)
.union(Article.where(user_id: cached_following_users_ids))
end
def cached_following_users_ids
cache_key = "user-#{id}-#{last_followed_at}-#{following_users_count}/following_users_ids"
Rails.cache.fetch(cache_key, expires_in: 12.hours) do
Follow.follower_user(id).limit(150).pluck(:followable_id)
end
end
def cached_following_organizations_ids
cache_key = "user-#{id}-#{last_followed_at}-#{following_orgs_count}/following_organizations_ids"
Rails.cache.fetch(cache_key, expires_in: 12.hours) do
Follow.follower_organization(id).limit(150).pluck(:followable_id)
end
end
def cached_following_podcasts_ids
cache_key = "user-#{id}-#{last_followed_at}/following_podcasts_ids"
Rails.cache.fetch(cache_key, expires_in: 12.hours) do
Follow.follower_podcast(id).pluck(:followable_id)
end
end
def cached_reading_list_article_ids
Rails.cache.fetch("reading_list_ids_of_articles_#{id}_#{public_reactions_count}_#{last_reacted_at}") do
Reaction.readinglist.where(
user_id: id, reactable_type: "Article",
).where.not(status: "archived").order(created_at: :desc).pluck(:reactable_id)
end
end
def processed_website_url
profile.website_url.to_s.strip if profile.website_url.present?
end
def remember_me
true
end
def cached_followed_tag_names
cache_name = "user-#{id}-#{following_tags_count}-#{last_followed_at&.rfc3339}/followed_tag_names"
Rails.cache.fetch(cache_name, expires_in: 24.hours) do
Tag.where(
id: Follow.where(
follower_id: id,
followable_type: "ActsAsTaggableOn::Tag",
points: 1..,
).select(:followable_id),
).pluck(:name)
end
end
def cached_antifollowed_tag_names
cache_name = "user-#{id}-#{following_tags_count}-#{last_followed_at&.rfc3339}/antifollowed_tag_names"
Rails.cache.fetch(cache_name, expires_in: 24.hours) do
Tag.where(
id: Follow.where(
follower_id: id,
followable_type: "ActsAsTaggableOn::Tag",
points: ...1,
).select(:followable_id),
).pluck(:name)
end
end
def suspended?
has_role?(:suspended)
end
def warned
has_role?(:warned)
end
def admin?
has_role?(:super_admin)
end
def creator?
has_role?(:creator)
end
def any_admin?
@any_admin ||= roles.where(name: ANY_ADMIN_ROLES).any?
end
def tech_admin?
has_role?(:tech_admin) || has_role?(:super_admin)
end
def vomitted_on?
Reaction.exists?(reactable_id: id, reactable_type: "User", category: "vomit", status: "confirmed")
end
def trusted
return @trusted if defined? @trusted
@trusted = Rails.cache.fetch("user-#{id}/has_trusted_role", expires_in: 200.hours) do
has_role?(:trusted)
end
end
alias trusted? trusted
def moderator_for_tags
Rails.cache.fetch("user-#{id}/tag_moderators_list", expires_in: 200.hours) do
tag_ids = roles.where(name: "tag_moderator").pluck(:resource_id)
Tag.where(id: tag_ids).pluck(:name)
end
end
def comment_suspended?
has_role?(:comment_suspended)
end
def workshop_eligible?
has_any_role?(:workshop_pass)
end
def admin_organizations
org_ids = organization_memberships.where(type_of_user: "admin").pluck(:organization_id)
organizations.where(id: org_ids)
end
def member_organizations
org_ids = organization_memberships.where(type_of_user: %w[admin member]).pluck(:organization_id)
organizations.where(id: org_ids)
end
def org_member?(organization)
OrganizationMembership.exists?(user: self, organization: organization, type_of_user: %w[admin member])
end
def org_admin?(organization)
OrganizationMembership.exists?(user: self, organization: organization, type_of_user: "admin")
end
def block; end
def all_blocked_by
UserBlock.where(blocked_id: id)
end
def blocking?(blocked_id)
UserBlock.blocking?(id, blocked_id)
end
def blocked_by?(blocker_id)
UserBlock.blocking?(blocker_id, id)
end
def unique_including_orgs_and_podcasts
username_taken = (
Organization.exists?(slug: username) ||
Podcast.exists?(slug: username) ||
Page.exists?(slug: username)
)
errors.add(:username, "is taken.") if username_taken
end
def non_banished_username
errors.add(:username, "has been banished.") if BanishedUser.exists?(username: username)
end
def banished?
username.starts_with?("spam_")
end
def subscribe_to_mailchimp_newsletter
return unless registered && email.present?
return if Settings::General.mailchimp_api_key.blank?
return if saved_changes.key?(:unconfirmed_email) && saved_changes.key?(:confirmation_sent_at)
return unless saved_changes.key?(:email)
Users::SubscribeToMailchimpNewsletterWorker.perform_async(id)
end
def a_sustaining_member?
monthly_dues.positive?
end
def resave_articles
articles.find_each do |article|
if article.path
cache_bust = EdgeCache::Bust.new
cache_bust.call(article.path)
cache_bust.call("#{article.path}?i=i")
end
article.save
end
end
def profile_image_90
Images::Profile.call(profile_image_url, length: 90)
end
def unsubscribe_from_newsletters
return if email.blank?
return if Settings::General.mailchimp_api_key.blank?
Mailchimp::Bot.new(self).unsubscribe_all_newsletters
end
def auditable?
trusted || tag_moderator? || any_admin?
end
def tag_moderator?
roles.where(name: "tag_moderator").any?
end
def enough_credits?(num_credits_needed)
credits.unspent.size >= num_credits_needed
end
def receives_follower_email_notifications?
email.present? && subscribed_to_email_follower_notifications?
end
def hotness_score
search_score
end
def authenticated_through?(provider_name)
return false unless Authentication::Providers.available?(provider_name)
return false unless Authentication::Providers.enabled?(provider_name)
identities_enabled.exists?(provider: provider_name)
end
def authenticated_with_all_providers?
# ga_providers refers to Generally Available (not in beta)
ga_providers = Authentication::Providers.enabled.reject { |sym| sym == :apple }
enabled_providers = identities.pluck(:provider).map(&:to_sym)
(ga_providers - enabled_providers).empty?
end
def rate_limiter
RateLimitChecker.new(self)
end
def flipper_id
"User:#{id}"
end
def subscribed_to_welcome_notifications?
notification_setting.welcome_notifications
end
def subscribed_to_mod_roundrobin_notifications?
notification_setting.mod_roundrobin_notifications
end
def subscribed_to_email_follower_notifications?
notification_setting.email_follower_notifications
end
protected
# Send emails asynchronously
# see https://github.com/heartcombo/devise#activejob-integration
def send_devise_notification(notification, *args)
message = devise_mailer.public_send(notification, self, *args)
message.deliver_later
end
private
def create_users_settings_and_notification_settings_records
self.setting = Users::Setting.create
self.notification_setting = Users::NotificationSetting.create
end
def send_welcome_notification
return unless (set_up_profile_broadcast = Broadcast.active.find_by(title: "Welcome Notification: set_up_profile"))
Notification.send_welcome_notification(id, set_up_profile_broadcast.id)
end
def verify_email
self.email = nil if email == ""
end
def set_username
set_temp_username if username.blank?
self.username = username&.downcase
end
def set_temp_username
self.username = if temp_name_exists?
"#{temp_username}_#{rand(100)}"
else
temp_username
end
end
def temp_name_exists?
User.exists?(username: temp_username) || Organization.exists?(slug: temp_username)
end
def temp_username
Authentication::Providers.username_fields.each do |username_field|
value = public_send(username_field)
next if value.blank?
return value.downcase.gsub(/[^0-9a-z_]/i, "").delete(" ")
end
end
def downcase_email
self.email = email.downcase if email
end
def check_for_username_change
return unless username_changed?
self.old_old_username = old_username
self.old_username = username_was
articles.find_each do |article|
article.path = article.path.gsub(username_was, username)
article.save
end
end
def bust_cache
Users::BustCacheWorker.perform_async(id)
end
def create_conditional_autovomits
return unless Settings::RateLimit.spam_trigger_terms.any? do |term|
name.match?(/#{term}/i)
end
Reaction.create!(
user_id: Settings::General.mascot_user_id,
reactable_id: id,
reactable_type: "User",
category: "vomit",
)
end
# TODO: @citizen428 I don't want to completely remove this method yet, as we
# have similar methods in other models. But the previous implementation used
# three profile fields that we can't guarantee to exist across all Forems. So
# for now this method will just return an empty string.
def tag_keywords_for_search
""
end
# TODO: this can be removed once we migrate away from ES
def search_score
counts_score = (articles_count + comments_count + reactions_count + badge_achievements_count) * 10
score = (counts_score + tag_keywords_for_search.size) * reputation_modifier
score.to_i
end
def destroy_follows
follower_relationships = Follow.followable_user(id)
follower_relationships.destroy_all
follows.destroy_all
end
def can_send_confirmation_email
return if changes[:email].blank? || id.blank?
rate_limiter.track_limit_by_action(:send_email_confirmation)
rate_limiter.check_limit!(:send_email_confirmation)
rescue RateLimitChecker::LimitReached => e
errors.add(:email, "confirmation could not be sent. #{e.message}")
end
def update_rate_limit
return unless persisted?
rate_limiter.track_limit_by_action(:user_update)
rate_limiter.check_limit!(:user_update)
rescue RateLimitChecker::LimitReached => e
errors.add(:base, "User could not be saved. #{e.message}")
end
def password_matches_confirmation
return true if password == password_confirmation
errors.add(:password, "doesn't match password confirmation")
end
def strip_payment_pointer
self.payment_pointer = payment_pointer.strip if payment_pointer
end
def confirmation_required?
ForemInstance.smtp_enabled?
end
end