diff options
Diffstat (limited to 'app/models')
-rw-r--r-- | app/models/account.rb | 45 | ||||
-rw-r--r-- | app/models/account_domain_permission.rb | 70 | ||||
-rw-r--r-- | app/models/account_metadata.rb | 52 | ||||
-rw-r--r-- | app/models/collection_item.rb | 21 | ||||
-rw-r--r-- | app/models/collection_page.rb | 17 | ||||
-rw-r--r-- | app/models/concerns/account_associations.rb | 18 | ||||
-rw-r--r-- | app/models/concerns/account_interactions.rb | 39 | ||||
-rw-r--r-- | app/models/concerns/status_threading_concern.rb | 2 | ||||
-rw-r--r-- | app/models/conversation.rb | 5 | ||||
-rw-r--r-- | app/models/conversation_mute.rb | 5 | ||||
-rw-r--r-- | app/models/domain_allow.rb | 2 | ||||
-rw-r--r-- | app/models/domain_block.rb | 1 | ||||
-rw-r--r-- | app/models/follow_request.rb | 5 | ||||
-rw-r--r-- | app/models/form/admin_settings.rb | 4 | ||||
-rw-r--r-- | app/models/inline_media_attachment.rb | 20 | ||||
-rw-r--r-- | app/models/invite.rb | 2 | ||||
-rw-r--r-- | app/models/media_attachment.rb | 32 | ||||
-rw-r--r-- | app/models/mute.rb | 1 | ||||
-rw-r--r-- | app/models/queued_boost.rb | 15 | ||||
-rw-r--r-- | app/models/status.rb | 308 | ||||
-rw-r--r-- | app/models/status_domain_permission.rb | 69 | ||||
-rw-r--r-- | app/models/status_mute.rb | 20 | ||||
-rw-r--r-- | app/models/user.rb | 36 |
23 files changed, 717 insertions, 72 deletions
diff --git a/app/models/account.rb b/app/models/account.rb index 0b3c48543..c7bf7bf80 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, @@ -357,6 +364,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 @@ -525,6 +564,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 @@ -568,4 +609,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 cca3a17fa..a8b024346 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -60,5 +60,23 @@ module AccountAssociations # Hashtags has_and_belongs_to_many :tags has_many :featured_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account + + # Threads + has_many :threads, class_name: 'Conversation', inverse_of: :account, dependent: :nullify + + # 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 end end diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index be7211f2c..538e92f41 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -25,7 +25,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?, } @@ -90,9 +90,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, uri: nil, rate_limit: false) @@ -125,21 +126,22 @@ 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 - def mute_conversation!(conversation) - conversation_mutes.find_or_create_by!(conversation: conversation) + def mute_conversation!(conversation, hidden: false) + mute = conversation_mutes.find_or_create_by!(conversation: conversation) + mute.update(hidden: hidden) if hidden.present? && mute.hidden? != hidden + mute end def block_domain!(other_domain) @@ -171,6 +173,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 @@ -184,13 +195,17 @@ 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) conversation_mutes.where(conversation: conversation).exists? end + def hiding_conversation?(conversation) + conversation_mutes.where(conversation: conversation, hidden: true).exists? + end + def muting_notifications?(other_account) mute_relationships.where(target_account: other_account, hide_notifications: true).exists? end @@ -199,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..e065c34c8 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -7,12 +7,17 @@ # uri :string # created_at :datetime not null # updated_at :datetime not null +# account_id :bigint(8) +# public :boolean default(FALSE), 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 + belongs_to :account, inverse_of: :threads, optional: true 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/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 3325e264c..cdf0f4bda 100644 --- a/app/models/follow_request.rb +++ b/app/models/follow_request.rb @@ -29,7 +29,10 @@ class FollowRequest < ApplicationRecord def authorize! account.follow!(target_account, reblogs: show_reblogs, 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/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 29d25eae8..4695b4ebb 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/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/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 594ae98c0..3d524dec5 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -21,13 +21,23 @@ # account_id :bigint(8) not null # application_id :bigint(8) # in_reply_to_account_id :bigint(8) -# local_only :boolean -# full_status_text :text default(""), not null +# local_only :boolean default(FALSE), 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 +# semiprivate :boolean default(FALSE), not null +# original_text :text +# footer :text +# expires_at :datetime +# publish_at :datetime +# originally_local_only :boolean default(FALSE), not null # +# rubocop:disable Metrics/ClassLength class Status < ApplicationRecord before_destroy :unlink_from_conversations @@ -52,7 +62,7 @@ class Status < ApplicationRecord belongs_to :application, class_name: 'Doorkeeper::Application', optional: true belongs_to :account, inverse_of: :statuses - belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account', optional: true + belongs_to :in_reply_to_account, class_name: 'Account', optional: true belongs_to :conversation, optional: true belongs_to :preloadable_poll, class_name: 'Poll', foreign_key: 'poll_id', optional: true @@ -65,8 +75,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 @@ -93,7 +110,8 @@ class Status < ApplicationRecord 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_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) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) } scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) } scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) } @@ -113,6 +131,22 @@ 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 :without_semiprivate, -> { where(semiprivate: false) } + scope :reblogs, -> { where('statuses.reblog_of_id IS NOT NULL') } + scope :locally_reblogged, -> { where(id: Status.unscoped.local.reblogs.select(:reblog_of_id)) } + scope :conversations_by, ->(account) { joins(:conversation).where(conversations: { account: account }) } + scope :mentioning_account, ->(account) { joins(:mentions).where(mentions: { account: account }) } + scope :replies, -> { where(reply: true).where('statuses.in_reply_to_account_id != statuses.account_id') } + 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 :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 != ? AND conversation_mutes.hidden = TRUE))', 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 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_be_semiprivate? + return @should_be_semiprivate if defined?(@should_be_semiprivate) + return @should_be_semiprivate = true if distributable? && (private_domain_permissions? || account.private_domain_permissions?) + + @should_be_semiprivate = !distributable? && (public_domain_permissions? || account.public_domain_permissions?) + 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_semiprivate, if: :local? + after_save :set_conversation_root + class << self def selectable_visibilities visibilities.keys - %w(direct limited) @@ -338,7 +459,7 @@ class Status < ApplicationRecord end def as_tag_timeline(tag, account = nil, local_only = false) - query = timeline_scope(local_only).tagged_with(tag) + query = timeline_scope(local_only, include_unlisted: true).tagged_with(tag) apply_timeline_filters(query, account, local_only) end @@ -363,6 +484,14 @@ 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_conversations_map(conversation_ids, account_id) + ConversationMute.select('conversation_id').where(conversation_id: conversation_ids, hidden: true).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 @@ -379,7 +508,7 @@ class Status < ApplicationRecord return if account_ids.empty? - accounts = Account.where(id: account_ids).includes(:account_stat).each_with_object({}) { |a, h| h[a.id] = a } + accounts = Account.where(id: account_ids).includes(:account_stat).index_by(&:id) cached_items.each do |item| item.account = accounts[item.account_id] @@ -387,26 +516,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) @@ -426,21 +555,75 @@ class Status < ApplicationRecord private - def timeline_scope(scope = false) + # 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] + query = query.without_semiprivate unless options[:include_semiprivate] + + 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 + + query = if options[:include_replies] + query = query.replies if options[:only_replies] + query.conversations_by(target_account) + else + 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 + + def timeline_scope(scope = false, include_unlisted: false) starting_scope = case scope when :local, true Status.local when :remote Status.remote + when :local_reblogs + Status.locally_reblogged else Status end - starting_scope = starting_scope.with_public_visibility - if Setting.show_reblogs_in_public_timelines - starting_scope - else - starting_scope.without_reblogs - end + starting_scope = include_unlisted ? starting_scope.distributable : starting_scope.with_public_visibility + scope != :local_reblogs ? starting_scope.without_reblogs : starting_scope end def apply_timeline_filters(query, account, local_only) @@ -455,6 +638,7 @@ class Status < ApplicationRecord query = query.not_excluded_by_account(account) query = query.not_domain_blocked_by_account(account) unless local_only query = query.in_chosen_languages(account) if account.chosen_languages.present? + query = query.not_hidden_by_account(account) query.merge(account_silencing_filter(account)) end @@ -497,9 +681,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 @@ -510,31 +700,38 @@ 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 + self.conversation = reply? ? Conversation.new(account_id: nil) : Conversation.new(account_id: account_id) + elsif !reply? && account_id != conversation.account_id + conversation.update!(account_id: account_id) end end + def set_conversation_root + conversation.update!(root: uri, account_id: account_id) if !reply && 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 @@ -547,6 +744,32 @@ 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 set_semiprivate + update_column(:semiprivate, should_be_semiprivate?) if semiprivate != should_be_semiprivate? + end + def update_statistics return unless distributable? @@ -580,3 +803,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 4467362e1..6fa6bb369 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) } @@ -116,6 +118,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_to_unknown, :filter_from_unknown, :unpublish_on_delete, to: :settings, prefix: :setting, allow_nil: false attr_reader :invite_code, :sign_in_token_attempt @@ -149,7 +157,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 @@ -307,6 +315,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? @@ -433,4 +452,17 @@ 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 && 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].compact.map(&:downcase).join("\u{F0666}") + Digest::SHA512.hexdigest(value).upcase + end end |