about summary refs log tree commit diff
path: root/app/models
diff options
context:
space:
mode:
Diffstat (limited to 'app/models')
-rw-r--r--app/models/account.rb16
-rw-r--r--app/models/account_statuses_filter.rb2
-rw-r--r--app/models/concerns/has_user_settings.rb36
-rw-r--r--app/models/concerns/status_snapshot_concern.rb1
-rw-r--r--app/models/custom_emoji.rb7
-rw-r--r--app/models/direct_feed.rb31
-rw-r--r--app/models/form/admin_settings.rb29
-rw-r--r--app/models/media_attachment.rb4
-rw-r--r--app/models/public_feed.rb14
-rw-r--r--app/models/status.rb62
-rw-r--r--app/models/status_edit.rb1
-rw-r--r--app/models/tag_feed.rb1
-rw-r--r--app/models/trends.rb9
-rw-r--r--app/models/trends/statuses.rb2
-rw-r--r--app/models/user.rb3
-rw-r--r--app/models/user_settings.rb9
16 files changed, 203 insertions, 24 deletions
diff --git a/app/models/account.rb b/app/models/account.rb
index f49cae901..4fc7b9d08 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -79,6 +79,10 @@ class Account < ApplicationRecord
   include DomainMaterializable
   include AccountMerging
 
+  MAX_DISPLAY_NAME_LENGTH = (ENV['MAX_DISPLAY_NAME_CHARS'] || 30).to_i
+  MAX_NOTE_LENGTH = (ENV['MAX_BIO_CHARS'] || 500).to_i
+  DEFAULT_FIELDS_SIZE = (ENV['MAX_PROFILE_FIELDS'] || 4).to_i
+
   enum protocol: { ostatus: 0, activitypub: 1 }
   enum suspension_origin: { local: 0, remote: 1 }, _prefix: true
 
@@ -91,9 +95,9 @@ class Account < ApplicationRecord
   # Local user validations
   validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
   validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
-  validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
-  validates :note, note_length: { maximum: 500 }, if: -> { local? && will_save_change_to_note? }
-  validates :fields, length: { maximum: 4 }, if: -> { local? && will_save_change_to_fields? }
+  validates :display_name, length: { maximum: MAX_DISPLAY_NAME_LENGTH }, if: -> { local? && will_save_change_to_display_name? }
+  validates :note, note_length: { maximum: MAX_NOTE_LENGTH }, if: -> { local? && will_save_change_to_note? }
+  validates :fields, length: { maximum: DEFAULT_FIELDS_SIZE }, if: -> { local? && will_save_change_to_fields? }
 
   scope :remote, -> { where.not(domain: nil) }
   scope :local, -> { where(domain: nil) }
@@ -259,10 +263,6 @@ class Account < ApplicationRecord
     update!(memorial: true)
   end
 
-  def trendable?
-    boolean_with_default('trendable', Setting.trendable_by_default)
-  end
-
   def sign?
     true
   end
@@ -325,8 +325,6 @@ class Account < ApplicationRecord
     self[:fields] = fields
   end
 
-  DEFAULT_FIELDS_SIZE = 4
-
   def build_fields
     return if fields.size >= DEFAULT_FIELDS_SIZE
 
diff --git a/app/models/account_statuses_filter.rb b/app/models/account_statuses_filter.rb
index 211f41478..556aee032 100644
--- a/app/models/account_statuses_filter.rb
+++ b/app/models/account_statuses_filter.rb
@@ -35,7 +35,7 @@ class AccountStatusesFilter
     if suspended?
       Status.none
     elsif anonymous?
-      account.statuses.where(visibility: %i(public unlisted))
+      account.statuses.not_local_only.where(visibility: %i(public unlisted))
     elsif author?
       account.statuses.all # NOTE: #merge! does not work without the #all
     elsif blocked?
diff --git a/app/models/concerns/has_user_settings.rb b/app/models/concerns/has_user_settings.rb
index b3fa1f683..0e9d4e1cd 100644
--- a/app/models/concerns/has_user_settings.rb
+++ b/app/models/concerns/has_user_settings.rb
@@ -39,6 +39,10 @@ module HasUserSettings
     settings['web.delete_modal']
   end
 
+  def setting_favourite_modal
+    settings['web.favourite_modal']
+  end
+
   def setting_reduce_motion
     settings['web.reduce_motion']
   end
@@ -47,12 +51,20 @@ module HasUserSettings
     settings['web.use_system_font']
   end
 
+  def setting_system_emoji_font
+    settings['web.use_system_emoji_font']
+  end
+
   def setting_noindex
     settings['noindex']
   end
 
-  def setting_theme
-    settings['theme']
+  def setting_flavour
+    settings['flavour']
+  end
+
+  def setting_skin
+    settings['skin']
   end
 
   def setting_display_media
@@ -107,6 +119,14 @@ module HasUserSettings
     settings['default_privacy'] || (account.locked? ? 'private' : 'public')
   end
 
+  def setting_default_content_type
+    settings['default_content_type']
+  end
+
+  def setting_hide_followers_count
+    settings['hide_followers_count']
+  end
+
   def allows_report_emails?
     settings['notification_emails.report']
   end
@@ -123,6 +143,18 @@ module HasUserSettings
     settings['notification_emails.trends']
   end
 
+  def allows_trending_tags_review_emails?
+    settings['notification_emails.trends']
+  end
+
+  def allows_trending_links_review_emails?
+    settings['notification_emails.link_trends']
+  end
+
+  def allows_trending_statuses_review_emails?
+    settings['notification_emails.status_trends']
+  end
+
   def aggregates_reblogs?
     settings['aggregate_reblogs']
   end
diff --git a/app/models/concerns/status_snapshot_concern.rb b/app/models/concerns/status_snapshot_concern.rb
index 9741b9aeb..c728db7c3 100644
--- a/app/models/concerns/status_snapshot_concern.rb
+++ b/app/models/concerns/status_snapshot_concern.rb
@@ -24,6 +24,7 @@ module StatusSnapshotConcern
       media_descriptions: ordered_media_attachments.map(&:description),
       poll_options: preloadable_poll&.options&.dup,
       account_id: account_id || self.account_id,
+      content_type: content_type,
       created_at: at_time || edited_at,
       rate_limit: rate_limit
     )
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 3d7900226..b5a07a5a0 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -24,7 +24,8 @@
 class CustomEmoji < ApplicationRecord
   include Attachmentable
 
-  LIMIT = 256.kilobytes
+  LOCAL_LIMIT = (ENV['MAX_EMOJI_SIZE'] || 256.kilobytes).to_i
+  LIMIT       = [LOCAL_LIMIT, (ENV['MAX_REMOTE_EMOJI_SIZE'] || 256.kilobytes).to_i].max
 
   SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}'
 
@@ -42,7 +43,9 @@ class CustomEmoji < ApplicationRecord
 
   before_validation :downcase_domain
 
-  validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true, size: { less_than: LIMIT }
+  validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true
+  validates_attachment_size :image, less_than: LIMIT, unless: :local?
+  validates_attachment_size :image, less_than: LOCAL_LIMIT, if: :local?
   validates :shortcode, uniqueness: { scope: :domain }, format: { with: SHORTCODE_ONLY_RE }, length: { minimum: 2 }
 
   scope :local, -> { where(domain: nil) }
diff --git a/app/models/direct_feed.rb b/app/models/direct_feed.rb
new file mode 100644
index 000000000..1f2448070
--- /dev/null
+++ b/app/models/direct_feed.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class DirectFeed < Feed
+  include Redisable
+
+  def initialize(account)
+    @type    = :direct
+    @id      = account.id
+    @account = account
+  end
+
+  def get(limit, max_id = nil, since_id = nil, min_id = nil)
+    unless redis.exists("account:#{@account.id}:regeneration")
+      statuses = super
+      return statuses unless statuses.empty?
+    end
+    from_database(limit, max_id, since_id, min_id)
+  end
+
+  private
+
+  def from_database(limit, max_id, since_id, min_id)
+    loop do
+      statuses = Status.as_direct_timeline(@account, limit, max_id, since_id, min_id)
+      return statuses if statuses.empty?
+      max_id = statuses.last.id
+      statuses = statuses.reject { |status| FeedManager.instance.filter?(:direct, status, @account) }
+      return statuses unless statuses.empty?
+    end
+  end
+end
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index de965cb0b..eaee142fa 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -14,21 +14,29 @@ class Form::AdminSettings
     closed_registrations_message
     timeline_preview
     bootstrap_timeline_accounts
-    theme
+    flavour
+    skin
     activity_api_enabled
     peers_api_enabled
     preview_sensitive_media
     custom_css
     profile_directory
+    hide_followers_count
+    flavour_and_skin
     thumbnail
     mascot
+    show_reblogs_in_public_timelines
+    show_replies_in_public_timelines
     trends
     trends_as_landing_page
     trendable_by_default
+    trending_status_cw
     show_domain_blocks
     show_domain_blocks_rationale
     noindex
+    outgoing_spoilers
     require_invite_text
+    captcha_enabled
     media_cache_retention_period
     content_cache_retention_period
     backups_retention_period
@@ -47,11 +55,16 @@ class Form::AdminSettings
     peers_api_enabled
     preview_sensitive_media
     profile_directory
+    hide_followers_count
+    show_reblogs_in_public_timelines
+    show_replies_in_public_timelines
     trends
     trends_as_landing_page
     trendable_by_default
+    trending_status_cw
     noindex
     require_invite_text
+    captcha_enabled
   ).freeze
 
   UPLOAD_KEYS = %i(
@@ -59,6 +72,10 @@ class Form::AdminSettings
     mascot
   ).freeze
 
+  PSEUDO_KEYS = %i(
+    flavour_and_skin
+  ).freeze
+
   attr_accessor(*KEYS)
 
   validates :registrations_mode, inclusion: { in: %w(open approved none) }, if: -> { defined?(@registrations_mode) }
@@ -102,7 +119,7 @@ class Form::AdminSettings
     return false unless errors.empty? && valid?
 
     KEYS.each do |key|
-      next unless instance_variable_defined?("@#{key}")
+      next if PSEUDO_KEYS.include?(key) || !instance_variable_defined?("@#{key}")
 
       if UPLOAD_KEYS.include?(key)
         public_send(key).save
@@ -113,6 +130,14 @@ class Form::AdminSettings
     end
   end
 
+  def flavour_and_skin
+    "#{Setting.flavour}/#{Setting.skin}"
+  end
+
+  def flavour_and_skin=(value)
+    @flavour, @skin = value.split('/', 2)
+  end
+
   private
 
   def typecast_value(key, value)
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index e51e13b95..0367b4af7 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -39,8 +39,8 @@ class MediaAttachment < ApplicationRecord
 
   MAX_DESCRIPTION_LENGTH = 1_500
 
-  IMAGE_LIMIT = 16.megabytes
-  VIDEO_LIMIT = 99.megabytes
+  IMAGE_LIMIT = (ENV['MAX_IMAGE_SIZE'] || 16.megabytes).to_i
+  VIDEO_LIMIT = (ENV['MAX_VIDEO_SIZE'] || 99.megabytes).to_i
 
   MAX_VIDEO_MATRIX_LIMIT = 8_294_400 # 3840x2160px
   MAX_VIDEO_FRAME_RATE   = 120
diff --git a/app/models/public_feed.rb b/app/models/public_feed.rb
index 1cfd9a500..a987bb72c 100644
--- a/app/models/public_feed.rb
+++ b/app/models/public_feed.rb
@@ -8,6 +8,7 @@ class PublicFeed
   # @option [Boolean] :local
   # @option [Boolean] :remote
   # @option [Boolean] :only_media
+  # @option [Boolean] :allow_local_only
   def initialize(account, options = {})
     @account = account
     @options = options
@@ -21,6 +22,7 @@ class PublicFeed
   def get(limit, max_id = nil, since_id = nil, min_id = nil)
     scope = public_scope
 
+    scope.merge!(without_local_only_scope) unless allow_local_only?
     scope.merge!(without_replies_scope) unless with_replies?
     scope.merge!(without_reblogs_scope) unless with_reblogs?
     scope.merge!(local_only_scope) if local_only?
@@ -36,6 +38,10 @@ class PublicFeed
 
   attr_reader :account, :options
 
+  def allow_local_only?
+    local_account? && (local_only? || options[:allow_local_only])
+  end
+
   def with_reblogs?
     options[:with_reblogs]
   end
@@ -56,6 +62,10 @@ class PublicFeed
     account.present?
   end
 
+  def local_account?
+    account&.local?
+  end
+
   def media_only?
     options[:only_media]
   end
@@ -84,6 +94,10 @@ class PublicFeed
     Status.joins(:media_attachments).group(:id)
   end
 
+  def without_local_only_scope
+    Status.not_local_only
+  end
+
   def language_scope
     Status.where(language: account.chosen_languages)
   end
diff --git a/app/models/status.rb b/app/models/status.rb
index 2757497db..e01ddb5c5 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -22,7 +22,9 @@
 #  account_id                   :bigint(8)        not null
 #  application_id               :bigint(8)
 #  in_reply_to_account_id       :bigint(8)
+#  local_only                   :boolean
 #  poll_id                      :bigint(8)
+#  content_type                 :string
 #  deleted_at                   :datetime
 #  edited_at                    :datetime
 #  trendable                    :boolean
@@ -85,6 +87,7 @@ class Status < ApplicationRecord
   validates_with DisallowedHashtagsValidator
   validates :reblog, uniqueness: { scope: :account }, if: :reblog?
   validates :visibility, exclusion: { in: %w(direct limited) }, if: :reblog?
+  validates :content_type, inclusion: { in: %w(text/plain text/markdown text/html) }, allow_nil: true
 
   accepts_nested_attributes_for :poll
 
@@ -111,6 +114,8 @@ class Status < ApplicationRecord
     where('NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))', tag_ids)
   }
 
+  scope :not_local_only, -> { where(local_only: [false, nil]) }
+
   after_create_commit :trigger_create_webhooks
   after_update_commit :trigger_update_webhooks
 
@@ -322,6 +327,7 @@ class Status < ApplicationRecord
   before_validation :set_visibility
   before_validation :set_conversation
   before_validation :set_local
+  before_create :set_locality
 
   around_create Mastodon::Snowflake::Callbacks
 
@@ -332,6 +338,47 @@ class Status < ApplicationRecord
       visibilities.keys - %w(direct limited)
     end
 
+    def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil, cache_ids = false)
+      # direct timeline is mix of direct message from_me and to_me.
+      # 2 queries are executed with pagination.
+      # constant expression using arel_table is required for partial index
+
+      # _from_me part does not require any timeline filters
+      query_from_me = where(account_id: account.id)
+                      .where(Status.arel_table[:visibility].eq(3))
+                      .limit(limit)
+                      .order('statuses.id DESC')
+
+      # _to_me part requires mute and block filter.
+      # FIXME: may we check mutes.hide_notifications?
+      query_to_me = Status
+                    .joins(:mentions)
+                    .merge(Mention.where(account_id: account.id))
+                    .where(Status.arel_table[:visibility].eq(3))
+                    .limit(limit)
+                    .order('mentions.status_id DESC')
+                    .not_excluded_by_account(account)
+
+      if max_id.present?
+        query_from_me = query_from_me.where('statuses.id < ?', max_id)
+        query_to_me = query_to_me.where('mentions.status_id < ?', max_id)
+      end
+
+      if since_id.present?
+        query_from_me = query_from_me.where('statuses.id > ?', since_id)
+        query_to_me = query_to_me.where('mentions.status_id > ?', since_id)
+      end
+
+      if cache_ids
+        # returns array of cache_ids object that have id and updated_at
+        (query_from_me.cache_ids.to_a + query_to_me.cache_ids.to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
+      else
+        # returns ActiveRecord.Relation
+        items = (query_from_me.select(:id).to_a + query_to_me.select(:id).to_a).uniq(&:id).sort_by(&:id).reverse.take(limit)
+        Status.where(id: items.map(&:id))
+      end
+    end
+
     def favourites_map(status_ids, account_id)
       Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true }
     end
@@ -387,6 +434,15 @@ class Status < ApplicationRecord
     end
   end
 
+  def marked_local_only?
+    # match both with and without U+FE0F (the emoji variation selector)
+    /#{local_only_emoji}\ufe0f?\z/.match?(content)
+  end
+
+  def local_only_emoji
+    '👁'
+  end
+
   def status_stat
     super || build_status_stat
   end
@@ -496,6 +552,12 @@ class Status < ApplicationRecord
     self.sensitive  = false if sensitive.nil?
   end
 
+  def set_locality
+    if account.domain.nil? && !attribute_changed?(:local_only)
+      self.local_only = marked_local_only?
+    end
+  end
+
   def set_conversation
     self.thread = thread.reblog if thread&.reblog?
 
diff --git a/app/models/status_edit.rb b/app/models/status_edit.rb
index 2b3248bb2..fa35e38ac 100644
--- a/app/models/status_edit.rb
+++ b/app/models/status_edit.rb
@@ -11,6 +11,7 @@
 #  spoiler_text                 :text             default(""), not null
 #  created_at                   :datetime         not null
 #  updated_at                   :datetime         not null
+#  content_type                 :string
 #  ordered_media_attachment_ids :bigint(8)        is an Array
 #  media_descriptions           :text             is an Array
 #  poll_options                 :string           is an Array
diff --git a/app/models/tag_feed.rb b/app/models/tag_feed.rb
index b8cd63557..fbbdbaae2 100644
--- a/app/models/tag_feed.rb
+++ b/app/models/tag_feed.rb
@@ -25,6 +25,7 @@ class TagFeed < PublicFeed
   def get(limit, max_id = nil, since_id = nil, min_id = nil)
     scope = public_scope
 
+    scope.merge!(without_local_only_scope) unless local_account?
     scope.merge!(tagged_with_any_scope)
     scope.merge!(tagged_with_all_scope)
     scope.merge!(tagged_with_none_scope)
diff --git a/app/models/trends.rb b/app/models/trends.rb
index d07d62b71..b09db940e 100644
--- a/app/models/trends.rb
+++ b/app/models/trends.rb
@@ -32,10 +32,13 @@ module Trends
     tags_requiring_review     = tags.request_review
     statuses_requiring_review = statuses.request_review
 
-    return if links_requiring_review.empty? && tags_requiring_review.empty? && statuses_requiring_review.empty?
-
     User.those_who_can(:manage_taxonomies).includes(:account).find_each do |user|
-      AdminMailer.new_trends(user.account, links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails?
+      links    = user.allows_trending_links_review_emails? ? links_requiring_review : []
+      tags     = user.allows_trending_tags_review_emails? ? tags_requiring_review : []
+      statuses = user.allows_trending_statuses_review_emails? ? statuses_requiring_review : []
+      next if links.empty? && tags.empty? && statuses.empty?
+
+      AdminMailer.new_trends(user.account, links, tags, statuses).deliver_later!
     end
   end
 
diff --git a/app/models/trends/statuses.rb b/app/models/trends/statuses.rb
index 84bff9c02..d4d5b1c24 100644
--- a/app/models/trends/statuses.rb
+++ b/app/models/trends/statuses.rb
@@ -91,7 +91,7 @@ class Trends::Statuses < Trends::Base
   private
 
   def eligible?(status)
-    status.public_visibility? && status.account.discoverable? && !status.account.silenced? && status.spoiler_text.blank? && !status.sensitive? && !status.reply? && valid_locale?(status.language)
+    status.public_visibility? && status.account.discoverable? && !status.account.silenced? && (status.spoiler_text.blank? || Setting.trending_status_cw) && !status.sensitive? && !status.reply? && valid_locale?(status.language)
   end
 
   def calculate_scores(statuses, at_time)
diff --git a/app/models/user.rb b/app/models/user.rb
index 9b225d75f..3471bb2c1 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -244,7 +244,8 @@ class User < ApplicationRecord
   end
 
   def functional?
-    functional_or_moved? && account.moved_to_account_id.nil?
+
+    functional_or_moved?
   end
 
   def functional_or_moved?
diff --git a/app/models/user_settings.rb b/app/models/user_settings.rb
index 2c025d6c5..0be8c5fbc 100644
--- a/app/models/user_settings.rb
+++ b/app/models/user_settings.rb
@@ -9,12 +9,15 @@ class UserSettings
 
   setting :always_send_emails, default: false
   setting :aggregate_reblogs, default: true
-  setting :theme, default: -> { ::Setting.theme }
+  setting :flavour, default: -> { ::Setting.flavour }
+  setting :skin, default: -> { ::Setting.skin }
   setting :noindex, default: -> { ::Setting.noindex }
   setting :show_application, default: true
   setting :default_language, default: nil
   setting :default_sensitive, default: false
   setting :default_privacy, default: nil
+  setting :default_content_type, default: 'text/plain'
+  setting :hide_followers_count, default: false
 
   namespace :web do
     setting :crop_images, default: true
@@ -27,10 +30,12 @@ class UserSettings
     setting :delete_modal, default: true
     setting :reblog_modal, default: false
     setting :unfollow_modal, default: true
+    setting :favourite_modal, default: false
     setting :reduce_motion, default: false
     setting :expand_content_warnings, default: false
     setting :display_media, default: 'default', in: %w(default show_all hide_all)
     setting :auto_play, default: false
+    setting :use_system_emoji_font, default: false
   end
 
   namespace :notification_emails do
@@ -42,6 +47,8 @@ class UserSettings
     setting :report, default: true
     setting :pending_account, default: true
     setting :trends, default: true
+    setting :link_trends, default: false
+    setting :status_trends, default: false
     setting :appeal, default: true
   end