diff options
Diffstat (limited to 'app/models')
29 files changed, 763 insertions, 71 deletions
diff --git a/app/models/account.rb b/app/models/account.rb index 3a6b38181..228e36755 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -50,6 +50,12 @@ # avatar_storage_schema_version :integer # header_storage_schema_version :integer # devices_url :string +# require_dereference :boolean default(FALSE), not null +# show_replies :boolean default(TRUE), not null +# show_unlisted :boolean default(TRUE), not null +# private :boolean default(FALSE), not null +# require_auth :boolean default(FALSE), not null +# last_synced_at :datetime # class Account < ApplicationRecord @@ -115,6 +121,7 @@ class Account < ApplicationRecord scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) } scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) } scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) } + scope :random, -> { reorder(Arel.sql('RANDOM()')).limit(1) } delegate :email, :unconfirmed_email, @@ -354,6 +361,38 @@ class Account < ApplicationRecord shared_inbox_url.presence || inbox_url end + def max_visibility_for_domain(domain) + return 'public' if domain.blank? + + domain_permissions.find_by(domain: [domain, '*'])&.visibility || 'public' + end + + def visibility_for_domain(domain) + v = visibility.to_s + return v if domain.blank? + + case max_visibility_for_domain(domain) + when 'public' + v + when 'unlisted' + v == 'public' ? 'unlisted' : v + when 'private' + %w(public unlisted).include?(v) ? 'private' : v + when 'direct' + 'direct' + else + v != 'direct' ? 'limited' : 'direct' + end + end + + def public_domain_permissions? + domain_permissions.where(visibility: [:public, :unlisted]).exists? + end + + def private_domain_permissions? + domain_permissions.where(visibility: [:private, :direct, :limited]).exists? + end + class Field < ActiveModelSerializers::Model attributes :name, :value, :verified_at, :account, :errors @@ -522,6 +561,8 @@ class Account < ApplicationRecord before_validation :prepare_username, on: :create before_destroy :clean_feed_manager + after_create_commit :set_metadata, if: :local? + private def prepare_contents @@ -565,4 +606,8 @@ class Account < ApplicationRecord end end end + + def set_metadata + self.metadata = AccountMetadata.new(account_id: id, fields: {}) if metadata.nil? + end end diff --git a/app/models/account_domain_permission.rb b/app/models/account_domain_permission.rb new file mode 100644 index 000000000..9e77950f2 --- /dev/null +++ b/app/models/account_domain_permission.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: account_domain_permissions +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# domain :string default(""), not null +# visibility :integer default("public"), not null +# sticky :boolean default(FALSE), not null +# + +class AccountDomainPermission < ApplicationRecord + include Paginable + include Cacheable + + validates :domain, presence: true, uniqueness: { scope: :account_id } + validates :visibility, presence: true + + belongs_to :account, inverse_of: :domain_permissions + enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility + + default_scope { order(domain: :desc) } + + cache_associated :account + + class << self + def create_by_domains(permissions_list) + Array(permissions_list).map(&method(:normalize)).map do |permissions| + where(**permissions).first_or_create + end + end + + def create_by_domains!(permissions_list) + Array(permissions_list).map(&method(:normalize)).map do |permissions| + where(**permissions).first_or_create! + end + end + + def create_or_update(domain_permissions) + domain_permissions = normalize(domain_permissions) + permissions = find_by(domain: domain_permissions[:domain]) + if permissions.present? + permissions.update(**domain_permissions) unless permissions.sticky? && %w(direct limited private).include?(domain_permissions[:visibility].to_s) + else + create(**domain_permissions) + end + permissions + end + + def create_or_update!(domain_permissions) + domain_permissions = normalize(domain_permissions) + permissions = find_by(domain: domain_permissions[:domain]) + if permissions.present? + permissions.update!(**domain_permissions) unless permissions.sticky? && %w(direct limited private).include?(domain_permissions[:visibility].to_s) + else + create!(**domain_permissions) + end + permissions + end + + private + + def normalize(hash) + hash.symbolize_keys! + hash[:domain] = hash[:domain].strip.downcase + hash.compact + end + end +end diff --git a/app/models/account_metadata.rb b/app/models/account_metadata.rb new file mode 100644 index 000000000..bb0f7676e --- /dev/null +++ b/app/models/account_metadata.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: account_metadata +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# fields :jsonb not null +# + +class AccountMetadata < ApplicationRecord + include Cacheable + + belongs_to :account, inverse_of: :metadata + cache_associated :account + + def fields + self[:fields].presence || {} + end + + def fields_json + fields.select { |name, _| name.start_with?('custom:') } + .map do |name, value| + { + '@context': { + schema: 'http://schema.org/', + name: 'schema:name', + value: 'schema:value', + }, + type: 'PropertyValue', + name: name, + value: value.is_a?(Array) ? value.join("\r\n") : value, + } + end + end + + def cached_fields_json + Rails.cache.fetch("custom_metadata:#{account_id}", expires_in: 1.hour) do + fields_json + end + end + + class << self + def create_or_update(fields) + create(fields).presence || update(fields) + end + + def create_or_update!(fields) + create(fields).presence || update!(fields) + end + end +end diff --git a/app/models/collection_item.rb b/app/models/collection_item.rb new file mode 100644 index 000000000..24aaf66d4 --- /dev/null +++ b/app/models/collection_item.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: collection_items +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# uri :string not null +# processed :boolean default(FALSE), not null +# retries :integer default(0), not null +# + +class CollectionItem < ApplicationRecord + belongs_to :account, inverse_of: :collection_items, optional: true + + default_scope { order(id: :desc) } + scope :unprocessed, -> { where(processed: false) } + scope :joins_on_collection_pages, -> { joins('LEFT OUTER JOIN collection_pages ON collection_pages.account_id = collection_items.account_id') } + scope :inactive, -> { joins_on_collection_pages.where('collection_pages.account_id IS NULL') } + scope :active, -> { joins_on_collection_pages.where('collection_pages.account_id IS NOT NULL') } +end diff --git a/app/models/collection_page.rb b/app/models/collection_page.rb new file mode 100644 index 000000000..e974e58a2 --- /dev/null +++ b/app/models/collection_page.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: collection_pages +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# uri :string not null +# next :string +# + +class CollectionPage < ApplicationRecord + belongs_to :account, inverse_of: :collection_pages, optional: true + + default_scope { order(id: :desc) } + scope :current, -> { where(next: nil) } +end diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index 98849f8fc..ec4e18699 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -63,5 +63,23 @@ module AccountAssociations # Account deletion requests has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy + + # Domain permissions + has_many :domain_permissions, class_name: 'AccountDomainPermission', inverse_of: :account, dependent: :destroy + + # Custom metadata + has_one :metadata, class_name: 'AccountMetadata', inverse_of: :account, dependent: :destroy + + # Queued boosts + has_many :queued_boosts, inverse_of: :account, dependent: :destroy + + # Collection pages + has_many :collection_pages, inverse_of: :account, dependent: :destroy + + # Collection items + has_many :collection_items, inverse_of: :account, dependent: :destroy + + # Custom emojis + has_many :custom_emojis, inverse_of: :account, dependent: :nullify end end diff --git a/app/models/concerns/account_finder_concern.rb b/app/models/concerns/account_finder_concern.rb index 04b2c981b..c414a6d87 100644 --- a/app/models/concerns/account_finder_concern.rb +++ b/app/models/concerns/account_finder_concern.rb @@ -16,6 +16,10 @@ module AccountFinderConcern Account.find(-99) end + def site_contact + Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')).presence || representative + end + def find_local(username) find_remote(username, nil) end diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 427ebdae2..184064e94 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -26,7 +26,7 @@ module AccountInteractions end def muting_map(target_account_ids, account_id) - Mute.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |mute, mapping| + Mute.where(target_account_id: target_account_ids, account_id: account_id, timelines_only: false).each_with_object({}) do |mute, mapping| mapping[mute.target_account_id] = { notifications: mute.hide_notifications?, } @@ -92,9 +92,10 @@ module AccountInteractions has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account has_many :muted_by_relationships, class_name: 'Mute', foreign_key: :target_account_id, dependent: :destroy has_many :muted_by, -> { order('mutes.id desc') }, through: :muted_by_relationships, source: :account - has_many :conversation_mutes, dependent: :destroy + has_many :conversation_mutes, inverse_of: :account, dependent: :destroy has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy has_many :announcement_mutes, dependent: :destroy + has_many :status_mutes, inverse_of: :account, dependent: :destroy end def follow!(other_account, reblogs: nil, notify: nil, uri: nil, rate_limit: false) @@ -131,15 +132,14 @@ module AccountInteractions .find_or_create_by!(target_account: other_account) end - def mute!(other_account, notifications: nil) + def mute!(other_account, notifications: nil, timelines_only: nil) notifications = true if notifications.nil? - mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account) + timelines_only = false if timelines_only.nil? + mute = mute_relationships.create_with(hide_notifications: notifications, timelines_only: timelines_only).find_or_create_by!(target_account: other_account) remove_potential_friendship(other_account) # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't. - if mute.hide_notifications? != notifications - mute.update!(hide_notifications: notifications) - end + mute.update!(hide_notifications: notifications, timelines_only: timelines_only) if mute.hide_notifications? != notifications mute end @@ -177,6 +177,15 @@ module AccountInteractions block&.destroy end + def mute_status!(status) + status_mutes.find_or_create_by!(status: status) + end + + def unmute_status!(status) + mute = status_mutes.find_by(status: status) + mute&.destroy + end + def following?(other_account) active_relationships.where(target_account: other_account).exists? end @@ -190,7 +199,7 @@ module AccountInteractions end def muting?(other_account) - mute_relationships.where(target_account: other_account).exists? + mute_relationships.where(target_account: other_account, timelines_only: false).exists? end def muting_conversation?(conversation) @@ -205,6 +214,10 @@ module AccountInteractions active_relationships.where(target_account: other_account, show_reblogs: false).exists? end + def muting_status?(status) + status_mutes.where(status: status).exists? + end + def requested?(other_account) follow_requests.where(target_account: other_account).exists? end diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb index a0ead1995..50d081811 100644 --- a/app/models/concerns/status_threading_concern.rb +++ b/app/models/concerns/status_threading_concern.rb @@ -86,7 +86,7 @@ module StatusThreadingConcern domains = statuses.map(&:account_domain).compact.uniq relations = relations_map_for_account(account, account_ids, domains) - statuses.reject! { |status| StatusFilter.new(status, account, relations).filtered? } + statuses.reject! { |status| StatusFilter.new(status, account, false, relations).filtered? } # Order ancestors/descendants by tree path statuses.sort_by! { |status| ids.index(status.id) } diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 4dfaea889..6f776e052 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -7,12 +7,14 @@ # uri :string # created_at :datetime not null # updated_at :datetime not null +# root :string # class Conversation < ApplicationRecord validates :uri, uniqueness: true, if: :uri? has_many :statuses + has_many :mutes, class_name: 'ConversationMute', inverse_of: :conversation, dependent: :destroy def local? uri.nil? diff --git a/app/models/conversation_mute.rb b/app/models/conversation_mute.rb index 52c1a33e0..5d56a3172 100644 --- a/app/models/conversation_mute.rb +++ b/app/models/conversation_mute.rb @@ -6,9 +6,10 @@ # id :bigint(8) not null, primary key # conversation_id :bigint(8) not null # account_id :bigint(8) not null +# hidden :boolean default(FALSE), not null # class ConversationMute < ApplicationRecord - belongs_to :account - belongs_to :conversation + belongs_to :account, inverse_of: :conversation_mutes + belongs_to :conversation, inverse_of: :mutes end diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index 7cb03b819..c819288ba 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -18,6 +18,7 @@ # visible_in_picker :boolean default(TRUE), not null # category_id :bigint(8) # image_storage_schema_version :integer +# account_id :bigint(8) # class CustomEmoji < ApplicationRecord @@ -32,6 +33,7 @@ class CustomEmoji < ApplicationRecord IMAGE_MIME_TYPES = %w(image/png image/gif).freeze belongs_to :category, class_name: 'CustomEmojiCategory', optional: true + belongs_to :account, inverse_of: :custom_emojis, optional: true has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } } @@ -46,6 +48,7 @@ class CustomEmoji < ApplicationRecord scope :alphabetic, -> { order(domain: :asc, shortcode: :asc) } scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) } scope :listed, -> { local.where(disabled: false).where(visible_in_picker: true) } + scope :owned_by, ->(account) { where(account: account) } remotable_attachment :image, LIMIT @@ -61,8 +64,11 @@ class CustomEmoji < ApplicationRecord :emoji end - def copy! + def copy!(current_account = nil) copy = self.class.find_or_initialize_by(domain: nil, shortcode: shortcode) + return copy if copy.account_id.present? && copy.account_id != current_account&.id + + copy.account = current_account copy.image = image copy.tap(&:save!) end diff --git a/app/models/custom_emoji_filter.rb b/app/models/custom_emoji_filter.rb index 414e1fcdd..58c888518 100644 --- a/app/models/custom_emoji_filter.rb +++ b/app/models/custom_emoji_filter.rb @@ -5,13 +5,16 @@ class CustomEmojiFilter local remote by_domain + claimed + unclaimed shortcode ).freeze attr_reader :params - def initialize(params) + def initialize(params, account) @params = params + @account = account end def results @@ -36,6 +39,10 @@ class CustomEmojiFilter CustomEmoji.remote when 'by_domain' CustomEmoji.where(domain: value.strip.downcase) + when 'claimed' + CustomEmoji.where(account: @account) + when 'unclaimed' + CustomEmoji.where(account: nil) when 'shortcode' CustomEmoji.search(value.strip) else diff --git a/app/models/domain_allow.rb b/app/models/domain_allow.rb index 5fe0e3a29..70f559f49 100644 --- a/app/models/domain_allow.rb +++ b/app/models/domain_allow.rb @@ -8,10 +8,12 @@ # domain :string default(""), not null # created_at :datetime not null # updated_at :datetime not null +# hidden :boolean default(FALSE), not null # class DomainAllow < ApplicationRecord include DomainNormalizable + include Paginable validates :domain, presence: true, uniqueness: true, domain: true diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index 2b18e01fa..743e21a29 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -16,6 +16,7 @@ class DomainBlock < ApplicationRecord include DomainNormalizable + include Paginable enum severity: [:silence, :suspend, :noop] diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb index c1f19149b..5899a7f0e 100644 --- a/app/models/follow_request.rb +++ b/app/models/follow_request.rb @@ -30,7 +30,10 @@ class FollowRequest < ApplicationRecord def authorize! account.follow!(target_account, reblogs: show_reblogs, notify: notify, uri: uri) - MergeWorker.perform_async(target_account.id, account.id) if account.local? + if account.local? + MergeWorker.perform_async(target_account.id, account.id) + ActivityPub::SyncAccountWorker.perform_async(target_account.id, every_page: true) unless target_account.local? + end destroy! end diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index fcec3e686..e36974519 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -4,6 +4,8 @@ class Form::AdminSettings include ActiveModel::Model KEYS = %i( + show_domain_allows + site_contact_username site_contact_email site_title @@ -76,6 +78,8 @@ class Form::AdminSettings attr_accessor(*KEYS) + validates :show_domain_allows, inclusion: { in: %w(disabled users all) } + validates :site_short_description, :site_description, html: { wrap_with: :p } validates :site_extended_description, :site_terms, :closed_registrations_message, html: true validates :registrations_mode, inclusion: { in: %w(open approved none) } diff --git a/app/models/form/custom_emoji_batch.rb b/app/models/form/custom_emoji_batch.rb index f4fa84c10..54a15dc18 100644 --- a/app/models/form/custom_emoji_batch.rb +++ b/app/models/form/custom_emoji_batch.rb @@ -24,13 +24,17 @@ class Form::CustomEmojiBatch copy! when 'delete' delete! + when 'claim' + claim! + when 'unclaim' + unclaim! end end private - def custom_emojis - @custom_emojis ||= CustomEmoji.where(id: custom_emoji_ids) + def custom_emojis(include_all = false) + @custom_emojis ||= (include_all || current_account&.user&.staff? ? CustomEmoji.where(id: custom_emoji_ids) : CustomEmoji.local.where(id: custom_emoji_ids, account: current_account)) end def update! @@ -40,10 +44,12 @@ class Form::CustomEmojiBatch if category_id.present? CustomEmojiCategory.find(category_id) elsif category_name.present? - CustomEmojiCategory.find_or_create_by!(name: category_name) + CustomEmojiCategory.find_or_create_by!(name: current_account&.user&.staff? ? category_name.strip : "(@#{current_account.username}) #{category_name}".rstrip) end end + return if category.name.start_with?('(@') && !category.name.start_with?("(@#{current_account.username}) ") + custom_emojis.each do |custom_emoji| custom_emoji.update(category_id: category&.id) log_action :update, custom_emoji @@ -87,10 +93,10 @@ class Form::CustomEmojiBatch end def copy! - custom_emojis.each { |custom_emoji| authorize(custom_emoji, :copy?) } + custom_emojis(true).each { |custom_emoji| authorize(custom_emoji, :copy?) } custom_emojis.each do |custom_emoji| - copied_custom_emoji = custom_emoji.copy! + copied_custom_emoji = custom_emoji.copy!(current_account) log_action :create, copied_custom_emoji end end @@ -103,4 +109,27 @@ class Form::CustomEmojiBatch log_action :destroy, custom_emoji end end + + def claim! + custom_emojis(true).each { |custom_emoji| authorize(custom_emoji, :claim?) } + + custom_emojis.each do |custom_emoji| + if custom_emoji.local? + custom_emoji.update(account: current_account) + log_action :update, custom_emoji + else + copied_custom_emoji = custom_emoji.copy!(current_account) + log_action :create, copied_custom_emoji + end + end + end + + def unclaim! + custom_emojis.each { |custom_emoji| authorize(custom_emoji, :unclaim?) } + + custom_emojis.each do |custom_emoji| + custom_emoji.update(account: nil) + log_action :update, custom_emoji + end + end end diff --git a/app/models/inline_media_attachment.rb b/app/models/inline_media_attachment.rb new file mode 100644 index 000000000..faa8ca1ac --- /dev/null +++ b/app/models/inline_media_attachment.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: inline_media_attachments +# +# id :bigint(8) not null, primary key +# status_id :bigint(8) +# media_attachment_id :bigint(8) +# + +class InlineMediaAttachment < ApplicationRecord + include Cacheable + + validates :status_id, uniqueness: { scope: :media_attachment_id } + + belongs_to :status, inverse_of: :inlined_attachments + belongs_to :media_attachment, inverse_of: :inlines + + cache_associated :status, :media_attachment +end diff --git a/app/models/invite.rb b/app/models/invite.rb index 7ea4e2f98..d60866ad6 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -35,7 +35,7 @@ class Invite < ApplicationRecord def set_code loop do - self.code = ([*('a'..'z'), *('A'..'Z'), *('0'..'9')] - %w(0 1 I l O)).sample(8).join + self.code = ([*('a'..'z'), *('A'..'Z'), *('0'..'9')] - %w(0 1 I l O)).sample(16).join break if Invite.find_by(code: code).nil? end end diff --git a/app/models/list.rb b/app/models/list.rb index 8493046e5..006b7e745 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -9,12 +9,13 @@ # created_at :datetime not null # updated_at :datetime not null # replies_policy :integer default("list_replies"), not null +# reblogs :boolean default(FALSE), not null # class List < ApplicationRecord include Paginable - PER_ACCOUNT_LIMIT = 50 + PER_ACCOUNT_LIMIT = 100 enum replies_policy: [:list_replies, :all_replies, :no_replies], _prefix: :show diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index cc81b648c..a1fe76589 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -26,6 +26,7 @@ # thumbnail_file_size :integer # thumbnail_updated_at :datetime # thumbnail_remote_url :string +# inline :boolean default(FALSE), not null # class MediaAttachment < ApplicationRecord @@ -34,7 +35,7 @@ class MediaAttachment < ApplicationRecord enum type: [:image, :gifv, :video, :unknown, :audio] enum processing: [:queued, :in_progress, :complete, :failed], _prefix: true - MAX_DESCRIPTION_LENGTH = 1_500 + MAX_DESCRIPTION_LENGTH = 2_000 IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze @@ -59,12 +60,12 @@ class MediaAttachment < ApplicationRecord IMAGE_STYLES = { original: { - pixels: 1_638_400, # 1280x1280px + pixels: 16_777_216, # 4096x4096px file_geometry_parser: FastGeometryParser, }.freeze, small: { - pixels: 160_000, # 400x400px + pixels: 250_000, # 500x500px file_geometry_parser: FastGeometryParser, blurhash: BLURHASH_OPTIONS, }.freeze, @@ -81,8 +82,8 @@ class MediaAttachment < ApplicationRecord 'vf' => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'', 'vsync' => 'cfr', 'c:v' => 'h264', - 'maxrate' => '1300K', - 'bufsize' => '1300K', + 'maxrate' => '2M', + 'bufsize' => '2M', 'frames:v' => 60 * 60 * 3, 'crf' => 18, 'map_metadata' => '-1', @@ -112,7 +113,7 @@ class MediaAttachment < ApplicationRecord convert_options: { output: { 'loglevel' => 'fatal', - vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease', + vf: 'scale=\'min(500\, iw):min(500\, ih)\':force_original_aspect_ratio=decrease', }.freeze, }.freeze, format: 'png', @@ -131,7 +132,7 @@ class MediaAttachment < ApplicationRecord convert_options: { output: { 'loglevel' => 'fatal', - 'q:a' => 2, + 'q:a' => 0, }.freeze, }.freeze, }.freeze, @@ -147,7 +148,7 @@ class MediaAttachment < ApplicationRecord }.freeze GLOBAL_CONVERT_OPTIONS = { - all: '-quality 90 -strip +set modify-date +set create-date', + all: '-quality 95 -strip +set modify-date +set create-date', }.freeze IMAGE_LIMIT = (ENV['MAX_IMAGE_SIZE'] || 10.megabytes).to_i @@ -160,6 +161,8 @@ class MediaAttachment < ApplicationRecord belongs_to :status, inverse_of: :media_attachments, optional: true belongs_to :scheduled_status, inverse_of: :media_attachments, optional: true + has_many :inlines, class_name: 'InlineMediaAttachment', inverse_of: :media_attachment, dependent: :destroy + has_attached_file :file, styles: ->(f) { file_styles f }, processors: ->(f) { file_processors f }, @@ -189,13 +192,16 @@ class MediaAttachment < ApplicationRecord validates :file, presence: true, if: :local? validates :thumbnail, absence: true, if: -> { local? && !audio_or_video? } - scope :attached, -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) } - scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) } - scope :local, -> { where(remote_url: '') } - scope :remote, -> { where.not(remote_url: '') } + scope :attached, -> { all_media.where.not(status_id: nil).or(all_media.where.not(scheduled_status_id: nil)) } + scope :unattached, -> { all_media.where(status_id: nil, scheduled_status_id: nil) } + scope :uninlined, -> { where(inline: false) } + scope :inlined, -> { rewhere(inline: true) } + scope :all_media, -> { unscope(where: :inline) } + scope :local, -> { all_media.where(remote_url: '') } + scope :remote, -> { all_media.where.not(remote_url: '') } scope :cached, -> { remote.where.not(file_file_name: nil) } - default_scope { order(id: :asc) } + default_scope { uninlined.order(id: :asc) } def local? remote_url.blank? diff --git a/app/models/mute.rb b/app/models/mute.rb index 639120f7d..11f833d8e 100644 --- a/app/models/mute.rb +++ b/app/models/mute.rb @@ -9,6 +9,7 @@ # hide_notifications :boolean default(TRUE), not null # account_id :bigint(8) not null # target_account_id :bigint(8) not null +# timelines_only :boolean default(FALSE), not null # class Mute < ApplicationRecord diff --git a/app/models/public_feed.rb b/app/models/public_feed.rb index 2839da5cb..7418a1c9d 100644 --- a/app/models/public_feed.rb +++ b/app/models/public_feed.rb @@ -26,7 +26,8 @@ class PublicFeed < Feed scope.merge!(without_replies_scope) unless with_replies? scope.merge!(without_reblogs_scope) unless with_reblogs? scope.merge!(local_only_scope) if local_only? - scope.merge!(remote_only_scope) if remote_only? + #scope.merge!(remote_only_scope) if remote_only? + scope.merge!(curated_scope) unless local_only? || remote_only? scope.merge!(account_filters_scope) if account? scope.merge!(media_only_scope) if media_only? @@ -79,6 +80,10 @@ class PublicFeed < Feed Status.remote end + def curated_scope + Status.curated + end + def without_replies_scope Status.without_replies end diff --git a/app/models/queued_boost.rb b/app/models/queued_boost.rb new file mode 100644 index 000000000..6eca3725f --- /dev/null +++ b/app/models/queued_boost.rb @@ -0,0 +1,15 @@ +# == Schema Information +# +# Table name: queued_boosts +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# status_id :bigint(8) not null +# + +class QueuedBoost < ApplicationRecord + belongs_to :account, inverse_of: :queued_boosts + belongs_to :status, inverse_of: :queued_boosts + + validates :account_id, uniqueness: { scope: :status_id } +end diff --git a/app/models/status.rb b/app/models/status.rb index d1ac2e4f2..dd7fad4fd 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -21,13 +21,24 @@ # account_id :bigint(8) not null # application_id :bigint(8) # in_reply_to_account_id :bigint(8) -# local_only :boolean +# local_only :boolean default(FALSE), not null # full_status_text :text default(""), not null # poll_id :bigint(8) # content_type :string # deleted_at :datetime +# edited :integer default(0), not null +# nest_level :integer default(0), not null +# published :boolean default(TRUE), not null +# title :text +# original_text :text +# footer :text +# expires_at :datetime +# publish_at :datetime +# originally_local_only :boolean default(FALSE), not null +# curated :boolean default(FALSE), not null # +# rubocop:disable Metrics/ClassLength class Status < ApplicationRecord before_destroy :unlink_from_conversations @@ -65,8 +76,15 @@ class Status < ApplicationRecord has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread has_many :mentions, dependent: :destroy, inverse_of: :status has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status + has_many :silent_mentions, -> { silent }, class_name: 'Mention', inverse_of: :status has_many :media_attachments, dependent: :nullify + has_many :inlined_attachments, class_name: 'InlineMediaAttachment', inverse_of: :status, dependent: :destroy + has_many :mutes, class_name: 'StatusMute', inverse_of: :status, dependent: :destroy + belongs_to :conversation_mute, primary_key: 'conversation_id', foreign_key: 'conversation_id', inverse_of: :conversation, dependent: :destroy, optional: true + has_many :domain_permissions, class_name: 'StatusDomainPermission', inverse_of: :status, dependent: :destroy + has_many :queued_boosts, inverse_of: :status, dependent: :destroy + has_and_belongs_to_many :tags has_and_belongs_to_many :preview_cards @@ -90,9 +108,10 @@ class Status < ApplicationRecord scope :remote, -> { where(local: false).where.not(uri: nil) } scope :local, -> { where(local: true).or(where(uri: nil)) } scope :with_accounts, ->(ids) { where(id: ids).includes(:account) } - scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') } + scope :without_replies, -> { where(reply: false) } scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') } - scope :with_public_visibility, -> { where(visibility: :public) } + scope :with_public_visibility, -> { where(visibility: :public, published: true) } + scope :distributable, -> { where(visibility: [:public, :unlisted], published: true) } scope :tagged_with, ->(tag_ids) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag_ids }) } scope :in_chosen_languages, ->(account) { where(language: nil).or where(language: account.chosen_languages) } scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) } @@ -113,6 +132,21 @@ class Status < ApplicationRecord scope :not_local_only, -> { where(local_only: [false, nil]) } + scope :including_unpublished, -> { unscope(where: :published) } + scope :unpublished, -> { rewhere(published: false) } + scope :published, -> { where(published: true) } + scope :reblogs, -> { where('statuses.reblog_of_id IS NOT NULL') } + scope :locally_reblogged, -> { where(id: Status.unscoped.local.reblogs.select(:reblog_of_id)) } + scope :mentioning_account, ->(account) { joins(:mentions).where(mentions: { account: account }) } + scope :replies, -> { where(reply: true) } + scope :expired, -> { published.where('statuses.expires_at IS NOT NULL AND statuses.expires_at < ?', Time.now.utc) } + scope :ready_to_publish, -> { unpublished.where('statuses.publish_at IS NOT NULL AND statuses.publish_at < ?', Time.now.utc) } + scope :curated, -> { where(curated: true) } + + scope :not_hidden_by_account, ->(account) do + left_outer_joins(:mutes, :conversation_mute).where('(status_mutes.account_id IS NULL OR status_mutes.account_id != ?) AND (conversation_mutes.account_id IS NULL OR conversation_mutes.account_id != ?)', account.id, account.id) + end + cache_associated :application, :media_attachments, :conversation, @@ -136,8 +170,20 @@ class Status < ApplicationRecord thread: { account: :account_stat } delegate :domain, to: :account, prefix: true + delegate :max_visibility_for_domain, to: :account REAL_TIME_WINDOW = 6.hours + SORTED_VISIBILITY = { + direct: 0, + limited: 1, + private: 2, + unlisted: 3, + public: 4, + }.with_indifferent_access.freeze + TIMER_VALUES = [ + 0, 1, 2, 3, 5, 10, 15, 30, 60, 120, 180, 360, 720, 1440, 2880, 4320, 7200, + 10_080, 20_160, 30_240, 60_480, 120_960, 181_440, 241_920, 362_880, 524_160 + ].freeze def searchable_by(preloaded = nil) ids = [] @@ -204,7 +250,7 @@ class Status < ApplicationRecord end def hidden? - !distributable? + !(published? || distributable?) end def distributable? @@ -228,7 +274,7 @@ class Status < ApplicationRecord def emojis return @emojis if defined?(@emojis) - fields = [spoiler_text, text] + fields = [spoiler_text, text, footer || ''] fields += preloadable_poll.options unless preloadable_poll.nil? @emojis = CustomEmoji.from_text(fields.join(' '), account.domain) @@ -262,24 +308,99 @@ class Status < ApplicationRecord update_status_stat!(key => [public_send(key) - 1, 0].max) end + def curate! + update_column(:curated, true) if public_visibility? && !curated + end + + def uncurate! + update_column(:curated, false) if curated + end + + def notify=(value) + Redis.current.set("status:#{id}:notify", value ? 1 : 0, ex: 1.hour) + @notify = value + end + + def notify + return @notify if defined?(@notify) + + value = Redis.current.get("status:#{id}:notify") + @notify = value.nil? ? true : value.to_i == 1 + end + + alias notify? notify + + def less_private_than?(other_visibility) + return false if other_visibility.blank? + + SORTED_VISIBILITY[visibility] > SORTED_VISIBILITY[other_visibility] + end + + def more_private_than?(other_visibility) + return false if other_visibility.blank? + + SORTED_VISIBILITY[visibility] < SORTED_VISIBILITY[other_visibility] + end + + def visibility_for_domain(domain) + return visibility.to_s if domain.blank? + + v = domain_permissions.find_by(domain: [domain, '*'])&.visibility || visibility.to_s + + case max_visibility_for_domain(domain) + when 'public' + v + when 'unlisted' + v == 'public' ? 'unlisted' : v + when 'private' + %w(public unlisted).include?(v) ? 'private' : v + when 'direct' + 'direct' + else + v != 'direct' ? 'limited' : 'direct' + end + end + + def public_domain_permissions? + return @public_permissions if defined?(@public_permissions) + return @public_permissions = false unless account.local? + + @public_permissions = domain_permissions.where(visibility: [:public, :unlisted]).exists? + end + + def private_domain_permissions? + return @private_permissions if defined?(@private_permissions) + return @private_permissions = false unless account.local? + + @private_permissions = domain_permissions.where(visibility: [:private, :direct, :limited]).exists? + end + + def should_limit_visibility? + less_private_than?(thread&.visibility) + end + after_create_commit :increment_counter_caches after_destroy_commit :decrement_counter_caches after_create_commit :store_uri, if: :local? + after_create_commit :store_url, if: :local? after_create_commit :update_statistics, if: :local? around_create Mastodon::Snowflake::Callbacks before_create :set_locality + before_create :set_nest_level before_validation :prepare_contents, if: :local? before_validation :set_reblog - before_validation :set_visibility - before_validation :set_conversation + before_validation :set_conversation_perms before_validation :set_local after_create :set_poll_id + after_save :set_domain_permissions, if: :local? + after_save :set_conversation_root + class << self def selectable_visibilities visibilities.keys - %w(direct limited) @@ -346,6 +467,10 @@ class Status < ApplicationRecord ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.conversation_id] = true } end + def hidden_statuses_map(status_ids, account_id) + StatusMute.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |m, h| h[m.status_id] = true } + end + def pins_map(status_ids, account_id) StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |p, h| h[p.status_id] = true } end @@ -370,26 +495,26 @@ class Status < ApplicationRecord end end - def permitted_for(target_account, account) + def permitted_for(target_account, account, **options) visibility = [:public, :unlisted] - if account.nil? - where(visibility: visibility).not_local_only - elsif target_account.blocking?(account) || (account.domain.present? && target_account.domain_blocking?(account.domain)) # get rid of blocked peeps - none - elsif account.id == target_account.id # author can see own stuff - all - else - # followers can see followers-only stuff, but also things they are mentioned in. - # non-followers can see everything that isn't private/direct, but can see stuff they are mentioned in. + if account.present? + return none if target_account.blocking?(account) || (account.domain.present? && target_account.domain_blocking?(account.domain)) + return apply_category_filters(all, target_account, account, **options) if account.id == target_account.id + visibility.push(:private) if account.following?(target_account) + end - scope = left_outer_joins(:reblog) + visibility = :public if options[:public] || (account.blank? && !target_account.show_unlisted?) - scope.where(visibility: visibility) - .or(scope.where(id: account.mentions.select(:status_id))) - .merge(scope.where(reblog_of_id: nil).or(scope.where.not(reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids }))) - end + scope = where(visibility: visibility) + apply_category_filters(scope, target_account, account, **options) + end + + def mentions_between(account, target_account) + return none if account.blank? || target_account.blank? + + account.statuses.mentioning_account(target_account).or(target_account.statuses.mentioning_account(account)) end def from_text(text) @@ -406,6 +531,62 @@ class Status < ApplicationRecord status&.distributable? ? status : nil end.compact end + + private + + # TODO: Cast cleanup spell. + # rubocop:disable Metrics/PerceivedComplexity + def apply_category_filters(query, target_account, account, **options) + options[:without_account_filters] ||= target_account.id == account&.id + query = apply_account_filters(query, account, **options) + return query if options[:without_category_filters] + + query = query.published unless options[:include_unpublished] + + if options[:only_reblogs] + query = query.joins(:reblog) + if account.present? && account.excluded_from_timeline_account_ids.present? + query = query.where.not( + reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids } + ) + end + elsif target_account.id == account&.id + query = query.without_replies unless options[:include_replies] || options[:only_replies] + query = query.without_reblogs unless options[:include_reblogs] || options[:only_reblogs] + query = query.reblogs if options[:only_reblogs] + query = query.replies if options[:only_replies] + else + if options[:include_reblogs] && account.present? && account.excluded_from_timeline_account_ids.present? + query = query.left_outer_joins(:reblog).where( + '(statuses.reblog_of_id IS NULL OR reblogs_statuses.account_id NOT IN (?))', + account.excluded_from_timeline_account_ids + ) + elsif !options[:include_reblogs] + query = query.without_reblogs + end + + if options[:include_replies] + query = query.replies if options[:only_replies] + else + query = query.without_replies + end + end + + return query if options[:tag].blank? + + (tag = Tag.find_normalized(options[:tag])) ? query.merge(Status.tagged_with(tag.id)) : none + end + # rubocop:enable Metrics/PerceivedComplexity + + def apply_account_filters(query, account, **options) + return query.not_local_only if account.blank? + return (!options[:exclude_local_only] && account.local? ? query : query.not_local_only) if options[:without_account_filters] + + query = query.not_local_only unless !options[:exclude_local_only] && account.local? + query = query.not_hidden_by_account(account) + query = query.in_chosen_languages(account) if account.chosen_languages.present? + query + end end def marked_local_only? @@ -433,9 +614,15 @@ class Status < ApplicationRecord update_column(:uri, ActivityPub::TagManager.instance.uri_for(self)) if uri.nil? end + def store_url + update_column(:url, ActivityPub::TagManager.instance.url_for(self)) if url.nil? + end + def prepare_contents text&.strip! spoiler_text&.strip! + title&.strip! + language&.gsub!('en-MP', 'en') end def set_reblog @@ -446,31 +633,34 @@ class Status < ApplicationRecord update_column(:poll_id, poll.id) unless poll.nil? end - def set_visibility - self.visibility = reblog.visibility if reblog? && visibility.nil? - self.visibility = (account.locked? ? :private : :public) if visibility.nil? - self.sensitive = false if sensitive.nil? - end - def set_locality if account.domain.nil? && !attribute_changed?(:local_only) - self.local_only = marked_local_only? + self.local_only = true if marked_local_only? end + self.local_only = true if thread&.local_only? && local_only.nil? + self.local_only = reblog.local_only if reblog? + + self.originally_local_only = local_only if attribute_changed?(:local_only) && !attribute_changed?(:originally_local_only) end - def set_conversation + def set_conversation_perms self.thread = thread.reblog if thread&.reblog? - self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply + self.visibility = reblog.visibility if reblog? && visibility.nil? + self.visibility = (account.locked? ? :private : :public) if visibility.nil? + self.visibility = thread.visibility if should_limit_visibility? + self.sensitive = false if sensitive.nil? if reply? && !thread.nil? self.in_reply_to_account_id = carried_over_reply_to_account_id self.conversation_id = thread.conversation_id if conversation_id.nil? - elsif conversation_id.nil? - self.conversation = Conversation.new end end + def set_conversation_root + conversation.update!(root: uri) if !reply && conversation.present? && conversation.root.blank? + end + def carried_over_reply_to_account_id if thread.account_id == account_id && thread.reply? thread.in_reply_to_account_id @@ -483,6 +673,28 @@ class Status < ApplicationRecord self.local = account.local? end + def set_nest_level + return if attribute_changed?(:nest_level) + + self.nest_level = if reply? + [thread&.account_id == account_id ? thread&.nest_level.to_i : thread&.nest_level.to_i + 1, 127].min + else + 0 + end + end + + def set_domain_permissions + return unless saved_change_to_visibility? + + domain_permissions.transaction do + existing_domains = domain_permissions.select(:domain) + permissions = account.domain_permissions.where.not(domain: existing_domains) + permissions.find_each do |permission| + domain_permissions.create!(domain: permission.domain, visibility: permission.visibility) if less_private_than?(permission.visibility) + end + end + end + def update_statistics return unless distributable? @@ -516,3 +728,4 @@ class Status < ApplicationRecord end end end +# rubocop:enable Metrics/ClassLength diff --git a/app/models/status_domain_permission.rb b/app/models/status_domain_permission.rb new file mode 100644 index 000000000..be767a2b6 --- /dev/null +++ b/app/models/status_domain_permission.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: status_domain_permissions +# +# id :bigint(8) not null, primary key +# status_id :bigint(8) not null +# domain :string default(""), not null +# visibility :integer default("public"), not null +# + +class StatusDomainPermission < ApplicationRecord + include Paginable + include Cacheable + + validates :domain, presence: true, uniqueness: { scope: :status_id } + validates :visibility, presence: true + + belongs_to :status, inverse_of: :domain_permissions + enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility + + default_scope { order(domain: :desc) } + + cache_associated :status + + class << self + def create_by_domains(permissions_list) + Array(permissions_list).map(&method(:normalize)).map do |permissions| + where(**permissions).first_or_create + end + end + + def create_by_domains!(permissions_list) + Array(permissions_list).map(&method(:normalize)).map do |permissions| + where(**permissions).first_or_create! + end + end + + def create_or_update(domain_permissions) + domain_permissions = normalize(domain_permissions) + permissions = find_by(domain: domain_permissions[:domain]) + if permissions.present? + permissions.update(**domain_permissions) + else + create(**domain_permissions) + end + permissions + end + + def create_or_update!(domain_permissions) + domain_permissions = normalize(domain_permissions) + permissions = find_by(domain: domain_permissions[:domain]) + if permissions.present? + permissions.update!(**domain_permissions) + else + create!(**domain_permissions) + end + permissions + end + + private + + def normalize(hash) + hash.symbolize_keys! + hash[:domain] = hash[:domain].strip.downcase + hash.compact + end + end +end diff --git a/app/models/status_mute.rb b/app/models/status_mute.rb new file mode 100644 index 000000000..1e01f0278 --- /dev/null +++ b/app/models/status_mute.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: status_mutes +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# status_id :bigint(8) not null +# + +class StatusMute < ApplicationRecord + include Cacheable + + validates :account_id, uniqueness: { scope: :status_id } + + belongs_to :account, inverse_of: :status_mutes + belongs_to :status, inverse_of: :mutes + + cache_associated :account, :status +end diff --git a/app/models/user.rb b/app/models/user.rb index 6cd2ca6bd..07fb9e1bb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -41,6 +41,8 @@ # sign_in_token :string # sign_in_token_sent_at :datetime # webauthn_id :string +# username :string +# kobold :string # class User < ApplicationRecord @@ -89,7 +91,7 @@ class User < ApplicationRecord validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create scope :recent, -> { order(id: :desc) } - scope :pending, -> { where(approved: false) } + scope :pending, -> { where(approved: false).where.not(kobold: '') } scope :approved, -> { where(approved: true) } scope :confirmed, -> { where.not(confirmed_at: nil) } scope :enabled, -> { where(disabled: false) } @@ -99,6 +101,7 @@ class User < ApplicationRecord scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) } scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) } scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) } + scope :lower_username, ->(username) { where('lower(users.username) = lower(?)', username) } before_validation :sanitize_languages before_create :set_approved @@ -116,6 +119,12 @@ class User < ApplicationRecord :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images, :default_content_type, :system_emoji_font, + :manual_publish, :style_dashed_nest, :style_underline_a, :style_css_profile, + :style_css_profile_errors, :style_css_webapp, :style_css_webapp_errors, + :style_wide_media, + :publish_in, :unpublish_in, :unpublish_delete, :boost_every, :boost_jitter, + :boost_random, :filter_from_unknown, :unpublish_on_delete, + :rss_disabled, :no_boosts_home, to: :settings, prefix: :setting, allow_nil: false attr_reader :invite_code, :sign_in_token_attempt @@ -149,7 +158,7 @@ class User < ApplicationRecord if new_user && approved? prepare_new_user! - elsif new_user + elsif new_user && user_might_not_be_a_spam_bot notify_staff_about_pending_account! end end @@ -254,6 +263,10 @@ class User < ApplicationRecord @shows_application ||= settings.show_application end + def disables_home_reblogs? + @disables_home_reblogs ||= settings.no_boosts_home + end + # rubocop:disable Naming/MethodParameterName def token_for_app(a) return nil if a.nil? || a.owner != self @@ -307,6 +320,17 @@ class User < ApplicationRecord super end + def send_confirmation_instructions + unless approved? || user_might_not_be_a_spam_bot + invite_request&.destroy + account&.destroy + destroy + return false + end + + super + end + def reset_password!(new_password, new_password_confirmation) return false if encrypted_password.blank? @@ -415,7 +439,7 @@ class User < ApplicationRecord def notify_staff_about_pending_account! User.staff.includes(:account).find_each do |u| - next unless u.allows_pending_account_emails? + next unless u.account.actor_type == 'Person' && u.allows_pending_account_emails? AdminMailer.new_pending_account(u.account, self).deliver_later end end @@ -433,4 +457,27 @@ class User < ApplicationRecord def validate_email_dns? email_changed? && !(Rails.env.test? || Rails.env.development?) end + + def user_might_not_be_a_spam_bot + username == account.username && (invited? || (invite_request&.text.present? && kobold_hash_matches?)) + end + + def kobold_hash_matches? + kobold.present? && kobold == kobold_hash + end + + def kobold_hash + value = [account.username, username.downcase, email, invite_request.text.gsub(/\r\n?/, "\n")].compact.map(&:downcase).join("\u{F0666}") + Digest::SHA512.hexdigest(value).upcase + end + + class << self + def find_by_lower_username(username) + lower_username(username).first + end + + def find_by_lower_username!(username) + lower_username(username).first! + end + end end |