mirror of
https://github.com/discourse/discourse.git
synced 2024-11-30 12:23:46 +08:00
a08691a599
Settings that are using the new `file_size_restriction` types like the
`max_image_size_kb` setting need to have their values saved as integers.
This was a recent regression in 00209f03e6
that caused these values to be saved as strings.
This change also removes negatives from the validation regex because
file sizes can't be negative anyways.
Bug report: https://meta.discourse.org/t/289037
2193 lines
70 KiB
Ruby
2193 lines
70 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Topic < ActiveRecord::Base
|
|
class UserExists < StandardError
|
|
end
|
|
|
|
class NotAllowed < StandardError
|
|
end
|
|
include RateLimiter::OnCreateRecord
|
|
include HasCustomFields
|
|
include Trashable
|
|
include Searchable
|
|
include LimitedEdit
|
|
extend Forwardable
|
|
|
|
EXTERNAL_ID_MAX_LENGTH = 50
|
|
|
|
self.ignored_columns = [
|
|
"avg_time", # TODO(2021-01-04): remove
|
|
"image_url", # TODO(2021-06-01): remove
|
|
]
|
|
|
|
def_delegator :featured_users, :user_ids, :featured_user_ids
|
|
def_delegator :featured_users, :choose, :feature_topic_users
|
|
|
|
def_delegator :notifier, :watch!, :notify_watch!
|
|
def_delegator :notifier, :track!, :notify_tracking!
|
|
def_delegator :notifier, :regular!, :notify_regular!
|
|
def_delegator :notifier, :mute!, :notify_muted!
|
|
def_delegator :notifier, :toggle_mute, :toggle_mute
|
|
|
|
attr_accessor :allowed_user_ids, :allowed_group_ids, :tags_changed, :includes_destination_category
|
|
|
|
def self.max_fancy_title_length
|
|
400
|
|
end
|
|
|
|
def self.share_thumbnail_size
|
|
[1024, 1024]
|
|
end
|
|
|
|
def self.thumbnail_sizes
|
|
[self.share_thumbnail_size] + DiscoursePluginRegistry.topic_thumbnail_sizes
|
|
end
|
|
|
|
def thumbnail_job_redis_key(sizes)
|
|
"generate_topic_thumbnail_enqueue_#{id}_#{sizes.inspect}"
|
|
end
|
|
|
|
def filtered_topic_thumbnails(extra_sizes: [])
|
|
return nil unless original = image_upload
|
|
return nil unless original.read_attribute(:width) && original.read_attribute(:height)
|
|
|
|
thumbnail_sizes = Topic.thumbnail_sizes + extra_sizes
|
|
topic_thumbnails.filter do |record|
|
|
thumbnail_sizes.include?([record.max_width, record.max_height])
|
|
end
|
|
end
|
|
|
|
def thumbnail_info(enqueue_if_missing: false, extra_sizes: [])
|
|
return nil unless original = image_upload
|
|
return nil if original.filesize >= SiteSetting.max_image_size_kb.to_i.kilobytes
|
|
return nil unless original.read_attribute(:width) && original.read_attribute(:height)
|
|
|
|
infos = []
|
|
infos << { # Always add original
|
|
max_width: nil,
|
|
max_height: nil,
|
|
width: original.width,
|
|
height: original.height,
|
|
url: original.url,
|
|
}
|
|
|
|
records = filtered_topic_thumbnails(extra_sizes: extra_sizes)
|
|
|
|
records.each do |record|
|
|
next unless record.optimized_image # Only serialize successful thumbnails
|
|
|
|
infos << {
|
|
max_width: record.max_width,
|
|
max_height: record.max_height,
|
|
width: record.optimized_image&.width,
|
|
height: record.optimized_image&.height,
|
|
url: record.optimized_image&.url,
|
|
}
|
|
end
|
|
|
|
thumbnail_sizes = Topic.thumbnail_sizes + extra_sizes
|
|
if SiteSetting.create_thumbnails && enqueue_if_missing &&
|
|
records.length < thumbnail_sizes.length &&
|
|
Discourse.redis.set(thumbnail_job_redis_key(extra_sizes), 1, nx: true, ex: 1.minute)
|
|
Jobs.enqueue(:generate_topic_thumbnails, { topic_id: id, extra_sizes: extra_sizes })
|
|
end
|
|
|
|
infos.each { |i| i[:url] = UrlHelper.cook_url(i[:url], secure: original.secure?, local: true) }
|
|
|
|
infos.sort_by! { |i| -i[:width] * i[:height] }
|
|
end
|
|
|
|
def generate_thumbnails!(extra_sizes: [])
|
|
return nil unless SiteSetting.create_thumbnails
|
|
return nil unless original = image_upload
|
|
return nil if original.filesize >= SiteSetting.max_image_size_kb.kilobytes
|
|
return nil unless original.width && original.height
|
|
extra_sizes = [] unless extra_sizes.kind_of?(Array)
|
|
|
|
(Topic.thumbnail_sizes + extra_sizes).each do |dim|
|
|
TopicThumbnail.find_or_create_for!(original, max_width: dim[0], max_height: dim[1])
|
|
end
|
|
end
|
|
|
|
def image_url(enqueue_if_missing: false)
|
|
thumbnail =
|
|
topic_thumbnails.detect do |record|
|
|
record.max_width == Topic.share_thumbnail_size[0] &&
|
|
record.max_height == Topic.share_thumbnail_size[1]
|
|
end
|
|
|
|
if thumbnail.nil? && image_upload && SiteSetting.create_thumbnails &&
|
|
image_upload.filesize < SiteSetting.max_image_size_kb.kilobytes &&
|
|
image_upload.read_attribute(:width) && image_upload.read_attribute(:height) &&
|
|
enqueue_if_missing &&
|
|
Discourse.redis.set(thumbnail_job_redis_key([]), 1, nx: true, ex: 1.minute)
|
|
Jobs.enqueue(:generate_topic_thumbnails, { topic_id: id })
|
|
end
|
|
|
|
raw_url = thumbnail&.optimized_image&.url || image_upload&.url
|
|
UrlHelper.cook_url(raw_url, secure: image_upload&.secure?, local: true) if raw_url
|
|
end
|
|
|
|
def featured_users
|
|
@featured_users ||= TopicFeaturedUsers.new(self)
|
|
end
|
|
|
|
def trash!(trashed_by = nil)
|
|
trigger_event = false
|
|
|
|
if deleted_at.nil?
|
|
update_category_topic_count_by(-1) if visible?
|
|
CategoryTagStat.topic_deleted(self) if self.tags.present?
|
|
trigger_event = true
|
|
end
|
|
|
|
super(trashed_by)
|
|
|
|
DiscourseEvent.trigger(:topic_trashed, self) if trigger_event
|
|
|
|
self.topic_embed.trash! if has_topic_embed?
|
|
end
|
|
|
|
def recover!(recovered_by = nil)
|
|
trigger_event = false
|
|
|
|
unless deleted_at.nil?
|
|
update_category_topic_count_by(1) if visible?
|
|
CategoryTagStat.topic_recovered(self) if self.tags.present?
|
|
trigger_event = true
|
|
end
|
|
|
|
# Note parens are required because superclass doesn't take `recovered_by`
|
|
super()
|
|
|
|
DiscourseEvent.trigger(:topic_recovered, self) if trigger_event
|
|
|
|
unless (topic_embed = TopicEmbed.with_deleted.find_by_topic_id(id)).nil?
|
|
topic_embed.recover!
|
|
end
|
|
end
|
|
|
|
rate_limit :default_rate_limiter
|
|
rate_limit :limit_topics_per_day
|
|
rate_limit :limit_private_messages_per_day
|
|
|
|
validates :title,
|
|
if: Proc.new { |t| t.new_record? || t.title_changed? },
|
|
presence: true,
|
|
topic_title_length: true,
|
|
censored_words: true,
|
|
watched_words: true,
|
|
quality_title: {
|
|
unless: :private_message?,
|
|
},
|
|
max_emojis: true,
|
|
unique_among: {
|
|
unless:
|
|
Proc.new { |t| (SiteSetting.allow_duplicate_topic_titles? || t.private_message?) },
|
|
message: :has_already_been_used,
|
|
allow_blank: true,
|
|
case_sensitive: false,
|
|
collection:
|
|
Proc.new { |t|
|
|
if SiteSetting.allow_duplicate_topic_titles_category?
|
|
Topic.listable_topics.where("category_id = ?", t.category_id)
|
|
else
|
|
Topic.listable_topics
|
|
end
|
|
},
|
|
}
|
|
|
|
validates :category_id,
|
|
presence: true,
|
|
exclusion: {
|
|
in: Proc.new { [SiteSetting.uncategorized_category_id] },
|
|
},
|
|
if:
|
|
Proc.new { |t|
|
|
(t.new_record? || t.category_id_changed?) &&
|
|
!SiteSetting.allow_uncategorized_topics && (t.archetype.nil? || t.regular?)
|
|
}
|
|
|
|
validates :featured_link, allow_nil: true, url: true
|
|
validate if: :featured_link do
|
|
if featured_link_changed? && !Guardian.new(user).can_edit_featured_link?(category_id)
|
|
errors.add(:featured_link)
|
|
end
|
|
end
|
|
|
|
validates :external_id,
|
|
allow_nil: true,
|
|
uniqueness: {
|
|
case_sensitive: false,
|
|
},
|
|
length: {
|
|
maximum: EXTERNAL_ID_MAX_LENGTH,
|
|
},
|
|
format: {
|
|
with: /\A[\w-]+\z/,
|
|
}
|
|
|
|
before_validation do
|
|
self.title = TextCleaner.clean_title(TextSentinel.title_sentinel(title).text) if errors[
|
|
:title
|
|
].empty?
|
|
self.featured_link = self.featured_link.strip.presence if self.featured_link
|
|
end
|
|
|
|
belongs_to :category
|
|
has_many :category_users, through: :category
|
|
has_many :posts
|
|
|
|
# NOTE: To get all Post _and_ Topic bookmarks for a topic by user,
|
|
# use the Bookmark.for_user_in_topic scope.
|
|
has_many :bookmarks, as: :bookmarkable
|
|
|
|
has_many :ordered_posts, -> { order(post_number: :asc) }, class_name: "Post"
|
|
has_many :topic_allowed_users
|
|
has_many :topic_allowed_groups
|
|
has_many :incoming_email
|
|
|
|
has_many :group_archived_messages, dependent: :destroy
|
|
has_many :user_archived_messages, dependent: :destroy
|
|
|
|
has_many :allowed_groups, through: :topic_allowed_groups, source: :group
|
|
has_many :allowed_group_users, through: :allowed_groups, source: :users
|
|
has_many :allowed_users, through: :topic_allowed_users, source: :user
|
|
|
|
has_many :topic_tags
|
|
has_many :tags, through: :topic_tags, dependent: :destroy # dependent destroy applies to the topic_tags records
|
|
has_many :tag_users, through: :tags
|
|
|
|
has_one :top_topic
|
|
has_one :shared_draft, dependent: :destroy
|
|
has_one :published_page
|
|
|
|
belongs_to :user
|
|
belongs_to :last_poster, class_name: "User", foreign_key: :last_post_user_id
|
|
belongs_to :featured_user1, class_name: "User", foreign_key: :featured_user1_id
|
|
belongs_to :featured_user2, class_name: "User", foreign_key: :featured_user2_id
|
|
belongs_to :featured_user3, class_name: "User", foreign_key: :featured_user3_id
|
|
belongs_to :featured_user4, class_name: "User", foreign_key: :featured_user4_id
|
|
|
|
has_many :topic_users
|
|
has_many :dismissed_topic_users
|
|
has_many :topic_links
|
|
has_many :topic_invites
|
|
has_many :invites, through: :topic_invites, source: :invite
|
|
has_many :topic_timers, dependent: :destroy
|
|
has_many :reviewables
|
|
has_many :user_profiles
|
|
|
|
has_one :user_warning
|
|
has_one :first_post, -> { where post_number: 1 }, class_name: "Post"
|
|
has_one :topic_search_data
|
|
has_one :topic_embed, dependent: :destroy
|
|
has_one :linked_topic, dependent: :destroy
|
|
|
|
belongs_to :image_upload, class_name: "Upload"
|
|
has_many :topic_thumbnails, through: :image_upload
|
|
|
|
# When we want to temporarily attach some data to a forum topic (usually before serialization)
|
|
attr_accessor :user_data
|
|
attr_accessor :category_user_data
|
|
attr_accessor :dismissed
|
|
|
|
attr_accessor :posters # TODO: can replace with posters_summary once we remove old list code
|
|
attr_accessor :participants
|
|
attr_accessor :participant_groups
|
|
attr_accessor :topic_list
|
|
attr_accessor :include_last_poster
|
|
attr_accessor :import_mode # set to true to optimize creation and save for imports
|
|
|
|
# The regular order
|
|
scope :topic_list_order, -> { order("topics.bumped_at desc") }
|
|
|
|
# Return private message topics
|
|
scope :private_messages, -> { where(archetype: Archetype.private_message) }
|
|
|
|
PRIVATE_MESSAGES_SQL_USER = <<~SQL
|
|
SELECT topic_id
|
|
FROM topic_allowed_users
|
|
WHERE user_id = :user_id
|
|
SQL
|
|
|
|
PRIVATE_MESSAGES_SQL_GROUP = <<~SQL
|
|
SELECT tg.topic_id
|
|
FROM topic_allowed_groups tg
|
|
JOIN group_users gu ON gu.user_id = :user_id AND gu.group_id = tg.group_id
|
|
SQL
|
|
|
|
scope :private_messages_for_user,
|
|
->(user) do
|
|
private_messages.where(
|
|
"topics.id IN (#{PRIVATE_MESSAGES_SQL_USER})
|
|
OR topics.id IN (#{PRIVATE_MESSAGES_SQL_GROUP})",
|
|
user_id: user.id,
|
|
)
|
|
end
|
|
|
|
scope :listable_topics, -> { where("topics.archetype <> ?", Archetype.private_message) }
|
|
|
|
scope :by_newest, -> { order("topics.created_at desc, topics.id desc") }
|
|
|
|
scope :visible, -> { where(visible: true) }
|
|
|
|
scope :created_since, lambda { |time_ago| where("topics.created_at > ?", time_ago) }
|
|
|
|
scope :exclude_scheduled_bump_topics, -> { where.not(id: TopicTimer.scheduled_bump_topics) }
|
|
|
|
scope :secured,
|
|
lambda { |guardian = nil|
|
|
ids = guardian.secure_category_ids if guardian
|
|
|
|
# Query conditions
|
|
condition =
|
|
if ids.present?
|
|
["NOT read_restricted OR id IN (:cats)", cats: ids]
|
|
else
|
|
["NOT read_restricted"]
|
|
end
|
|
|
|
where(
|
|
"topics.category_id IS NULL OR topics.category_id IN (SELECT id FROM categories WHERE #{condition[0]})",
|
|
condition[1],
|
|
)
|
|
}
|
|
|
|
scope :in_category_and_subcategories,
|
|
lambda { |category_id|
|
|
if category_id
|
|
where("topics.category_id IN (?)", Category.subcategory_ids(category_id.to_i))
|
|
end
|
|
}
|
|
|
|
scope :with_subtype, ->(subtype) { where("topics.subtype = ?", subtype) }
|
|
|
|
attr_accessor :ignore_category_auto_close
|
|
attr_accessor :skip_callbacks
|
|
attr_accessor :advance_draft
|
|
|
|
before_create { initialize_default_values }
|
|
|
|
after_create do
|
|
unless skip_callbacks
|
|
changed_to_category(category)
|
|
advance_draft_sequence if advance_draft
|
|
end
|
|
end
|
|
|
|
before_save do
|
|
ensure_topic_has_a_category unless skip_callbacks
|
|
|
|
write_attribute(:fancy_title, Topic.fancy_title(title)) if title_changed?
|
|
|
|
if category_id_changed? || new_record?
|
|
inherit_auto_close_from_category
|
|
inherit_slow_mode_from_category
|
|
end
|
|
end
|
|
|
|
after_save do
|
|
banner = "banner"
|
|
|
|
if archetype_before_last_save == banner || archetype == banner
|
|
ApplicationController.banner_json_cache.clear
|
|
end
|
|
|
|
if tags_changed || saved_change_to_attribute?(:category_id) ||
|
|
saved_change_to_attribute?(:title)
|
|
SearchIndexer.queue_post_reindex(self.id)
|
|
|
|
if tags_changed
|
|
TagUser.auto_watch(topic_id: id)
|
|
TagUser.auto_track(topic_id: id)
|
|
self.tags_changed = false
|
|
end
|
|
end
|
|
|
|
SearchIndexer.index(self)
|
|
end
|
|
|
|
after_update do
|
|
if saved_changes[:category_id] && self.tags.present?
|
|
CategoryTagStat.topic_moved(self, *saved_changes[:category_id])
|
|
elsif saved_changes[:category_id] && self.category&.read_restricted?
|
|
UserProfile.remove_featured_topic_from_all_profiles(self)
|
|
end
|
|
end
|
|
|
|
def initialize_default_values
|
|
self.bumped_at ||= Time.now
|
|
self.last_post_user_id ||= user_id
|
|
end
|
|
|
|
def advance_draft_sequence
|
|
if self.private_message?
|
|
DraftSequence.next!(user, Draft::NEW_PRIVATE_MESSAGE)
|
|
else
|
|
DraftSequence.next!(user, Draft::NEW_TOPIC)
|
|
end
|
|
end
|
|
|
|
def ensure_topic_has_a_category
|
|
if category_id.nil? && (archetype.nil? || self.regular?)
|
|
self.category_id = category&.id || SiteSetting.uncategorized_category_id
|
|
end
|
|
end
|
|
|
|
def self.visible_post_types(viewed_by = nil, include_moderator_actions: true)
|
|
types = Post.types
|
|
result = [types[:regular]]
|
|
result += [types[:moderator_action], types[:small_action]] if include_moderator_actions
|
|
result << types[:whisper] if viewed_by&.whisperer?
|
|
result
|
|
end
|
|
|
|
def self.top_viewed(max = 10)
|
|
Topic.listable_topics.visible.secured.order("views desc").limit(max)
|
|
end
|
|
|
|
def self.recent(max = 10)
|
|
Topic.listable_topics.visible.secured.order("created_at desc").limit(max)
|
|
end
|
|
|
|
def self.count_exceeds_minimum?
|
|
count > SiteSetting.minimum_topics_similar
|
|
end
|
|
|
|
def best_post
|
|
posts
|
|
.where(post_type: Post.types[:regular], user_deleted: false)
|
|
.order("score desc nulls last")
|
|
.limit(1)
|
|
.first
|
|
end
|
|
|
|
def self.has_flag_scope
|
|
ReviewableFlaggedPost.pending_and_default_visible
|
|
end
|
|
|
|
def has_flags?
|
|
self.class.has_flag_scope.exists?(topic_id: self.id)
|
|
end
|
|
|
|
def is_official_warning?
|
|
subtype == TopicSubtype.moderator_warning
|
|
end
|
|
|
|
# all users (in groups or directly targeted) that are going to get the pm
|
|
def all_allowed_users
|
|
moderators_sql = " UNION #{User.moderators.to_sql}" if private_message? &&
|
|
(has_flags? || is_official_warning?)
|
|
User.from(
|
|
"(#{allowed_users.to_sql} UNION #{allowed_group_users.to_sql}#{moderators_sql}) as users",
|
|
)
|
|
end
|
|
|
|
# Additional rate limits on topics: per day and private messages per day
|
|
def limit_topics_per_day
|
|
return unless regular?
|
|
if user && user.new_user_posting_on_first_day?
|
|
limit_first_day_topics_per_day
|
|
else
|
|
apply_per_day_rate_limit_for("topics", :max_topics_per_day)
|
|
end
|
|
end
|
|
|
|
def limit_private_messages_per_day
|
|
return unless private_message?
|
|
apply_per_day_rate_limit_for("pms", :max_personal_messages_per_day)
|
|
end
|
|
|
|
def self.fancy_title(title)
|
|
return unless escaped = ERB::Util.html_escape(title)
|
|
fancy_title = Emoji.unicode_unescape(HtmlPrettify.render(escaped))
|
|
fancy_title.length > Topic.max_fancy_title_length ? escaped : fancy_title
|
|
end
|
|
|
|
def fancy_title
|
|
return ERB::Util.html_escape(title) unless SiteSetting.title_fancy_entities?
|
|
|
|
unless fancy_title = read_attribute(:fancy_title)
|
|
fancy_title = Topic.fancy_title(title)
|
|
write_attribute(:fancy_title, fancy_title)
|
|
|
|
if !new_record? && !Discourse.readonly_mode?
|
|
# make sure data is set in table, this also allows us to change algorithm
|
|
# by simply nulling this column
|
|
DB.exec(
|
|
"UPDATE topics SET fancy_title = :fancy_title where id = :id",
|
|
id: self.id,
|
|
fancy_title: fancy_title,
|
|
)
|
|
end
|
|
end
|
|
|
|
fancy_title
|
|
end
|
|
|
|
# Returns hot topics since a date for display in email digest.
|
|
def self.for_digest(user, since, opts = nil)
|
|
opts = opts || {}
|
|
period = ListController.best_period_for(since)
|
|
|
|
topics =
|
|
Topic
|
|
.visible
|
|
.secured(Guardian.new(user))
|
|
.joins(
|
|
"LEFT OUTER JOIN topic_users ON topic_users.topic_id = topics.id AND topic_users.user_id = #{user.id.to_i}",
|
|
)
|
|
.joins(
|
|
"LEFT OUTER JOIN category_users ON category_users.category_id = topics.category_id AND category_users.user_id = #{user.id.to_i}",
|
|
)
|
|
.joins("LEFT OUTER JOIN users ON users.id = topics.user_id")
|
|
.where(closed: false, archived: false)
|
|
.where(
|
|
"COALESCE(topic_users.notification_level, 1) <> ?",
|
|
TopicUser.notification_levels[:muted],
|
|
)
|
|
.created_since(since)
|
|
.where("topics.created_at < ?", (SiteSetting.editing_grace_period || 0).seconds.ago)
|
|
.listable_topics
|
|
.includes(:category)
|
|
|
|
unless opts[:include_tl0] || user.user_option.try(:include_tl0_in_digests)
|
|
topics = topics.where("COALESCE(users.trust_level, 0) > 0")
|
|
end
|
|
|
|
if !!opts[:top_order]
|
|
topics =
|
|
topics.joins("LEFT OUTER JOIN top_topics ON top_topics.topic_id = topics.id").order(<<~SQL)
|
|
COALESCE(topic_users.notification_level, 1) DESC,
|
|
COALESCE(category_users.notification_level, 1) DESC,
|
|
COALESCE(top_topics.#{TopTopic.score_column_for_period(period)}, 0) DESC,
|
|
topics.bumped_at DESC
|
|
SQL
|
|
end
|
|
|
|
topics = topics.limit(opts[:limit]) if opts[:limit]
|
|
|
|
# Remove category topics
|
|
topics = topics.where.not(id: Category.select(:topic_id).where.not(topic_id: nil))
|
|
|
|
# Remove muted and shared draft categories
|
|
remove_category_ids =
|
|
CategoryUser.where(
|
|
user_id: user.id,
|
|
notification_level: CategoryUser.notification_levels[:muted],
|
|
).pluck(:category_id)
|
|
if SiteSetting.digest_suppress_categories.present?
|
|
topics =
|
|
topics.where(
|
|
"topics.category_id NOT IN (?)",
|
|
SiteSetting.digest_suppress_categories.split("|").map(&:to_i),
|
|
)
|
|
end
|
|
if SiteSetting.digest_suppress_tags.present?
|
|
tag_ids = Tag.where_name(SiteSetting.digest_suppress_tags.split("|")).pluck(:id)
|
|
if tag_ids.present?
|
|
topics =
|
|
topics.joins("LEFT JOIN topic_tags tg ON topics.id = tg.topic_id").where(
|
|
"tg.tag_id NOT IN (?) OR tg.tag_id IS NULL",
|
|
tag_ids,
|
|
)
|
|
end
|
|
end
|
|
remove_category_ids << SiteSetting.shared_drafts_category if SiteSetting.shared_drafts_enabled?
|
|
if remove_category_ids.present?
|
|
remove_category_ids.uniq!
|
|
topics =
|
|
topics.where(
|
|
"topic_users.notification_level != ? OR topics.category_id NOT IN (?)",
|
|
TopicUser.notification_levels[:muted],
|
|
remove_category_ids,
|
|
)
|
|
end
|
|
|
|
# Remove muted tags
|
|
muted_tag_ids = TagUser.lookup(user, :muted).pluck(:tag_id)
|
|
unless muted_tag_ids.empty?
|
|
# If multiple tags per topic, include topics with tags that aren't muted,
|
|
# and don't forget untagged topics.
|
|
topics =
|
|
topics.where(
|
|
"EXISTS ( SELECT 1 FROM topic_tags WHERE topic_tags.topic_id = topics.id AND tag_id NOT IN (?) )
|
|
OR NOT EXISTS (SELECT 1 FROM topic_tags WHERE topic_tags.topic_id = topics.id)",
|
|
muted_tag_ids,
|
|
)
|
|
end
|
|
|
|
topics
|
|
end
|
|
|
|
def reload(options = nil)
|
|
@post_numbers = nil
|
|
@public_topic_timer = nil
|
|
@slow_mode_topic_timer = nil
|
|
@is_category_topic = nil
|
|
super(options)
|
|
end
|
|
|
|
def post_numbers
|
|
@post_numbers ||= posts.order(:post_number).pluck(:post_number)
|
|
end
|
|
|
|
def age_in_minutes
|
|
((Time.zone.now - created_at) / 1.minute).round
|
|
end
|
|
|
|
def self.listable_count_per_day(
|
|
start_date,
|
|
end_date,
|
|
category_id = nil,
|
|
include_subcategories = false,
|
|
group_ids = nil
|
|
)
|
|
result =
|
|
listable_topics.where(
|
|
"topics.created_at >= ? AND topics.created_at <= ?",
|
|
start_date,
|
|
end_date,
|
|
)
|
|
result = result.group("date(topics.created_at)").order("date(topics.created_at)")
|
|
result =
|
|
result.where(
|
|
category_id: include_subcategories ? Category.subcategory_ids(category_id) : category_id,
|
|
) if category_id
|
|
|
|
if group_ids
|
|
result =
|
|
result
|
|
.joins("INNER JOIN users ON users.id = topics.user_id")
|
|
.joins("INNER JOIN group_users ON group_users.user_id = users.id")
|
|
.where("group_users.group_id IN (?)", group_ids)
|
|
end
|
|
|
|
result.count
|
|
end
|
|
|
|
def private_message?
|
|
self.archetype == Archetype.private_message
|
|
end
|
|
|
|
def regular?
|
|
self.archetype == Archetype.default
|
|
end
|
|
|
|
def open?
|
|
!self.closed?
|
|
end
|
|
|
|
MAX_SIMILAR_BODY_LENGTH ||= 200
|
|
|
|
def self.similar_to(title, raw, user = nil)
|
|
return [] if title.blank?
|
|
raw = raw.presence || ""
|
|
search_data = Search.prepare_data(title.strip)
|
|
|
|
return [] if search_data.blank?
|
|
|
|
tsquery = Search.set_tsquery_weight_filter(search_data, "A")
|
|
|
|
if raw.present?
|
|
cooked =
|
|
SearchIndexer::HtmlScrubber.scrub(PrettyText.cook(raw[0...MAX_SIMILAR_BODY_LENGTH].strip))
|
|
|
|
prepared_data = cooked.present? && Search.prepare_data(cooked)
|
|
|
|
if prepared_data.present?
|
|
raw_tsquery = Search.set_tsquery_weight_filter(prepared_data, "B")
|
|
|
|
tsquery = "#{tsquery} & #{raw_tsquery}"
|
|
end
|
|
end
|
|
|
|
tsquery = Search.to_tsquery(term: tsquery, joiner: "|")
|
|
|
|
guardian = Guardian.new(user)
|
|
|
|
excluded_category_ids_sql =
|
|
Category
|
|
.secured(guardian)
|
|
.where(search_priority: Searchable::PRIORITIES[:ignore])
|
|
.select(:id)
|
|
.to_sql
|
|
|
|
excluded_category_ids_sql = <<~SQL if user
|
|
#{excluded_category_ids_sql}
|
|
UNION
|
|
#{CategoryUser.muted_category_ids_query(user, include_direct: true).select("categories.id").to_sql}
|
|
SQL
|
|
|
|
candidates =
|
|
Topic
|
|
.visible
|
|
.listable_topics
|
|
.secured(guardian)
|
|
.joins("JOIN topic_search_data s ON topics.id = s.topic_id")
|
|
.joins("LEFT JOIN categories c ON topics.id = c.topic_id")
|
|
.where("search_data @@ #{tsquery}")
|
|
.where("c.topic_id IS NULL")
|
|
.where("topics.category_id NOT IN (#{excluded_category_ids_sql})")
|
|
.order("ts_rank(search_data, #{tsquery}) DESC")
|
|
.limit(SiteSetting.max_similar_results * 3)
|
|
|
|
candidate_ids = candidates.pluck(:id)
|
|
|
|
return [] if candidate_ids.blank?
|
|
|
|
similars =
|
|
Topic
|
|
.joins("JOIN posts AS p ON p.topic_id = topics.id AND p.post_number = 1")
|
|
.where("topics.id IN (?)", candidate_ids)
|
|
.order("similarity DESC")
|
|
.limit(SiteSetting.max_similar_results)
|
|
|
|
if raw.present?
|
|
similars.select(
|
|
DB.sql_fragment(
|
|
"topics.*, similarity(topics.title, :title) + similarity(p.raw, :raw) AS similarity, p.cooked AS blurb",
|
|
title: title,
|
|
raw: raw,
|
|
),
|
|
).where(
|
|
"similarity(topics.title, :title) + similarity(p.raw, :raw) > 0.2",
|
|
title: title,
|
|
raw: raw,
|
|
)
|
|
else
|
|
similars.select(
|
|
DB.sql_fragment(
|
|
"topics.*, similarity(topics.title, :title) AS similarity, p.cooked AS blurb",
|
|
title: title,
|
|
),
|
|
).where("similarity(topics.title, :title) > 0.2", title: title)
|
|
end
|
|
end
|
|
|
|
def update_status(status, enabled, user, opts = {})
|
|
TopicStatusUpdater.new(self, user).update!(status, enabled, opts)
|
|
DiscourseEvent.trigger(:topic_status_updated, self, status, enabled)
|
|
|
|
if status == "closed"
|
|
StaffActionLogger.new(user).log_topic_closed(self, closed: enabled)
|
|
elsif status == "archived"
|
|
StaffActionLogger.new(user).log_topic_archived(self, archived: enabled)
|
|
end
|
|
|
|
if enabled && private_message? && status.to_s["closed"]
|
|
group_ids = user.groups.pluck(:id)
|
|
if group_ids.present?
|
|
allowed_group_ids =
|
|
self.allowed_groups.where("topic_allowed_groups.group_id IN (?)", group_ids).pluck(:id)
|
|
allowed_group_ids.each { |id| GroupArchivedMessage.archive!(id, self) }
|
|
end
|
|
end
|
|
end
|
|
|
|
# Atomically creates the next post number
|
|
def self.next_post_number(topic_id, opts = {})
|
|
highest =
|
|
DB
|
|
.query_single(
|
|
"SELECT coalesce(max(post_number),0) AS max FROM posts WHERE topic_id = ?",
|
|
topic_id,
|
|
)
|
|
.first
|
|
.to_i
|
|
|
|
if opts[:whisper]
|
|
result = DB.query_single(<<~SQL, highest, topic_id)
|
|
UPDATE topics
|
|
SET highest_staff_post_number = ? + 1
|
|
WHERE id = ?
|
|
RETURNING highest_staff_post_number
|
|
SQL
|
|
|
|
result.first.to_i
|
|
else
|
|
reply_sql = opts[:reply] ? ", reply_count = reply_count + 1" : ""
|
|
posts_sql = opts[:post] ? ", posts_count = posts_count + 1" : ""
|
|
|
|
result = DB.query_single(<<~SQL, highest: highest, topic_id: topic_id)
|
|
UPDATE topics
|
|
SET highest_staff_post_number = :highest + 1,
|
|
highest_post_number = :highest + 1
|
|
#{reply_sql}
|
|
#{posts_sql}
|
|
WHERE id = :topic_id
|
|
RETURNING highest_post_number
|
|
SQL
|
|
|
|
result.first.to_i
|
|
end
|
|
end
|
|
|
|
def self.reset_all_highest!
|
|
DB.exec <<~SQL
|
|
WITH
|
|
X as (
|
|
SELECT topic_id,
|
|
COALESCE(MAX(post_number), 0) highest_post_number
|
|
FROM posts
|
|
WHERE deleted_at IS NULL
|
|
GROUP BY topic_id
|
|
),
|
|
Y as (
|
|
SELECT topic_id,
|
|
coalesce(MAX(post_number), 0) highest_post_number,
|
|
count(*) posts_count,
|
|
max(created_at) last_posted_at
|
|
FROM posts
|
|
WHERE deleted_at IS NULL AND post_type <> 4
|
|
GROUP BY topic_id
|
|
)
|
|
UPDATE topics
|
|
SET
|
|
highest_staff_post_number = X.highest_post_number,
|
|
highest_post_number = Y.highest_post_number,
|
|
last_posted_at = Y.last_posted_at,
|
|
posts_count = Y.posts_count
|
|
FROM X, Y
|
|
WHERE
|
|
topics.archetype <> 'private_message' AND
|
|
X.topic_id = topics.id AND
|
|
Y.topic_id = topics.id AND (
|
|
topics.highest_staff_post_number <> X.highest_post_number OR
|
|
topics.highest_post_number <> Y.highest_post_number OR
|
|
topics.last_posted_at <> Y.last_posted_at OR
|
|
topics.posts_count <> Y.posts_count
|
|
)
|
|
SQL
|
|
|
|
DB.exec <<~SQL
|
|
WITH
|
|
X as (
|
|
SELECT topic_id,
|
|
COALESCE(MAX(post_number), 0) highest_post_number
|
|
FROM posts
|
|
WHERE deleted_at IS NULL
|
|
GROUP BY topic_id
|
|
),
|
|
Y as (
|
|
SELECT topic_id,
|
|
coalesce(MAX(post_number), 0) highest_post_number,
|
|
count(*) posts_count,
|
|
max(created_at) last_posted_at
|
|
FROM posts
|
|
WHERE deleted_at IS NULL AND post_type <> 3 AND post_type <> 4
|
|
GROUP BY topic_id
|
|
)
|
|
UPDATE topics
|
|
SET
|
|
highest_staff_post_number = X.highest_post_number,
|
|
highest_post_number = Y.highest_post_number,
|
|
last_posted_at = Y.last_posted_at,
|
|
posts_count = Y.posts_count
|
|
FROM X, Y
|
|
WHERE
|
|
topics.archetype = 'private_message' AND
|
|
X.topic_id = topics.id AND
|
|
Y.topic_id = topics.id AND (
|
|
topics.highest_staff_post_number <> X.highest_post_number OR
|
|
topics.highest_post_number <> Y.highest_post_number OR
|
|
topics.last_posted_at <> Y.last_posted_at OR
|
|
topics.posts_count <> Y.posts_count
|
|
)
|
|
SQL
|
|
end
|
|
|
|
# If a post is deleted we have to update our highest post counters and last post information
|
|
def self.reset_highest(topic_id)
|
|
archetype = Topic.where(id: topic_id).pick(:archetype)
|
|
|
|
# ignore small_action replies for private messages
|
|
post_type =
|
|
archetype == Archetype.private_message ? " AND post_type <> #{Post.types[:small_action]}" : ""
|
|
|
|
result = DB.query_single(<<~SQL, topic_id: topic_id)
|
|
UPDATE topics
|
|
SET
|
|
highest_staff_post_number = (
|
|
SELECT COALESCE(MAX(post_number), 0) FROM posts
|
|
WHERE topic_id = :topic_id AND
|
|
deleted_at IS NULL
|
|
),
|
|
highest_post_number = (
|
|
SELECT COALESCE(MAX(post_number), 0) FROM posts
|
|
WHERE topic_id = :topic_id AND
|
|
deleted_at IS NULL AND
|
|
post_type <> 4
|
|
#{post_type}
|
|
),
|
|
posts_count = (
|
|
SELECT count(*) FROM posts
|
|
WHERE deleted_at IS NULL AND
|
|
topic_id = :topic_id AND
|
|
post_type <> 4
|
|
#{post_type}
|
|
),
|
|
last_posted_at = (
|
|
SELECT MAX(created_at) FROM posts
|
|
WHERE topic_id = :topic_id AND
|
|
deleted_at IS NULL AND
|
|
post_type <> 4
|
|
#{post_type}
|
|
),
|
|
last_post_user_id = COALESCE((
|
|
SELECT user_id FROM posts
|
|
WHERE topic_id = :topic_id AND
|
|
deleted_at IS NULL AND
|
|
post_type <> 4
|
|
#{post_type}
|
|
ORDER BY created_at desc
|
|
LIMIT 1
|
|
), last_post_user_id)
|
|
WHERE id = :topic_id
|
|
RETURNING highest_post_number
|
|
SQL
|
|
|
|
highest_post_number = result.first.to_i
|
|
|
|
# Update the forum topic user records
|
|
DB.exec(<<~SQL, highest: highest_post_number, topic_id: topic_id)
|
|
UPDATE topic_users
|
|
SET last_read_post_number = CASE
|
|
WHEN last_read_post_number > :highest THEN :highest
|
|
ELSE last_read_post_number
|
|
END
|
|
WHERE topic_id = :topic_id
|
|
SQL
|
|
end
|
|
|
|
cattr_accessor :update_featured_topics
|
|
|
|
def changed_to_category(new_category)
|
|
return true if new_category.blank? || Category.exists?(topic_id: id)
|
|
|
|
if new_category.id == SiteSetting.uncategorized_category_id &&
|
|
!SiteSetting.allow_uncategorized_topics
|
|
return false
|
|
end
|
|
|
|
Topic.transaction do
|
|
old_category = category
|
|
|
|
if self.category_id != new_category.id
|
|
self.update(category_id: new_category.id)
|
|
|
|
if old_category
|
|
Category.where(id: old_category.id).update_all("topic_count = topic_count - 1")
|
|
|
|
count =
|
|
if old_category.read_restricted && !new_category.read_restricted
|
|
1
|
|
elsif !old_category.read_restricted && new_category.read_restricted
|
|
-1
|
|
end
|
|
|
|
Tag.update_counters(self.tags, { public_topic_count: count }) if count
|
|
end
|
|
|
|
# when a topic changes category we may have to start watching it
|
|
# if we happen to have read state for it
|
|
CategoryUser.auto_watch(category_id: new_category.id, topic_id: self.id)
|
|
CategoryUser.auto_track(category_id: new_category.id, topic_id: self.id)
|
|
|
|
if !SiteSetting.disable_category_edit_notifications && (post = self.ordered_posts.first)
|
|
notified_user_ids = [post.user_id, post.last_editor_id].uniq
|
|
DB.after_commit do
|
|
Jobs.enqueue(
|
|
:notify_category_change,
|
|
post_id: post.id,
|
|
notified_user_ids: notified_user_ids,
|
|
)
|
|
end
|
|
end
|
|
|
|
# when a topic changes category we may need to make uploads
|
|
# linked to posts secure/not secure depending on whether the
|
|
# category is private. this is only done if the category
|
|
# has actually changed to avoid noise.
|
|
DB.after_commit { Jobs.enqueue(:update_topic_upload_security, topic_id: self.id) }
|
|
end
|
|
|
|
Category.where(id: new_category.id).update_all("topic_count = topic_count + 1")
|
|
|
|
if Topic.update_featured_topics != false
|
|
CategoryFeaturedTopic.feature_topics_for(old_category) unless @import_mode
|
|
unless @import_mode || old_category.try(:id) == new_category.id
|
|
CategoryFeaturedTopic.feature_topics_for(new_category)
|
|
end
|
|
end
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def add_small_action(user, action_code, who = nil, opts = {})
|
|
custom_fields = {}
|
|
custom_fields["action_code_who"] = who if who.present?
|
|
opts =
|
|
opts.merge(
|
|
post_type: Post.types[:small_action],
|
|
action_code: action_code,
|
|
custom_fields: custom_fields,
|
|
)
|
|
|
|
add_moderator_post(user, nil, opts)
|
|
end
|
|
|
|
def add_moderator_post(user, text, opts = nil)
|
|
opts ||= {}
|
|
new_post = nil
|
|
creator =
|
|
PostCreator.new(
|
|
user,
|
|
raw: text,
|
|
post_type: opts[:post_type] || Post.types[:moderator_action],
|
|
action_code: opts[:action_code],
|
|
no_bump: opts[:bump].blank?,
|
|
topic_id: self.id,
|
|
silent: opts[:silent],
|
|
skip_validations: true,
|
|
custom_fields: opts[:custom_fields],
|
|
import_mode: opts[:import_mode],
|
|
)
|
|
|
|
if (new_post = creator.create) && new_post.present?
|
|
increment!(:moderator_posts_count) if new_post.persisted?
|
|
# If we are moving posts, we want to insert the moderator post where the previous posts were
|
|
# in the stream, not at the end.
|
|
if opts[:post_number].present?
|
|
new_post.update!(post_number: opts[:post_number], sort_order: opts[:post_number])
|
|
end
|
|
|
|
# Grab any links that are present
|
|
TopicLink.extract_from(new_post)
|
|
QuotedPost.extract_from(new_post)
|
|
end
|
|
|
|
new_post
|
|
end
|
|
|
|
def change_category_to_id(category_id)
|
|
return false if private_message?
|
|
|
|
new_category_id = category_id.to_i
|
|
# if the category name is blank, reset the attribute
|
|
new_category_id = SiteSetting.uncategorized_category_id if new_category_id == 0
|
|
|
|
return true if self.category_id == new_category_id
|
|
|
|
cat = Category.find_by(id: new_category_id)
|
|
return false unless cat
|
|
|
|
reviewables.update_all(category_id: new_category_id)
|
|
|
|
changed_to_category(cat)
|
|
end
|
|
|
|
def remove_allowed_group(removed_by, name)
|
|
if group = Group.find_by(name: name)
|
|
group_user = topic_allowed_groups.find_by(group_id: group.id)
|
|
if group_user
|
|
group_user.destroy
|
|
allowed_groups.reload
|
|
add_small_action(removed_by, "removed_group", group.name)
|
|
return true
|
|
end
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
def remove_allowed_user(removed_by, username)
|
|
user = username.is_a?(User) ? username : User.find_by(username: username)
|
|
|
|
if user
|
|
topic_user = topic_allowed_users.find_by(user_id: user.id)
|
|
|
|
if topic_user
|
|
if user.id == removed_by&.id
|
|
add_small_action(removed_by, "user_left", user.username)
|
|
else
|
|
add_small_action(removed_by, "removed_user", user.username)
|
|
end
|
|
|
|
topic_user.destroy
|
|
return true
|
|
end
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
def reached_recipients_limit?
|
|
return false unless private_message?
|
|
topic_allowed_users.count + topic_allowed_groups.count >=
|
|
SiteSetting.max_allowed_message_recipients
|
|
end
|
|
|
|
def invite_group(user, group)
|
|
TopicAllowedGroup.create!(topic_id: self.id, group_id: group.id)
|
|
self.allowed_groups.reload
|
|
|
|
last_post =
|
|
self.posts.order("post_number desc").where("not hidden AND posts.deleted_at IS NULL").first
|
|
if last_post
|
|
Jobs.enqueue(:post_alert, post_id: last_post.id)
|
|
add_small_action(user, "invited_group", group.name)
|
|
Jobs.enqueue(:group_pm_alert, user_id: user.id, group_id: group.id, post_id: last_post.id)
|
|
end
|
|
|
|
# If the group invited includes the OP of the topic as one of is members,
|
|
# we cannot strip the topic_allowed_user record since it will be more
|
|
# complicated to recover the topic_allowed_user record for the OP if the
|
|
# group is removed.
|
|
allowed_user_where_clause = <<~SQL
|
|
users.id IN (
|
|
SELECT topic_allowed_users.user_id
|
|
FROM topic_allowed_users
|
|
INNER JOIN group_users ON group_users.user_id = topic_allowed_users.user_id
|
|
INNER JOIN topic_allowed_groups ON topic_allowed_groups.group_id = group_users.group_id
|
|
WHERE topic_allowed_groups.group_id = :group_id AND
|
|
topic_allowed_users.topic_id = :topic_id AND
|
|
topic_allowed_users.user_id != :op_user_id
|
|
)
|
|
SQL
|
|
User
|
|
.where(
|
|
[
|
|
allowed_user_where_clause,
|
|
{ group_id: group.id, topic_id: self.id, op_user_id: self.user_id },
|
|
],
|
|
)
|
|
.find_each { |allowed_user| remove_allowed_user(Discourse.system_user, allowed_user) }
|
|
|
|
true
|
|
end
|
|
|
|
def invite(invited_by, username_or_email, group_ids = nil, custom_message = nil)
|
|
guardian = Guardian.new(invited_by)
|
|
|
|
if target_user = User.find_by_username_or_email(username_or_email)
|
|
if topic_allowed_users.exists?(user_id: target_user.id)
|
|
raise UserExists.new(I18n.t("topic_invite.user_exists"))
|
|
end
|
|
|
|
comm_screener = UserCommScreener.new(acting_user: invited_by, target_user_ids: target_user.id)
|
|
if comm_screener.ignoring_or_muting_actor?(target_user.id)
|
|
raise NotAllowed.new(I18n.t("not_accepting_pms", username: target_user.username))
|
|
end
|
|
|
|
if TopicUser.where(
|
|
topic: self,
|
|
user: target_user,
|
|
notification_level: TopicUser.notification_levels[:muted],
|
|
).exists?
|
|
raise NotAllowed.new(I18n.t("topic_invite.muted_topic"))
|
|
end
|
|
|
|
if comm_screener.disallowing_pms_from_actor?(target_user.id)
|
|
raise NotAllowed.new(I18n.t("topic_invite.receiver_does_not_allow_pm"))
|
|
end
|
|
|
|
if UserCommScreener.new(
|
|
acting_user: target_user,
|
|
target_user_ids: invited_by.id,
|
|
).disallowing_pms_from_actor?(invited_by.id)
|
|
raise NotAllowed.new(I18n.t("topic_invite.sender_does_not_allow_pm"))
|
|
end
|
|
|
|
if private_message?
|
|
!!invite_to_private_message(invited_by, target_user, guardian)
|
|
else
|
|
!!invite_to_topic(invited_by, target_user, group_ids, guardian)
|
|
end
|
|
elsif username_or_email =~ /\A.+@.+\z/ && guardian.can_invite_via_email?(self)
|
|
!!Invite.generate(
|
|
invited_by,
|
|
email: username_or_email,
|
|
topic: self,
|
|
group_ids: group_ids,
|
|
custom_message: custom_message,
|
|
invite_to_topic: true,
|
|
)
|
|
end
|
|
end
|
|
|
|
def email_already_exists_for?(invite)
|
|
invite.email_already_exists && private_message?
|
|
end
|
|
|
|
def grant_permission_to_user(lower_email)
|
|
user = User.find_by_email(lower_email)
|
|
unless topic_allowed_users.exists?(user_id: user.id)
|
|
topic_allowed_users.create!(user_id: user.id)
|
|
end
|
|
end
|
|
|
|
def max_post_number
|
|
posts.with_deleted.maximum(:post_number).to_i
|
|
end
|
|
|
|
def move_posts(moved_by, post_ids, opts)
|
|
post_mover =
|
|
PostMover.new(
|
|
self,
|
|
moved_by,
|
|
post_ids,
|
|
move_to_pm: opts[:archetype].present? && opts[:archetype] == "private_message",
|
|
)
|
|
|
|
if opts[:destination_topic_id]
|
|
topic =
|
|
post_mover.to_topic(
|
|
opts[:destination_topic_id],
|
|
**opts.slice(:participants, :chronological_order),
|
|
)
|
|
|
|
DiscourseEvent.trigger(:topic_merged, post_mover.original_topic, post_mover.destination_topic)
|
|
|
|
topic
|
|
elsif opts[:title]
|
|
post_mover.to_new_topic(opts[:title], opts[:category_id], opts[:tags])
|
|
end
|
|
end
|
|
|
|
# Updates the denormalized statistics of a topic including featured posters. They shouldn't
|
|
# go out of sync unless you do something drastic live move posts from one topic to another.
|
|
# this recalculates everything.
|
|
def update_statistics
|
|
feature_topic_users
|
|
update_action_counts
|
|
Topic.reset_highest(id)
|
|
end
|
|
|
|
def update_action_counts
|
|
update_column(
|
|
:like_count,
|
|
Post.where.not(post_type: Post.types[:whisper]).where(topic_id: id).sum(:like_count),
|
|
)
|
|
end
|
|
|
|
def posters_summary(options = {}) # avatar lookup in options
|
|
@posters_summary ||= TopicPostersSummary.new(self, options).summary
|
|
end
|
|
|
|
def participants_summary(options = {})
|
|
@participants_summary ||= TopicParticipantsSummary.new(self, options).summary
|
|
end
|
|
|
|
def participant_groups_summary(options = {})
|
|
@participant_groups_summary ||= TopicParticipantGroupsSummary.new(self, options).summary
|
|
end
|
|
|
|
def make_banner!(user, bannered_until = nil)
|
|
if bannered_until
|
|
bannered_until =
|
|
begin
|
|
Time.parse(bannered_until)
|
|
rescue ArgumentError
|
|
raise Discourse::InvalidParameters.new(:bannered_until)
|
|
end
|
|
end
|
|
|
|
# only one banner at the same time
|
|
previous_banner = Topic.where(archetype: Archetype.banner).first
|
|
previous_banner.remove_banner!(user) if previous_banner.present?
|
|
|
|
UserProfile.where("dismissed_banner_key IS NOT NULL").update_all(dismissed_banner_key: nil)
|
|
|
|
self.archetype = Archetype.banner
|
|
self.bannered_until = bannered_until
|
|
self.add_small_action(user, "banner.enabled")
|
|
self.save
|
|
|
|
MessageBus.publish("/site/banner", banner)
|
|
|
|
Jobs.cancel_scheduled_job(:remove_banner, topic_id: self.id)
|
|
Jobs.enqueue_at(bannered_until, :remove_banner, topic_id: self.id) if bannered_until
|
|
end
|
|
|
|
def remove_banner!(user)
|
|
self.archetype = Archetype.default
|
|
self.bannered_until = nil
|
|
self.add_small_action(user, "banner.disabled")
|
|
self.save
|
|
|
|
MessageBus.publish("/site/banner", nil)
|
|
|
|
Jobs.cancel_scheduled_job(:remove_banner, topic_id: self.id)
|
|
end
|
|
|
|
def banner
|
|
post = self.ordered_posts.first
|
|
|
|
{ html: post.cooked, key: self.id, url: self.url }
|
|
end
|
|
|
|
cattr_accessor :slug_computed_callbacks
|
|
self.slug_computed_callbacks = []
|
|
|
|
def slug_for_topic(title)
|
|
return "" unless title.present?
|
|
slug = Slug.for(title)
|
|
|
|
# this is a hook for plugins that need to modify the generated slug
|
|
self.class.slug_computed_callbacks.each { |callback| slug = callback.call(self, slug, title) }
|
|
|
|
slug
|
|
end
|
|
|
|
# Even if the slug column in the database is null, topic.slug will return something:
|
|
def slug
|
|
unless slug = read_attribute(:slug)
|
|
return "" unless title.present?
|
|
slug = slug_for_topic(title)
|
|
if new_record?
|
|
write_attribute(:slug, slug)
|
|
else
|
|
update_column(:slug, slug)
|
|
end
|
|
end
|
|
|
|
slug
|
|
end
|
|
|
|
def self.find_by_slug(slug)
|
|
if SiteSetting.slug_generation_method != "encoded"
|
|
Topic.find_by(slug: slug.downcase)
|
|
else
|
|
encoded_slug = CGI.escape(slug)
|
|
Topic.find_by(slug: encoded_slug)
|
|
end
|
|
end
|
|
|
|
def title=(t)
|
|
slug = slug_for_topic(t.to_s)
|
|
write_attribute(:slug, slug)
|
|
write_attribute(:fancy_title, nil)
|
|
write_attribute(:title, t)
|
|
end
|
|
|
|
# NOTE: These are probably better off somewhere else.
|
|
# Having a model know about URLs seems a bit strange.
|
|
def last_post_url
|
|
"#{Discourse.base_path}/t/#{slug}/#{id}/#{posts_count}"
|
|
end
|
|
|
|
def self.url(id, slug, post_number = nil)
|
|
url = +"#{Discourse.base_url}/t/#{slug}/#{id}"
|
|
url << "/#{post_number}" if post_number.to_i > 1
|
|
url
|
|
end
|
|
|
|
def url(post_number = nil)
|
|
self.class.url id, slug, post_number
|
|
end
|
|
|
|
def self.relative_url(id, slug, post_number = nil)
|
|
url = +"#{Discourse.base_path}/t/"
|
|
url << "#{slug}/" if slug.present?
|
|
url << id.to_s
|
|
url << "/#{post_number}" if post_number.to_i > 1
|
|
url
|
|
end
|
|
|
|
def slugless_url(post_number = nil)
|
|
Topic.relative_url(id, nil, post_number)
|
|
end
|
|
|
|
def relative_url(post_number = nil)
|
|
Topic.relative_url(id, slug, post_number)
|
|
end
|
|
|
|
def clear_pin_for(user)
|
|
return unless user.present?
|
|
TopicUser.change(user.id, id, cleared_pinned_at: Time.now)
|
|
end
|
|
|
|
def re_pin_for(user)
|
|
return unless user.present?
|
|
TopicUser.change(user.id, id, cleared_pinned_at: nil)
|
|
end
|
|
|
|
def update_pinned(status, global = false, pinned_until = nil)
|
|
if pinned_until
|
|
pinned_until =
|
|
begin
|
|
Time.parse(pinned_until)
|
|
rescue ArgumentError
|
|
raise Discourse::InvalidParameters.new(:pinned_until)
|
|
end
|
|
end
|
|
|
|
update_columns(
|
|
pinned_at: status ? Time.zone.now : nil,
|
|
pinned_globally: global,
|
|
pinned_until: pinned_until,
|
|
)
|
|
|
|
Jobs.cancel_scheduled_job(:unpin_topic, topic_id: self.id)
|
|
Jobs.enqueue_at(pinned_until, :unpin_topic, topic_id: self.id) if pinned_until
|
|
end
|
|
|
|
def draft_key
|
|
"#{Draft::EXISTING_TOPIC}#{id}"
|
|
end
|
|
|
|
def notifier
|
|
@topic_notifier ||= TopicNotifier.new(self)
|
|
end
|
|
|
|
def muted?(user)
|
|
notifier.muted?(user.id) if user && user.id
|
|
end
|
|
|
|
def self.ensure_consistency!
|
|
# unpin topics that might have been missed
|
|
Topic.where("pinned_until < ?", Time.now).update_all(
|
|
pinned_at: nil,
|
|
pinned_globally: false,
|
|
pinned_until: nil,
|
|
)
|
|
Topic
|
|
.where("bannered_until < ?", Time.now)
|
|
.find_each { |topic| topic.remove_banner!(Discourse.system_user) }
|
|
end
|
|
|
|
def inherit_slow_mode_from_category
|
|
if self.category&.default_slow_mode_seconds
|
|
self.slow_mode_seconds = self.category&.default_slow_mode_seconds
|
|
end
|
|
end
|
|
|
|
def inherit_auto_close_from_category(timer_type: :close)
|
|
auto_close_hours = self.category&.auto_close_hours
|
|
|
|
if self.open? && !@ignore_category_auto_close && auto_close_hours.present? &&
|
|
public_topic_timer&.execute_at.blank?
|
|
based_on_last_post = self.category.auto_close_based_on_last_post
|
|
duration_minutes = based_on_last_post ? auto_close_hours * 60 : nil
|
|
|
|
# the timer time can be a timestamp or an integer based
|
|
# on the number of hours
|
|
auto_close_time = auto_close_hours
|
|
|
|
if !based_on_last_post
|
|
# set auto close to the original time it should have been
|
|
# when the topic was first created.
|
|
start_time = self.created_at || Time.zone.now
|
|
auto_close_time = start_time + auto_close_hours.hours
|
|
|
|
# if we have already passed the original close time then
|
|
# we should not recreate the auto-close timer for the topic
|
|
return if auto_close_time < Time.zone.now
|
|
|
|
# timestamp must be a string for set_or_create_timer
|
|
auto_close_time = auto_close_time.to_s
|
|
end
|
|
|
|
self.set_or_create_timer(
|
|
TopicTimer.types[timer_type],
|
|
auto_close_time,
|
|
by_user: Discourse.system_user,
|
|
based_on_last_post: based_on_last_post,
|
|
duration_minutes: duration_minutes,
|
|
)
|
|
end
|
|
end
|
|
|
|
def public_topic_timer
|
|
@public_topic_timer ||= topic_timers.find_by(public_type: true)
|
|
end
|
|
|
|
def slow_mode_topic_timer
|
|
@slow_mode_topic_timer ||= topic_timers.find_by(status_type: TopicTimer.types[:clear_slow_mode])
|
|
end
|
|
|
|
def delete_topic_timer(status_type, by_user: Discourse.system_user)
|
|
options = { status_type: status_type }
|
|
options.merge!(user: by_user) unless TopicTimer.public_types[status_type]
|
|
self.topic_timers.find_by(options)&.trash!(by_user)
|
|
@public_topic_timer = nil
|
|
nil
|
|
end
|
|
|
|
# Valid arguments for the time:
|
|
# * An integer, which is the number of hours from now to update the topic's status.
|
|
# * A timestamp, like "2013-11-25 13:00", when the topic's status should update.
|
|
# * A timestamp with timezone in JSON format. (e.g., "2013-11-26T21:00:00.000Z")
|
|
# * `nil` to delete the topic's status update.
|
|
# Options:
|
|
# * by_user: User who is setting the topic's status update.
|
|
# * based_on_last_post: True if time should be based on timestamp of the last post.
|
|
# * category_id: Category that the update will apply to.
|
|
# * duration_minutes: The duration of the timer in minutes, which is used if the timer is based
|
|
# on the last post or if the timer type is delete_replies.
|
|
# * silent: Affects whether the close topic timer status change will be silent or not.
|
|
def set_or_create_timer(
|
|
status_type,
|
|
time,
|
|
by_user: nil,
|
|
based_on_last_post: false,
|
|
category_id: SiteSetting.uncategorized_category_id,
|
|
duration_minutes: nil,
|
|
silent: nil
|
|
)
|
|
if time.blank? && duration_minutes.blank?
|
|
return delete_topic_timer(status_type, by_user: by_user)
|
|
end
|
|
|
|
duration_minutes = duration_minutes ? duration_minutes.to_i : 0
|
|
public_topic_timer = !!TopicTimer.public_types[status_type]
|
|
topic_timer_options = { topic: self, public_type: public_topic_timer }
|
|
topic_timer_options.merge!(user: by_user) unless public_topic_timer
|
|
topic_timer_options.merge!(silent: silent) if silent
|
|
topic_timer = TopicTimer.find_or_initialize_by(topic_timer_options)
|
|
topic_timer.status_type = status_type
|
|
|
|
time_now = Time.zone.now
|
|
topic_timer.based_on_last_post = !based_on_last_post.blank?
|
|
|
|
if status_type == TopicTimer.types[:publish_to_category]
|
|
topic_timer.category = Category.find_by(id: category_id)
|
|
end
|
|
|
|
if topic_timer.based_on_last_post
|
|
if duration_minutes > 0
|
|
last_post_created_at =
|
|
self.ordered_posts.last.present? ? self.ordered_posts.last.created_at : time_now
|
|
topic_timer.duration_minutes = duration_minutes
|
|
topic_timer.execute_at = last_post_created_at + duration_minutes.minutes
|
|
topic_timer.created_at = last_post_created_at
|
|
end
|
|
elsif topic_timer.status_type == TopicTimer.types[:delete_replies]
|
|
if duration_minutes > 0
|
|
first_reply_created_at =
|
|
(self.ordered_posts.where("post_number > 1").minimum(:created_at) || time_now)
|
|
topic_timer.duration_minutes = duration_minutes
|
|
topic_timer.execute_at = first_reply_created_at + duration_minutes.minutes
|
|
topic_timer.created_at = first_reply_created_at
|
|
end
|
|
else
|
|
utc = Time.find_zone("UTC")
|
|
is_float =
|
|
(
|
|
begin
|
|
Float(time)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
)
|
|
|
|
if is_float
|
|
num_hours = time.to_f
|
|
topic_timer.execute_at = num_hours.hours.from_now if num_hours > 0
|
|
else
|
|
timestamp = utc.parse(time)
|
|
raise Discourse::InvalidParameters unless timestamp && timestamp > utc.now
|
|
# a timestamp in client's time zone, like "2015-5-27 12:00"
|
|
topic_timer.execute_at = timestamp
|
|
end
|
|
end
|
|
|
|
if topic_timer.execute_at
|
|
if by_user&.staff? || by_user&.trust_level == TrustLevel[4]
|
|
topic_timer.user = by_user
|
|
else
|
|
topic_timer.user ||=
|
|
(
|
|
if self.user.staff? || self.user.trust_level == TrustLevel[4]
|
|
self.user
|
|
else
|
|
Discourse.system_user
|
|
end
|
|
)
|
|
end
|
|
|
|
if self.persisted?
|
|
# See TopicTimer.after_save for additional context; the topic
|
|
# status may be changed by saving.
|
|
topic_timer.save!
|
|
else
|
|
self.topic_timers << topic_timer
|
|
end
|
|
|
|
topic_timer
|
|
end
|
|
end
|
|
|
|
def read_restricted_category?
|
|
category && category.read_restricted
|
|
end
|
|
|
|
def category_allows_unlimited_owner_edits_on_first_post?
|
|
category && category.allow_unlimited_owner_edits_on_first_post?
|
|
end
|
|
|
|
def acting_user
|
|
@acting_user || user
|
|
end
|
|
|
|
def acting_user=(u)
|
|
@acting_user = u
|
|
end
|
|
|
|
def secure_group_ids
|
|
@secure_group_ids ||=
|
|
(self.category.secure_group_ids if self.category && self.category.read_restricted?)
|
|
end
|
|
|
|
def has_topic_embed?
|
|
TopicEmbed.where(topic_id: id).exists?
|
|
end
|
|
|
|
def expandable_first_post?
|
|
SiteSetting.embed_truncate? && has_topic_embed?
|
|
end
|
|
|
|
def message_archived?(user)
|
|
return false unless user && user.id
|
|
|
|
# tricky query but this checks to see if message is archived for ALL groups you belong to
|
|
# OR if you have it archived as a user explicitly
|
|
|
|
sql = <<~SQL
|
|
SELECT 1
|
|
WHERE
|
|
(
|
|
SELECT count(*) FROM topic_allowed_groups tg
|
|
JOIN group_archived_messages gm
|
|
ON gm.topic_id = tg.topic_id AND
|
|
gm.group_id = tg.group_id
|
|
WHERE tg.group_id IN (SELECT g.group_id FROM group_users g WHERE g.user_id = :user_id)
|
|
AND tg.topic_id = :topic_id
|
|
) =
|
|
(
|
|
SELECT case when count(*) = 0 then -1 else count(*) end FROM topic_allowed_groups tg
|
|
WHERE tg.group_id IN (SELECT g.group_id FROM group_users g WHERE g.user_id = :user_id)
|
|
AND tg.topic_id = :topic_id
|
|
)
|
|
|
|
UNION ALL
|
|
|
|
SELECT 1 FROM topic_allowed_users tu
|
|
JOIN user_archived_messages um ON um.user_id = tu.user_id AND um.topic_id = tu.topic_id
|
|
WHERE tu.user_id = :user_id AND tu.topic_id = :topic_id
|
|
SQL
|
|
|
|
DB.exec(sql, user_id: user.id, topic_id: id) > 0
|
|
end
|
|
|
|
TIME_TO_FIRST_RESPONSE_SQL ||= <<-SQL
|
|
SELECT AVG(t.hours)::float AS "hours", t.created_at AS "date"
|
|
FROM (
|
|
SELECT t.id, t.created_at::date AS created_at, EXTRACT(EPOCH FROM MIN(p.created_at) - t.created_at)::float / 3600.0 AS "hours"
|
|
FROM topics t
|
|
LEFT JOIN posts p ON p.topic_id = t.id
|
|
/*where*/
|
|
GROUP BY t.id
|
|
) t
|
|
GROUP BY t.created_at
|
|
ORDER BY t.created_at
|
|
SQL
|
|
|
|
TIME_TO_FIRST_RESPONSE_TOTAL_SQL ||= <<-SQL
|
|
SELECT AVG(t.hours)::float AS "hours"
|
|
FROM (
|
|
SELECT t.id, EXTRACT(EPOCH FROM MIN(p.created_at) - t.created_at)::float / 3600.0 AS "hours"
|
|
FROM topics t
|
|
LEFT JOIN posts p ON p.topic_id = t.id
|
|
/*where*/
|
|
GROUP BY t.id
|
|
) t
|
|
SQL
|
|
|
|
def self.time_to_first_response(sql, opts = nil)
|
|
opts ||= {}
|
|
builder = DB.build(sql)
|
|
builder.where("t.created_at >= :start_date", start_date: opts[:start_date]) if opts[:start_date]
|
|
builder.where("t.created_at < :end_date", end_date: opts[:end_date]) if opts[:end_date]
|
|
if opts[:category_id]
|
|
if opts[:include_subcategories]
|
|
builder.where("t.category_id IN (?)", Category.subcategory_ids(opts[:category_id]))
|
|
else
|
|
builder.where("t.category_id = ?", opts[:category_id])
|
|
end
|
|
end
|
|
builder.where("t.archetype <> '#{Archetype.private_message}'")
|
|
builder.where("t.deleted_at IS NULL")
|
|
builder.where("p.deleted_at IS NULL")
|
|
builder.where("p.post_number > 1")
|
|
builder.where("p.user_id != t.user_id")
|
|
builder.where("p.user_id in (:user_ids)", user_ids: opts[:user_ids]) if opts[:user_ids]
|
|
builder.where("p.post_type = :post_type", post_type: Post.types[:regular])
|
|
builder.where("EXTRACT(EPOCH FROM p.created_at - t.created_at) > 0")
|
|
builder.query_hash
|
|
end
|
|
|
|
def self.time_to_first_response_per_day(start_date, end_date, opts = {})
|
|
time_to_first_response(
|
|
TIME_TO_FIRST_RESPONSE_SQL,
|
|
opts.merge(start_date: start_date, end_date: end_date),
|
|
)
|
|
end
|
|
|
|
def self.time_to_first_response_total(opts = nil)
|
|
total = time_to_first_response(TIME_TO_FIRST_RESPONSE_TOTAL_SQL, opts)
|
|
total.first["hours"].to_f.round(2)
|
|
end
|
|
|
|
WITH_NO_RESPONSE_SQL ||= <<-SQL
|
|
SELECT COUNT(*) as count, tt.created_at AS "date"
|
|
FROM (
|
|
SELECT t.id, t.created_at::date AS created_at, MIN(p.post_number) first_reply
|
|
FROM topics t
|
|
LEFT JOIN posts p ON p.topic_id = t.id AND p.user_id != t.user_id AND p.deleted_at IS NULL AND p.post_type = #{Post.types[:regular]}
|
|
/*where*/
|
|
GROUP BY t.id
|
|
) tt
|
|
WHERE tt.first_reply IS NULL OR tt.first_reply < 2
|
|
GROUP BY tt.created_at
|
|
ORDER BY tt.created_at
|
|
SQL
|
|
|
|
def self.with_no_response_per_day(
|
|
start_date,
|
|
end_date,
|
|
category_id = nil,
|
|
include_subcategories = nil
|
|
)
|
|
builder = DB.build(WITH_NO_RESPONSE_SQL)
|
|
builder.where("t.created_at >= :start_date", start_date: start_date) if start_date
|
|
builder.where("t.created_at < :end_date", end_date: end_date) if end_date
|
|
if category_id
|
|
if include_subcategories
|
|
builder.where("t.category_id IN (?)", Category.subcategory_ids(category_id))
|
|
else
|
|
builder.where("t.category_id = ?", category_id)
|
|
end
|
|
end
|
|
builder.where("t.archetype <> '#{Archetype.private_message}'")
|
|
builder.where("t.deleted_at IS NULL")
|
|
builder.query_hash
|
|
end
|
|
|
|
WITH_NO_RESPONSE_TOTAL_SQL ||= <<-SQL
|
|
SELECT COUNT(*) as count
|
|
FROM (
|
|
SELECT t.id, MIN(p.post_number) first_reply
|
|
FROM topics t
|
|
LEFT JOIN posts p ON p.topic_id = t.id AND p.user_id != t.user_id AND p.deleted_at IS NULL AND p.post_type = #{Post.types[:regular]}
|
|
/*where*/
|
|
GROUP BY t.id
|
|
) tt
|
|
WHERE tt.first_reply IS NULL OR tt.first_reply < 2
|
|
SQL
|
|
|
|
def self.with_no_response_total(opts = {})
|
|
builder = DB.build(WITH_NO_RESPONSE_TOTAL_SQL)
|
|
if opts[:category_id]
|
|
if opts[:include_subcategories]
|
|
builder.where("t.category_id IN (?)", Category.subcategory_ids(opts[:category_id]))
|
|
else
|
|
builder.where("t.category_id = ?", opts[:category_id])
|
|
end
|
|
end
|
|
builder.where("t.archetype <> '#{Archetype.private_message}'")
|
|
builder.where("t.deleted_at IS NULL")
|
|
builder.query_single.first.to_i
|
|
end
|
|
|
|
def convert_to_public_topic(user, category_id: nil)
|
|
public_topic = TopicConverter.new(self, user).convert_to_public_topic(category_id)
|
|
Tag.update_counters(public_topic.tags, { public_topic_count: 1 }) if !category.read_restricted
|
|
add_small_action(user, "public_topic") if public_topic
|
|
public_topic
|
|
end
|
|
|
|
def convert_to_private_message(user)
|
|
read_restricted = category.read_restricted
|
|
private_topic = TopicConverter.new(self, user).convert_to_private_message
|
|
Tag.update_counters(private_topic.tags, { public_topic_count: -1 }) if !read_restricted
|
|
add_small_action(user, "private_topic") if private_topic
|
|
private_topic
|
|
end
|
|
|
|
def update_excerpt(excerpt)
|
|
update_column(:excerpt, excerpt)
|
|
ApplicationController.banner_json_cache.clear if archetype == "banner"
|
|
end
|
|
|
|
def pm_with_non_human_user?
|
|
sql = <<~SQL
|
|
SELECT 1 FROM topics
|
|
LEFT JOIN topic_allowed_groups ON topics.id = topic_allowed_groups.topic_id
|
|
WHERE topic_allowed_groups.topic_id IS NULL
|
|
AND topics.archetype = :private_message
|
|
AND topics.id = :topic_id
|
|
AND (
|
|
SELECT COUNT(*) FROM topic_allowed_users
|
|
WHERE topic_allowed_users.topic_id = :topic_id
|
|
AND topic_allowed_users.user_id > 0
|
|
) = 1
|
|
SQL
|
|
|
|
result = DB.exec(sql, private_message: Archetype.private_message, topic_id: self.id)
|
|
result != 0
|
|
end
|
|
|
|
def featured_link_root_domain
|
|
MiniSuffix.domain(UrlHelper.encode_and_parse(self.featured_link).hostname)
|
|
end
|
|
|
|
def self.private_message_topics_count_per_day(start_date, end_date, topic_subtype)
|
|
private_messages
|
|
.with_subtype(topic_subtype)
|
|
.where("topics.created_at >= ? AND topics.created_at <= ?", start_date, end_date)
|
|
.group("date(topics.created_at)")
|
|
.order("date(topics.created_at)")
|
|
.count
|
|
end
|
|
|
|
def is_category_topic?
|
|
@is_category_topic ||= Category.exists?(topic_id: self.id.to_i)
|
|
end
|
|
|
|
def reset_bumped_at
|
|
post =
|
|
ordered_posts.where(
|
|
user_deleted: false,
|
|
hidden: false,
|
|
post_type: Post.types[:regular],
|
|
).last || first_post
|
|
|
|
self.bumped_at = post.created_at
|
|
self.save(validate: false)
|
|
end
|
|
|
|
def auto_close_threshold_reached?
|
|
return if user&.staff?
|
|
|
|
scores =
|
|
ReviewableScore
|
|
.pending
|
|
.joins(:reviewable)
|
|
.where("reviewable_scores.score >= ?", Reviewable.min_score_for_priority)
|
|
.where("reviewables.topic_id = ?", self.id)
|
|
.pluck(
|
|
"COUNT(DISTINCT reviewable_scores.user_id), COALESCE(SUM(reviewable_scores.score), 0.0)",
|
|
)
|
|
.first
|
|
|
|
scores[0] >= SiteSetting.num_flaggers_to_close_topic &&
|
|
scores[1] >= Reviewable.score_to_auto_close_topic
|
|
end
|
|
|
|
def update_category_topic_count_by(num)
|
|
if category_id.present?
|
|
Category
|
|
.where("id = ?", category_id)
|
|
.where("topic_id != ? OR topic_id IS NULL", self.id)
|
|
.update_all("topic_count = topic_count + #{num.to_i}")
|
|
end
|
|
end
|
|
|
|
def access_topic_via_group
|
|
Group
|
|
.joins(:category_groups)
|
|
.where("category_groups.category_id = ?", self.category_id)
|
|
.where("groups.public_admission OR groups.allow_membership_requests")
|
|
.order(:allow_membership_requests)
|
|
.first
|
|
end
|
|
|
|
def incoming_email_addresses(group: nil, received_before: Time.zone.now)
|
|
email_addresses = Set.new
|
|
|
|
self
|
|
.incoming_email
|
|
.where("created_at <= ?", received_before)
|
|
.each do |incoming_email|
|
|
to_addresses = incoming_email.to_addresses_split
|
|
cc_addresses = incoming_email.cc_addresses_split
|
|
combined_addresses = [to_addresses, cc_addresses].flatten
|
|
|
|
# We only care about the emails addressed to the group or CC'd to the
|
|
# group if the group is present. If combined addresses is empty we do
|
|
# not need to do this check, and instead can proceed on to adding the
|
|
# from address.
|
|
#
|
|
# Will not include test1@gmail.com if the only IncomingEmail
|
|
# is:
|
|
#
|
|
# from: test1@gmail.com
|
|
# to: test+support@discoursemail.com
|
|
#
|
|
# Because we don't care about the from addresses and also the to address
|
|
# is not the email_username, which will be something like test1@gmail.com.
|
|
if group.present? && combined_addresses.any?
|
|
next if combined_addresses.none? { |address| address =~ group.email_username_regex }
|
|
end
|
|
|
|
email_addresses.add(incoming_email.from_address)
|
|
email_addresses.merge(combined_addresses)
|
|
end
|
|
|
|
email_addresses.subtract([nil, ""])
|
|
email_addresses.delete(group.email_username) if group.present?
|
|
|
|
email_addresses.to_a
|
|
end
|
|
|
|
def create_invite_notification!(target_user, notification_type, invited_by, post_number: 1)
|
|
if UserCommScreener.new(
|
|
acting_user: invited_by,
|
|
target_user_ids: target_user.id,
|
|
).ignoring_or_muting_actor?(target_user.id)
|
|
raise NotAllowed.new(I18n.t("not_accepting_pms", username: target_user.username))
|
|
end
|
|
|
|
target_user.notifications.create!(
|
|
notification_type: notification_type,
|
|
topic_id: self.id,
|
|
post_number: post_number,
|
|
data: {
|
|
topic_title: self.title,
|
|
display_username: invited_by.username,
|
|
original_user_id: user.id,
|
|
original_username: user.username,
|
|
}.to_json,
|
|
)
|
|
end
|
|
|
|
def rate_limit_topic_invitation(invited_by)
|
|
RateLimiter.new(
|
|
invited_by,
|
|
"topic-invitations-per-day",
|
|
SiteSetting.max_topic_invitations_per_day,
|
|
1.day.to_i,
|
|
).performed!
|
|
|
|
RateLimiter.new(
|
|
invited_by,
|
|
"topic-invitations-per-minute",
|
|
SiteSetting.max_topic_invitations_per_minute,
|
|
1.day.to_i,
|
|
).performed!
|
|
end
|
|
|
|
def cannot_permanently_delete_reason(user)
|
|
all_posts_count =
|
|
Post
|
|
.with_deleted
|
|
.where(topic_id: self.id)
|
|
.where(
|
|
post_type: [Post.types[:regular], Post.types[:moderator_action], Post.types[:whisper]],
|
|
)
|
|
.count
|
|
|
|
if posts_count > 0 || all_posts_count > 1
|
|
I18n.t("post.cannot_permanently_delete.many_posts")
|
|
elsif self.deleted_by_id == user&.id && self.deleted_at >= Post::PERMANENT_DELETE_TIMER.ago
|
|
time_left =
|
|
RateLimiter.time_left(
|
|
Post::PERMANENT_DELETE_TIMER.to_i - Time.zone.now.to_i + self.deleted_at.to_i,
|
|
)
|
|
I18n.t("post.cannot_permanently_delete.wait_or_different_admin", time_left: time_left)
|
|
end
|
|
end
|
|
|
|
def first_smtp_enabled_group
|
|
self.allowed_groups.where(smtp_enabled: true).first
|
|
end
|
|
|
|
def secure_audience_publish_messages
|
|
target_audience = {}
|
|
|
|
if private_message?
|
|
target_audience[:user_ids] = User.human_users.where("admin OR moderator").pluck(:id)
|
|
target_audience[:user_ids] |= allowed_users.pluck(:id)
|
|
target_audience[:user_ids] |= allowed_group_users.pluck(:id)
|
|
else
|
|
target_audience[:group_ids] = secure_group_ids
|
|
end
|
|
|
|
target_audience
|
|
end
|
|
|
|
def self.publish_stats_to_clients!(topic_id, type, opts = {})
|
|
topic = Topic.find_by(id: topic_id)
|
|
return unless topic.present?
|
|
|
|
case type
|
|
when :liked, :unliked
|
|
stats = { like_count: topic.like_count }
|
|
when :created, :destroyed, :deleted, :recovered
|
|
stats = {
|
|
posts_count: topic.posts_count,
|
|
last_posted_at: topic.last_posted_at.as_json,
|
|
last_poster: BasicUserSerializer.new(topic.last_poster, root: false).as_json,
|
|
}
|
|
else
|
|
stats = nil
|
|
end
|
|
|
|
if stats
|
|
secure_audience = topic.secure_audience_publish_messages
|
|
|
|
if secure_audience[:user_ids] != [] && secure_audience[:group_ids] != []
|
|
message = stats.merge({ id: topic_id, updated_at: Time.now, type: :stats })
|
|
MessageBus.publish("/topic/#{topic_id}", message, opts.merge(secure_audience))
|
|
end
|
|
end
|
|
end
|
|
|
|
def group_pm?
|
|
private_message? && all_allowed_users.count > 2
|
|
end
|
|
|
|
def visible_tags(guardian)
|
|
tags.reject { |tag| guardian.hidden_tag_names.include?(tag[:name]) }
|
|
end
|
|
|
|
def self.editable_custom_fields(guardian)
|
|
fields = []
|
|
fields.push(*DiscoursePluginRegistry.public_editable_topic_custom_fields)
|
|
fields.push(*DiscoursePluginRegistry.staff_editable_topic_custom_fields) if guardian.is_staff?
|
|
fields
|
|
end
|
|
|
|
private
|
|
|
|
def invite_to_private_message(invited_by, target_user, guardian)
|
|
if !guardian.can_send_private_message?(target_user)
|
|
raise UserExists.new(I18n.t("activerecord.errors.models.topic.attributes.base.cant_send_pm"))
|
|
end
|
|
|
|
rate_limit_topic_invitation(invited_by)
|
|
|
|
Topic.transaction do
|
|
unless topic_allowed_users.exists?(user_id: target_user.id)
|
|
topic_allowed_users.create!(user_id: target_user.id)
|
|
end
|
|
|
|
user_in_allowed_group = (user.group_ids & topic_allowed_groups.map(&:group_id)).present?
|
|
add_small_action(invited_by, "invited_user", target_user.username) if !user_in_allowed_group
|
|
|
|
create_invite_notification!(
|
|
target_user,
|
|
Notification.types[:invited_to_private_message],
|
|
invited_by,
|
|
)
|
|
end
|
|
end
|
|
|
|
def invite_to_topic(invited_by, target_user, group_ids, guardian)
|
|
Topic.transaction do
|
|
rate_limit_topic_invitation(invited_by)
|
|
|
|
if group_ids.present?
|
|
(
|
|
self.category.groups.where(id: group_ids).where(automatic: false) -
|
|
target_user.groups.where(automatic: false)
|
|
).each do |group|
|
|
if guardian.can_edit_group?(group)
|
|
group.add(target_user)
|
|
|
|
GroupActionLogger.new(invited_by, group).log_add_user_to_group(target_user)
|
|
end
|
|
end
|
|
end
|
|
|
|
if Guardian.new(target_user).can_see_topic?(self)
|
|
create_invite_notification!(target_user, Notification.types[:invited_to_topic], invited_by)
|
|
end
|
|
end
|
|
end
|
|
|
|
def limit_first_day_topics_per_day
|
|
apply_per_day_rate_limit_for("first-day-topics", :max_topics_in_first_day)
|
|
end
|
|
|
|
def apply_per_day_rate_limit_for(key, method_name)
|
|
RateLimiter.new(user, "#{key}-per-day", SiteSetting.get(method_name), 1.day.to_i)
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: topics
|
|
#
|
|
# id :integer not null, primary key
|
|
# title :string not null
|
|
# last_posted_at :datetime
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# views :integer default(0), not null
|
|
# posts_count :integer default(0), not null
|
|
# user_id :integer
|
|
# last_post_user_id :integer not null
|
|
# reply_count :integer default(0), not null
|
|
# featured_user1_id :integer
|
|
# featured_user2_id :integer
|
|
# featured_user3_id :integer
|
|
# deleted_at :datetime
|
|
# highest_post_number :integer default(0), not null
|
|
# like_count :integer default(0), not null
|
|
# incoming_link_count :integer default(0), not null
|
|
# category_id :integer
|
|
# visible :boolean default(TRUE), not null
|
|
# moderator_posts_count :integer default(0), not null
|
|
# closed :boolean default(FALSE), not null
|
|
# archived :boolean default(FALSE), not null
|
|
# bumped_at :datetime not null
|
|
# has_summary :boolean default(FALSE), not null
|
|
# archetype :string default("regular"), not null
|
|
# featured_user4_id :integer
|
|
# notify_moderators_count :integer default(0), not null
|
|
# spam_count :integer default(0), not null
|
|
# pinned_at :datetime
|
|
# score :float
|
|
# percent_rank :float default(1.0), not null
|
|
# subtype :string
|
|
# slug :string
|
|
# deleted_by_id :integer
|
|
# participant_count :integer default(1)
|
|
# word_count :integer
|
|
# excerpt :string
|
|
# pinned_globally :boolean default(FALSE), not null
|
|
# pinned_until :datetime
|
|
# fancy_title :string
|
|
# highest_staff_post_number :integer default(0), not null
|
|
# featured_link :string
|
|
# reviewable_score :float default(0.0), not null
|
|
# image_upload_id :bigint
|
|
# slow_mode_seconds :integer default(0), not null
|
|
# bannered_until :datetime
|
|
# external_id :string
|
|
#
|
|
# Indexes
|
|
#
|
|
# idx_topics_front_page (deleted_at,visible,archetype,category_id,id)
|
|
# idx_topics_user_id_deleted_at (user_id) WHERE (deleted_at IS NULL)
|
|
# idxtopicslug (slug) WHERE ((deleted_at IS NULL) AND (slug IS NOT NULL))
|
|
# index_topics_on_bannered_until (bannered_until) WHERE (bannered_until IS NOT NULL)
|
|
# index_topics_on_bumped_at_public (bumped_at) WHERE ((deleted_at IS NULL) AND ((archetype)::text <> 'private_message'::text))
|
|
# index_topics_on_created_at_and_visible (created_at,visible) WHERE ((deleted_at IS NULL) AND ((archetype)::text <> 'private_message'::text))
|
|
# index_topics_on_external_id (external_id) UNIQUE WHERE (external_id IS NOT NULL)
|
|
# index_topics_on_id_and_deleted_at (id,deleted_at)
|
|
# index_topics_on_id_filtered_banner (id) UNIQUE WHERE (((archetype)::text = 'banner'::text) AND (deleted_at IS NULL))
|
|
# index_topics_on_image_upload_id (image_upload_id)
|
|
# index_topics_on_lower_title (lower((title)::text))
|
|
# index_topics_on_pinned_at (pinned_at) WHERE (pinned_at IS NOT NULL)
|
|
# index_topics_on_pinned_globally (pinned_globally) WHERE pinned_globally
|
|
# index_topics_on_pinned_until (pinned_until) WHERE (pinned_until IS NOT NULL)
|
|
# index_topics_on_timestamps_private (bumped_at,created_at,updated_at) WHERE ((deleted_at IS NULL) AND ((archetype)::text = 'private_message'::text))
|
|
# index_topics_on_updated_at_public (updated_at,visible,highest_staff_post_number,highest_post_number,category_id,created_at,id) WHERE (((archetype)::text <> 'private_message'::text) AND (deleted_at IS NULL))
|
|
#
|