discourse/app/models/post.rb

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1362 lines
40 KiB
Ruby
Raw Normal View History

# frozen_string_literal: true
2013-02-06 03:16:51 +08:00
require "archetype"
require "digest/sha1"
class Post < ActiveRecord::Base
include RateLimiter::OnCreateRecord
include Trashable
include Searchable
include HasCustomFields
include LimitedEdit
2013-02-06 03:16:51 +08:00
self.ignored_columns = [
"avg_time", # TODO(2021-01-04): remove
"image_url", # TODO(2021-06-01): remove
]
cattr_accessor :plugin_permitted_create_params, :plugin_permitted_update_params
self.plugin_permitted_create_params = {}
self.plugin_permitted_update_params = {}
2014-05-30 12:45:39 +08:00
# increase this number to force a system wide post rebake
# Recreate `index_for_rebake_old` when the number is increased
# Version 1, was the initial version
# Version 2 15-12-2017, introduces CommonMark and a huge number of onebox fixes
BAKED_VERSION = 2
# Time between the delete and permanent delete of a post
PERMANENT_DELETE_TIMER = 5.minutes
2013-02-07 23:45:24 +08:00
rate_limit
rate_limit :limit_posts_per_day
2013-02-19 14:57:14 +08:00
2013-02-06 03:16:51 +08:00
belongs_to :user
belongs_to :topic
belongs_to :reply_to_user, class_name: "User"
2013-02-06 03:16:51 +08:00
has_many :post_replies
has_many :replies, through: :post_replies
has_many :post_actions, dependent: :destroy
has_many :topic_links
has_many :group_mentions, dependent: :destroy
2013-02-06 03:16:51 +08:00
has_many :upload_references, as: :target, dependent: :destroy
has_many :uploads, through: :upload_references
has_one :post_stat
has_many :bookmarks, as: :bookmarkable
has_one :incoming_email
has_many :post_details
2013-12-12 10:41:34 +08:00
has_many :post_revisions
has_many :revisions, -> { order(:number) }, foreign_key: :post_id, class_name: "PostRevision"
2013-12-12 10:41:34 +08:00
has_many :user_actions, foreign_key: :target_post_id
FEATURE: Include optimized thumbnails for topics (#9215) This introduces new APIs for obtaining optimized thumbnails for topics. There are a few building blocks required for this: - Introduces new `image_upload_id` columns on the `posts` and `topics` table. This replaces the old `image_url` column, which means that thumbnails are now restricted to uploads. Hotlinked thumbnails are no longer possible. In normal use (with pull_hotlinked_images enabled), this has no noticeable impact - A migration attempts to match existing urls to upload records. If a match cannot be found then the posts will be queued for rebake - Optimized thumbnails are generated during post_process_cooked. If thumbnails are missing when serializing a topic list, then a sidekiq job is queued - Topic lists and topics now include a `thumbnails` key, which includes all the available images: ``` "thumbnails": [ { "max_width": null, "max_height": null, "url": "//example.com/original-image.png", "width": 1380, "height": 1840 }, { "max_width": 1024, "max_height": 1024, "url": "//example.com/optimized-image.png", "width": 768, "height": 1024 } ] ``` - Themes can request additional thumbnail sizes by using a modifier in their `about.json` file: ``` "modifiers": { "topic_thumbnail_sizes": [ [200, 200], [800, 800] ], ... ``` Remember that these are generated asynchronously, so your theme should include logic to fallback to other available thumbnails if your requested size has not yet been generated - Two new raw plugin outlets are introduced, to improve the customisability of the topic list. `topic-list-before-columns` and `topic-list-before-link`
2020-05-05 16:07:50 +08:00
belongs_to :image_upload, class_name: "Upload"
has_many :post_hotlinked_media, dependent: :destroy, class_name: "PostHotlinkedMedia"
has_many :reviewables, as: :target, dependent: :destroy
validates_with PostValidator, unless: :skip_validation
validates :edit_reason, length: { maximum: 1000 }
2013-02-06 03:16:51 +08:00
after_commit :index_search
# We can pass several creating options to a post via attributes
attr_accessor :image_sizes,
:quoted_post_numbers,
:no_bump,
:invalidate_oneboxes,
:cooking_options,
:skip_unique_check,
:skip_validation
MISSING_UPLOADS ||= "missing uploads"
MISSING_UPLOADS_IGNORED ||= "missing uploads ignored"
NOTICE ||= "notice"
SHORT_POST_CHARS ||= 1200
2013-02-06 03:16:51 +08:00
register_custom_field_type(MISSING_UPLOADS, :json)
register_custom_field_type(MISSING_UPLOADS_IGNORED, :boolean)
register_custom_field_type(NOTICE, :json)
scope :private_posts_for_user,
->(user) do
where(
"topics.id IN (#{Topic::PRIVATE_MESSAGES_SQL_USER})
OR topics.id IN (#{Topic::PRIVATE_MESSAGES_SQL_GROUP})",
user_id: user.id,
)
end
scope :by_newest, -> { order("created_at DESC, id DESC") }
scope :by_post_number, -> { order("post_number ASC") }
scope :with_user, -> { includes(:user) }
scope :created_since, ->(time_ago) { where("posts.created_at > ?", time_ago) }
scope :public_posts,
-> { joins(:topic).where("topics.archetype <> ?", Archetype.private_message) }
scope :private_posts,
-> { joins(:topic).where("topics.archetype = ?", Archetype.private_message) }
scope :with_topic_subtype, ->(subtype) { joins(:topic).where("topics.subtype = ?", subtype) }
2014-06-27 01:48:07 +08:00
scope :visible, -> { joins(:topic).where("topics.visible = true").where(hidden: false) }
scope :secured,
->(guardian) { where("posts.post_type IN (?)", Topic.visible_post_types(guardian&.user)) }
scope :for_mailing_list,
->(user, since) do
q =
created_since(since).joins(
"INNER JOIN (#{Topic.for_digest(user, Time.at(0)).select(:id).to_sql}) AS digest_topics ON digest_topics.id = posts.topic_id",
) # we want all topics with new content, regardless when they were created
.order("posts.created_at ASC")
q = q.where.not(post_type: Post.types[:whisper]) unless user.staff?
q
end
scope :raw_match,
->(pattern, type = "string") do
type = type&.downcase
case type
when "string"
where("raw ILIKE ?", "%#{pattern}%")
when "regex"
where("raw ~* ?", "(?n)#{pattern}")
end
end
scope :have_uploads,
-> do
where(
"
(
posts.cooked LIKE '%<a %' OR
posts.cooked LIKE '%<img %' OR
posts.cooked LIKE '%<video %'
) AND (
posts.cooked LIKE ? OR
posts.cooked LIKE '%/original/%' OR
posts.cooked LIKE '%/optimized/%' OR
posts.cooked LIKE '%data-orig-src=%' OR
posts.cooked LIKE '%/uploads/short-url/%'
)",
"%/uploads/#{RailsMultisite::ConnectionManagement.current_db}/%",
)
end
delegate :username, to: :user
def self.hidden_reasons
@hidden_reasons ||=
Enum.new(
flag_threshold_reached: 1,
flag_threshold_reached_again: 2,
new_user_spam_threshold_reached: 3,
flagged_by_tl3_user: 4,
email_spam_header_found: 5,
flagged_by_tl4_user: 6,
email_authentication_result_header: 7,
imported_as_unlisted: 8,
)
end
2013-03-19 04:03:46 +08:00
def self.types
@types ||= Enum.new(regular: 1, moderator_action: 2, small_action: 3, whisper: 4)
2013-03-19 04:03:46 +08:00
end
def self.cook_methods
@cook_methods ||= Enum.new(regular: 1, raw_html: 2, email: 3)
end
def self.notices
@notices ||= Enum.new(custom: "custom", new_user: "new_user", returning_user: "returning_user")
end
def self.find_by_detail(key, value)
includes(:post_details).find_by(post_details: { key: key, value: value })
end
def self.find_by_number(topic_id, post_number)
find_by(topic_id: topic_id, post_number: post_number)
end
def whisper?
post_type == Post.types[:whisper]
end
def add_detail(key, value, extra = nil)
post_details.build(key: key, value: value, extra: extra)
end
def limit_posts_per_day
if user && user.new_user_posting_on_first_day? && post_number && post_number > 1
RateLimiter.new(
user,
"first-day-replies-per-day",
SiteSetting.max_replies_in_first_day,
1.day.to_i,
)
end
end
def readers_count
read_count = reads - 1 # Excludes poster
read_count < 0 ? 0 : read_count
end
def publish_change_to_clients!(type, opts = {})
# special failsafe for posts missing topics consistency checks should fix,
# but message is safe to skip
2015-09-11 04:01:23 +08:00
return unless topic
skip_topic_stats = opts.delete(:skip_topic_stats)
message = {
id: id,
post_number: post_number,
updated_at: Time.now,
user_id: user_id,
last_editor_id: last_editor_id,
type: type,
version: version,
}.merge(opts)
publish_message!("/topic/#{topic_id}", message)
Topic.publish_stats_to_clients!(topic.id, type) unless skip_topic_stats
end
def publish_message!(channel, message, opts = {})
return unless topic
if Topic.visible_post_types.include?(post_type)
opts.merge!(topic.secure_audience_publish_messages)
else
opts[:user_ids] = User.human_users.where("admin OR moderator OR id = ?", user_id).pluck(:id)
2015-09-11 04:01:23 +08:00
end
MessageBus.publish(channel, message, opts) if opts[:user_ids] != [] && opts[:group_ids] != []
end
2013-07-10 03:20:18 +08:00
def trash!(trashed_by = nil)
self.topic_links.each(&:destroy)
self.save_custom_fields if self.custom_fields.delete(Post::NOTICE)
2013-07-10 03:20:18 +08:00
super(trashed_by)
end
def recover!
super
recover_public_post_actions
TopicLink.extract_from(self)
QuotedPost.extract_from(self)
topic.category.update_latest if topic && topic.category_id && topic.category
end
2013-03-22 18:18:48 +08:00
# The key we use in redis to ensure unique posts
2013-02-06 03:16:51 +08:00
def unique_post_key
"unique#{topic&.private_message? ? "-pm" : ""}-post-#{user_id}:#{raw_hash}"
2013-02-06 03:16:51 +08:00
end
def store_unique_post_key
if SiteSetting.unique_posts_mins > 0
Discourse.redis.setex(unique_post_key, SiteSetting.unique_posts_mins.minutes.to_i, id)
end
end
def matches_recent_post?
post_id = Discourse.redis.get(unique_post_key)
post_id != (nil) && post_id.to_i != (id)
end
2013-02-06 03:16:51 +08:00
def raw_hash
return if raw.blank?
Digest::SHA1.hexdigest(raw)
2013-02-06 03:16:51 +08:00
end
def self.allowed_image_classes
@allowed_image_classes ||= %w[avatar favicon thumbnail emoji ytp-thumbnail-image]
end
def post_analyzer
@post_analyzers ||= {}
@post_analyzers[raw_hash] ||= PostAnalyzer.new(raw, topic_id)
end
%w[
raw_mentions
linked_hosts
embedded_media_count
attachment_count
link_count
raw_links
has_oneboxes?
2019-05-07 09:27:05 +08:00
].each { |attr| define_method(attr) { post_analyzer.public_send(attr) } }
def add_nofollow?
return false if user&.staff?
2016-08-16 00:57:58 +08:00
user.blank? || SiteSetting.tl3_links_no_follow? || !user.has_trust_level?(TrustLevel[3])
end
def omit_nofollow?
2016-08-16 00:57:58 +08:00
!add_nofollow?
end
def cook(raw, opts = {})
# For some posts, for example those imported via RSS, we support raw HTML. In that
# case we can skip the rendering pipeline.
return raw if cook_method == Post.cook_methods[:raw_html]
options = opts.dup
options[:cook_method] = cook_method
FEATURE: Generic hashtag autocomplete lookup and markdown cooking (#18937) This commit fleshes out and adds functionality for the new `#hashtag` search and lookup system, still hidden behind the `enable_experimental_hashtag_autocomplete` feature flag. **Serverside** We have two plugin API registration methods that are used to define data sources (`register_hashtag_data_source`) and hashtag result type priorities depending on the context (`register_hashtag_type_in_context`). Reading the comments in plugin.rb should make it clear what these are doing. Reading the `HashtagAutocompleteService` in full will likely help a lot as well. Each data source is responsible for providing its own **lookup** and **search** method that returns hashtag results based on the arguments provided. For example, the category hashtag data source has to take into account parent categories and how they relate, and each data source has to define their own icon to use for the hashtag, and so on. The `Site` serializer has two new attributes that source data from `HashtagAutocompleteService`. There is `hashtag_icons` that is just a simple array of all the different icons that can be used for allowlisting in our markdown pipeline, and there is `hashtag_context_configurations` that is used to store the type priority orders for each registered context. When sending emails, we cannot render the SVG icons for hashtags, so we need to change the HTML hashtags to the normal `#hashtag` text. **Markdown** The `hashtag-autocomplete.js` file is where I have added the new `hashtag-autocomplete` markdown rule, and like all of our rules this is used to cook the raw text on both the clientside and on the serverside using MiniRacer. Only on the server side do we actually reach out to the database with the `hashtagLookup` function, on the clientside we just render a plainer version of the hashtag HTML. Only in the composer preview do we do further lookups based on this. This rule is the first one (that I can find) that uses the `currentUser` based on a passed in `user_id` for guardian checks in markdown rendering code. This is the `last_editor_id` for both the post and chat message. In some cases we need to cook without a user present, so the `Discourse.system_user` is used in this case. **Chat Channels** This also contains the changes required for chat so that chat channels can be used as a data source for hashtag searches and lookups. This data source will only be used when `enable_experimental_hashtag_autocomplete` is `true`, so we don't have to worry about channel results suddenly turning up. ------ **Known Rough Edges** - Onebox excerpts will not render the icon svg/use tags, I plan to address that in a follow up PR - Selecting a hashtag + pressing the Quote button will result in weird behaviour, I plan to address that in a follow up PR - Mixed hashtag contexts for hashtags without a type suffix will not work correctly, e.g. #ux which is both a category and a channel slug will resolve to a category when used inside a post or within a [chat] transcript in that post. Users can get around this manually by adding the correct suffix, for example ::channel. We may get to this at some point in future - Icons will not show for the hashtags in emails since SVG support is so terrible in email (this is not likely to be resolved, but still noting for posterity) - Additional refinements and review fixes wil
2022-11-21 06:37:06 +08:00
# A rule in our Markdown pipeline may have Guardian checks that require a
# user to be present. The last editing user of the post will be more
# generally up to date than the creating user. For example, we use
# this when cooking #hashtags to determine whether we should render
# the found hashtag based on whether the user can access the category it
# is referencing.
options[:user_id] = self.last_editor_id
options[:omit_nofollow] = true if omit_nofollow?
if self.with_secure_uploads?
each_upload_url do |url|
uri = URI.parse(url)
if FileHelper.is_supported_media?(File.basename(uri.path))
raw =
raw.sub(
url,
Rails.application.routes.url_for(
controller: "uploads",
action: "show_secure",
path: uri.path[1..-1],
host: Discourse.current_hostname,
),
)
end
end
end
cooked = post_analyzer.cook(raw, options)
new_cooked = Plugin::Filter.apply(:after_post_cook, self, cooked)
if post_type == Post.types[:regular]
if new_cooked != cooked && new_cooked.blank?
Rails.logger.debug("Plugin is blanking out post: #{self.url}\nraw: #{raw}")
elsif new_cooked.blank?
Rails.logger.debug("Blank post detected post: #{self.url}\nraw: #{raw}")
end
end
new_cooked
2013-02-06 03:16:51 +08:00
end
# Sometimes the post is being edited by someone else, for example, a mod.
# If that's the case, they should not be bound by the original poster's
# restrictions, for example on not posting images.
def acting_user
@acting_user || user
end
def acting_user=(pu)
@acting_user = pu
end
def last_editor
self.last_editor_id ? (User.find_by_id(self.last_editor_id) || user) : user
end
def allowed_spam_hosts
hosts =
SiteSetting
.allowed_spam_host_domains
.split("|")
.map { |h| h.strip }
.reject { |h| !h.include?(".") }
hosts << GlobalSetting.hostname
hosts << RailsMultisite::ConnectionManagement.current_hostname
end
def total_hosts_usage
hosts = linked_hosts.clone
allowlisted = allowed_spam_hosts
hosts.reject! { |h| allowlisted.any? { |w| h.end_with?(w) } }
return hosts if hosts.length == 0
TopicLink
.where(domain: hosts.keys, user_id: acting_user.id)
.group(:domain, :post_id)
.count
.each_key do |tuple|
domain = tuple[0]
hosts[domain] = (hosts[domain] || 0) + 1
end
hosts
end
# Prevent new users from posting the same hosts too many times.
def has_host_spam?
if acting_user.present? &&
(
acting_user.staged? || acting_user.mature_staged? ||
acting_user.has_trust_level?(TrustLevel[1])
)
return false
end
return false if topic&.private_message?
total_hosts_usage.values.any? { |count| count >= SiteSetting.newuser_spam_host_threshold }
end
2013-02-06 03:16:51 +08:00
def archetype
topic&.archetype
2013-02-06 03:16:51 +08:00
end
2013-02-07 23:45:24 +08:00
2013-02-06 03:16:51 +08:00
def self.regular_order
2013-02-07 23:45:24 +08:00
order(:sort_order, :post_number)
2013-02-06 03:16:51 +08:00
end
def self.reverse_order
2013-02-07 23:45:24 +08:00
order("sort_order desc, post_number desc")
2013-02-06 03:16:51 +08:00
end
def self.summary(topic_id)
topic_id = topic_id.to_i
# percent rank has tons of ties
where(topic_id: topic_id).where(
[
"posts.id = ANY(
(
SELECT posts.id
FROM posts
WHERE posts.topic_id = #{topic_id.to_i}
AND posts.post_number = 1
) UNION
(
SELECT p1.id
FROM posts p1
WHERE p1.percent_rank <= ?
AND p1.topic_id = #{topic_id.to_i}
ORDER BY p1.percent_rank
LIMIT ?
)
)",
SiteSetting.summary_percent_filter.to_f / 100.0,
SiteSetting.summary_max_results,
],
)
2013-02-06 03:16:51 +08:00
end
def delete_post_notices
self.custom_fields.delete(Post::NOTICE)
self.save_custom_fields
end
def recover_public_post_actions
PostAction
.publics
.with_deleted
.where(post_id: self.id, id: self.custom_fields["deleted_public_actions"])
.find_each do |post_action|
post_action.recover!
post_action.save!
end
self.custom_fields.delete("deleted_public_actions")
self.save_custom_fields
end
def filter_quotes(parent_post = nil)
2013-02-06 03:16:51 +08:00
return cooked if parent_post.blank?
# We only filter quotes when there is exactly 1
return cooked unless (quote_count == 1)
parent_raw = parent_post.raw.sub(%r{\[quote.+/quote\]}m, "")
2013-02-06 03:16:51 +08:00
if raw[parent_raw] || (parent_raw.size < SHORT_POST_CHARS)
2013-02-06 03:16:51 +08:00
return cooked.sub(%r{\<aside.+\</aside\>}m, "")
end
cooked
end
def external_id
2013-02-07 23:45:24 +08:00
"#{topic_id}/#{post_number}"
2013-02-06 03:16:51 +08:00
end
2014-01-04 01:52:24 +08:00
def reply_to_post
return if reply_to_post_number.blank?
@reply_to_post ||=
Post.find_by(
"topic_id = :topic_id AND post_number = :post_number",
topic_id: topic_id,
post_number: reply_to_post_number,
)
2014-01-04 01:52:24 +08:00
end
2013-02-06 03:16:51 +08:00
def reply_notification_target
return if reply_to_post_number.blank?
Post.find_by(
"topic_id = :topic_id AND post_number = :post_number AND user_id <> :user_id",
topic_id: topic_id,
post_number: reply_to_post_number,
user_id: user_id,
).try(:user)
2013-02-06 03:16:51 +08:00
end
2013-04-30 11:25:55 +08:00
def self.excerpt(cooked, maxlength = nil, options = {})
2013-02-06 03:16:51 +08:00
maxlength ||= SiteSetting.post_excerpt_maxlength
2013-04-30 11:25:55 +08:00
PrettyText.excerpt(cooked, maxlength, options)
2013-02-06 03:16:51 +08:00
end
# Strip out most of the markup
2013-04-30 11:25:55 +08:00
def excerpt(maxlength = nil, options = {})
Post.excerpt(cooked, maxlength, options.merge(post: self))
2013-02-06 03:16:51 +08:00
end
def excerpt_for_topic
Post.excerpt(
cooked,
SiteSetting.topic_excerpt_maxlength,
strip_links: true,
strip_images: true,
post: self,
)
end
def is_first_post?
post_number.blank? ? topic.try(:highest_post_number) == 0 : post_number == 1
end
def is_category_description?
topic.present? && topic.is_category_topic? && is_first_post?
end
def is_reply_by_email?
via_email && post_number.present? && post_number > 1
end
2013-02-07 23:45:24 +08:00
def is_flagged?
flags.count != 0
end
def flags
post_actions.where(
post_action_type_id: PostActionType.flag_types_without_custom.values,
deleted_at: nil,
)
2013-02-07 12:15:48 +08:00
end
def reviewable_flag
ReviewableFlaggedPost.pending.find_by(target: self)
end
def with_secure_uploads?
return false if !SiteSetting.secure_uploads?
topic_including_deleted = Topic.with_deleted.find_by(id: self.topic_id)
return false if topic_including_deleted.blank?
# NOTE: This is meant to be a stopgap solution to prevent secure uploads
# in a single place (private messages) for sensitive admin data exports.
# Ideally we would want a more comprehensive way of saying that certain
# upload types get secured which is a hybrid/mixed mode secure uploads,
# but for now this will do the trick.
return topic_including_deleted.private_message? if SiteSetting.secure_uploads_pm_only?
SiteSetting.login_required? || topic_including_deleted.private_message? ||
topic_including_deleted.read_restricted_category?
end
def hide!(post_action_type_id, reason = nil, custom_message: nil)
return if hidden?
reason ||=
(
if hidden_at
Post.hidden_reasons[:flag_threshold_reached_again]
else
Post.hidden_reasons[:flag_threshold_reached]
end
)
hiding_again = hidden_at.present?
Post.transaction do
self.skip_validation = true
update!(hidden: true, hidden_at: Time.zone.now, hidden_reason_id: reason)
Topic.where(
"id = :topic_id AND NOT EXISTS(SELECT 1 FROM POSTS WHERE topic_id = :topic_id AND NOT hidden)",
topic_id: topic_id,
).update_all(visible: false)
UserStatCountUpdater.decrement!(self)
end
# inform user
if user.present?
options = {
url: url,
edit_delay: SiteSetting.cooldown_minutes_after_hiding_posts,
flag_reason:
I18n.t(
"flag_reasons.#{PostActionType.types[post_action_type_id]}",
locale: SiteSetting.default_locale,
base_path: Discourse.base_path,
),
}
message = custom_message
message = hiding_again ? :post_hidden_again : :post_hidden if message.nil?
Jobs.enqueue_in(
5.seconds,
:send_system_message,
user_id: user.id,
message_type: message.to_s,
message_options: options,
)
end
end
2013-02-07 12:15:48 +08:00
def unhide!
Post.transaction do
self.update!(hidden: false)
self.topic.update(visible: true) if is_first_post?
UserStatCountUpdater.increment!(self)
save(validate: false)
end
2014-09-23 00:55:13 +08:00
publish_change_to_clients!(:acted)
2013-02-07 12:15:48 +08:00
end
def full_url(opts = {})
"#{Discourse.base_url}#{url(opts)}"
end
def relative_url(opts = {})
"#{Discourse.base_path}#{url(opts)}"
end
def url(opts = nil)
opts ||= {}
2015-08-12 05:28:36 +08:00
if topic
Post.url(topic.slug, topic.id, post_number, opts)
2015-08-12 05:28:36 +08:00
else
"/404"
end
end
def canonical_url
topic_view = TopicView.new(topic, nil, post_number: post_number)
page = ""
page = "?page=#{topic_view.page}" if topic_view.page > 1
"#{topic.url}#{page}#post_#{post_number}"
end
def unsubscribe_url(user)
key_value = UnsubscribeKey.create_key_for(user, UnsubscribeKey::TOPIC_TYPE, post: self)
"#{Discourse.base_url}/email/unsubscribe/#{key_value}"
end
def self.url(slug, topic_id, post_number, opts = nil)
opts ||= {}
result = +"/t/"
result << "#{slug}/" if !opts[:without_slug]
if post_number == 1 && opts[:share_url]
"#{result}#{topic_id}"
else
"#{result}#{topic_id}/#{post_number}"
end
end
def self.urls(post_ids)
ids = post_ids.map { |u| u }
if ids.length > 0
urls = {}
Topic
.joins(:posts)
.where("posts.id" => ids)
.select(["posts.id as post_id", "post_number", "topics.slug", "topics.title", "topics.id"])
.each { |t| urls[t.post_id.to_i] = url(t.slug, t.id, t.post_number) }
urls
else
{}
end
2013-02-06 03:16:51 +08:00
end
def revise(updated_by, changes = {}, opts = {})
PostRevisor.new(self).revise!(updated_by, changes, opts)
end
def self.rebake_old(limit, priority: :normal, rate_limiter: true)
limiter =
RateLimiter.new(
nil,
"global_periodical_rebake_limit",
GlobalSetting.max_old_rebakes_per_15_minutes,
900,
global: true,
)
problems = []
2014-05-30 12:45:39 +08:00
Post
.where("baked_version IS NULL OR baked_version < ?", BAKED_VERSION)
.order("id desc")
.limit(limit)
.pluck(:id)
.each do |id|
begin
break if !limiter.can_perform?
post = Post.find(id)
post.rebake!(priority: priority)
begin
limiter.performed! if rate_limiter
rescue RateLimiter::LimitExceeded
break
end
rescue => e
problems << { post: post, ex: e }
2017-12-27 10:51:16 +08:00
attempts = post.custom_fields["rebake_attempts"].to_i
if attempts > 3
post.update_columns(baked_version: BAKED_VERSION)
Discourse.warn_exception(
e,
message: "Can not rebake post# #{post.id} after 3 attempts, giving up",
)
else
post.custom_fields["rebake_attempts"] = attempts + 1
post.save_custom_fields
end
end
end
problems
end
FIX: Secure upload post processing race condition (#23968) * FIX: Secure upload post processing race condition This commit fixes a couple of issues. A little background -- when uploads are created in the composer for posts, regardless of whether the upload will eventually be marked secure or not, if secure_uploads is enabled we always mark the upload secure at first. This is so the upload is by default protected, regardless of post type (regular or PM) or category. This was causing issues in some rare occasions though because of the order of operations of our post creation and processing pipeline. When creating a post, we enqueue a sidekiq job to post-process the post which does various things including converting images to lightboxes. We were also enqueuing a job to update the secure status for all uploads in that post. Sometimes the secure status job would run before the post process job, marking uploads as _not secure_ in the background and changing their ACL before the post processor ran, which meant the users would see a broken image in their posts. This commit fixes that issue by always running the upload security changes inline _within_ the cooked_post_processor job. The other issue was that the lightbox wrapper link for images in the post would end up with a URL like this: ``` href="/secure-uploads/original/2X/4/4e1f00a40b6c952198bbdacae383ba77932fc542.jpeg" ``` Since we weren't actually using the `upload.url` to pass to `UrlHelper.cook_url` here, we weren't converting this href to the CDN URL if the post was not in a secure context (the UrlHelper does not know how to convert a secure-uploads URL to a CDN one). Now we always end up with the correct lightbox href. This was less of an issue than the other one, since the secure-uploads URL works even when the upload has become non-secure, but it was a good inconsistency to fix anyway.
2023-10-19 07:48:01 +08:00
def rebake!(invalidate_broken_images: false, invalidate_oneboxes: false, priority: nil)
new_cooked = cook(raw, topic_id: topic_id, invalidate_oneboxes: invalidate_oneboxes)
old_cooked = cooked
PERF: Improve quality of `PostSearchData#raw_data`. (#7275) This commit fixes the follow quality issue with `PostSearchData#raw_data`: 1. URLs are being tokenized and links with similar href and characters are being duplicated in the raw data. `Post#cooked`: ``` <p><a href=\"https://meta.discourse.org/some.png\" class=\"onebox\" target=\"_blank\" rel=\"nofollow noopener\">https://meta.discourse.org/some.png</a></p> ``` `PostSearchData#raw_data` Before: ``` This is a test topic 0 Uncategorized https://meta.discourse.org/some.png discourse org/some png https://meta.discourse.org/some.png discourse org/some png ``` `PostSearchData#raw_data` After: ``` This is a test topic 0 Uncategorized https://meta.discourse.org/some.png meta discourse org ``` 2. Ligthbox being included in search pollutes the `PostSearchData#raw_data` unncessarily. From 28 March 2018 to 28 March 2019, searches for the term `image` on `meta.discourse.org` had a click through rate of 2.1%. Non-lightboxed images are not included in indexing for search yet we were indexing content within a lightbox. Also, search for terms like `image` was affected we were using `Pasted image` as the filename for uploads that were pasted. `Post#cooked` ``` <p>Let me see how I can fix this image<br>\n<div class=\"lightbox-wrapper\"><a class=\"lightbox\" href=\"https://meta.discourse.org/some.png\" title=\"some.png\" rel=\"nofollow noopener\"><img src=\"https://meta.discourse.org/some.png\" width=\"275\" height=\"299\"><div class=\"meta\">\n<svg class=\"fa d-icon d-icon-far-image svg-icon\" aria-hidden=\"true\"><use xlink:href=\"#far-image\"></use></svg><span class=\"filename\">some.png</span><span class=\"informations\">1750×2000</span><svg class=\"fa d-icon d-icon-discourse-expand svg-icon\" aria-hidden=\"true\"><use xlink:href=\"#discourse-expand\"></use></svg>\n</div></a></div></p> ``` `PostSearchData#raw_data` Before: ``` This is a test topic 0 Uncategorized Let me see how I can fix this image some.png png https://meta.discourse.org/some.png discourse org/some png some.png png 1750×2000 ``` `PostSearchData#raw_data` After: ``` This is a test topic 0 Uncategorized Let me see how I can fix this image ``` In terms of indexing performance, we now have to parse the given HTML through nokogiri twice. However performance is not a huge worry here since a string length of 194170 takes only 30ms to scrub plus the indexing takes place in a background job.
2019-04-01 10:14:29 +08:00
update_columns(cooked: new_cooked, baked_at: Time.zone.now, baked_version: BAKED_VERSION)
topic&.update_excerpt(excerpt_for_topic) if is_first_post?
if invalidate_broken_images
post_hotlinked_media.download_failed.destroy_all
post_hotlinked_media.upload_create_failed.destroy_all
end
# Extracts urls from the body
TopicLink.extract_from(self)
QuotedPost.extract_from(self)
# make sure we trigger the post process
trigger_post_process(bypass_bump: true, priority: priority)
2014-09-23 00:55:13 +08:00
publish_change_to_clients!(:rebaked)
new_cooked != old_cooked
end
def set_owner(new_user, actor, skip_revision = false)
return if user_id == new_user.id
edit_reason = I18n.t("change_owner.post_revision_text", locale: SiteSetting.default_locale)
revise(
actor,
{ raw: self.raw, user_id: new_user.id, edit_reason: edit_reason },
bypass_bump: true,
skip_revision: skip_revision,
skip_validations: true,
)
topic.update_columns(last_post_user_id: new_user.id) if post_number == topic.highest_post_number
end
before_create { PostCreator.before_create_tasks(self) }
2013-02-06 03:16:51 +08:00
def self.estimate_posts_per_day
val = Discourse.redis.get("estimated_posts_per_day")
return val.to_i if val
posts_per_day =
Topic.listable_topics.secured.joins(:posts).merge(Post.created_since(30.days.ago)).count / 30
Discourse.redis.setex("estimated_posts_per_day", 1.day.to_i, posts_per_day.to_s)
posts_per_day
end
2013-02-07 23:45:24 +08:00
before_save do
self.last_editor_id ||= user_id
if will_save_change_to_raw?
self.cooked = cook(raw, topic_id: topic_id) if !new_record?
self.baked_at = Time.zone.now
self.baked_version = BAKED_VERSION
end
2013-02-06 03:16:51 +08:00
end
def advance_draft_sequence
return if topic.blank? # could be deleted
DraftSequence.next!(last_editor_id, topic.draft_key) if last_editor_id
end
2013-07-23 04:39:20 +08:00
# TODO: move to post-analyzer?
# Determine what posts are quoted by this post
2013-02-06 03:16:51 +08:00
def extract_quoted_post_numbers
2013-05-23 03:45:31 +08:00
temp_collector = []
2013-02-06 03:16:51 +08:00
# Create relationships for the quotes
2013-05-23 03:38:45 +08:00
raw
.scan(/\[quote=\"([^"]+)"\]/)
.each do |quote|
args = parse_quote_into_arguments(quote)
2013-05-23 03:45:31 +08:00
# If the topic attribute is present, ensure it's the same topic
if !(args[:topic].present? && topic_id != args[:topic]) && args[:post] != post_number
temp_collector << args[:post]
end
end
2013-02-07 23:45:24 +08:00
2013-05-23 03:45:31 +08:00
temp_collector.uniq!
self.quoted_post_numbers = temp_collector
self.quote_count = temp_collector.size
2013-02-06 03:16:51 +08:00
end
def save_reply_relationships
2013-05-24 00:09:06 +08:00
add_to_quoted_post_numbers(reply_to_post_number)
return if self.quoted_post_numbers.blank?
# Create a reply relationship between quoted posts and this new post
2013-05-24 00:09:06 +08:00
self.quoted_post_numbers.each do |p|
post = Post.find_by(topic_id: topic_id, post_number: p)
2013-05-24 00:09:06 +08:00
create_reply_relationship_with(post)
end
end
# Enqueue post processing for this post
def trigger_post_process(
bypass_bump: false,
priority: :normal,
new_post: false,
skip_pull_hotlinked_images: false
)
2013-11-22 08:52:26 +08:00
args = {
bypass_bump: bypass_bump,
cooking_options: self.cooking_options,
new_post: new_post,
post_id: self.id,
skip_pull_hotlinked_images: skip_pull_hotlinked_images,
2013-11-22 08:52:26 +08:00
}
args[:image_sizes] = image_sizes if self.image_sizes.present?
args[:invalidate_oneboxes] = true if self.invalidate_oneboxes.present?
args[:queue] = priority.to_s if priority && priority != :normal
2013-02-07 23:45:24 +08:00
Jobs.enqueue(:process_post, args)
DiscourseEvent.trigger(:after_trigger_post_process, self)
2013-02-06 03:16:51 +08:00
end
2013-03-08 00:07:59 +08:00
def self.public_posts_count_per_day(
start_date,
end_date,
category_id = nil,
include_subcategories = false,
group_ids = nil
)
result =
public_posts.where(
"posts.created_at >= ? AND posts.created_at <= ?",
start_date,
end_date,
).where(post_type: Post.types[:regular])
if category_id
if include_subcategories
result = result.where("topics.category_id IN (?)", Category.subcategory_ids(category_id))
else
result = result.where("topics.category_id IN (?)", category_id)
end
end
if group_ids
result =
result
.joins("INNER JOIN users ON users.id = posts.user_id")
.joins("INNER JOIN group_users ON group_users.user_id = users.id")
.where("group_users.group_id IN (?)", group_ids)
end
result.group("date(posts.created_at)").order("date(posts.created_at)").count
end
def self.private_messages_count_per_day(start_date, end_date, topic_subtype)
private_posts
.with_topic_subtype(topic_subtype)
.where("posts.created_at >= ? AND posts.created_at <= ?", start_date, end_date)
.group("date(posts.created_at)")
.order("date(posts.created_at)")
.count
2013-03-08 00:07:59 +08:00
end
def reply_history(max_replies = 100, guardian = nil)
post_ids = DB.query_single(<<~SQL, post_id: id, topic_id: topic_id)
WITH RECURSIVE breadcrumb(id, reply_to_post_number) AS (
SELECT p.id, p.reply_to_post_number FROM posts AS p
WHERE p.id = :post_id
UNION
SELECT p.id, p.reply_to_post_number FROM posts AS p, breadcrumb
WHERE breadcrumb.reply_to_post_number = p.post_number
AND p.topic_id = :topic_id
)
SELECT id from breadcrumb
WHERE id <> :post_id
ORDER by id
SQL
# [1,2,3][-10,-1] => nil
post_ids = (post_ids[(0 - max_replies)..-1] || post_ids)
Post.secured(guardian).where(id: post_ids).includes(:user, :topic).order(:id).to_a
end
MAX_REPLY_LEVEL ||= 1000
def reply_ids(guardian = nil, only_replies_to_single_post: true)
builder = DB.build(<<~SQL)
WITH RECURSIVE breadcrumb(id, level) AS (
SELECT :post_id, 0
UNION
SELECT reply_post_id, level + 1
FROM post_replies AS r
JOIN posts AS p ON p.id = reply_post_id
JOIN breadcrumb AS b ON (r.post_id = b.id)
WHERE r.post_id <> r.reply_post_id
AND b.level < :max_reply_level
AND p.topic_id = :topic_id
), breadcrumb_with_count AS (
SELECT
id,
level,
COUNT(*) AS count
FROM post_replies AS r
JOIN breadcrumb AS b ON (r.reply_post_id = b.id)
WHERE r.reply_post_id <> r.post_id
GROUP BY id, level
)
SELECT id, MIN(level) AS level
FROM breadcrumb_with_count
/*where*/
GROUP BY id
ORDER BY id
SQL
builder.where("level > 0")
# ignore posts that aren't replies to exactly one post
# for example it skips a post when it contains 2 quotes (which are replies) from different posts
builder.where("count = 1") if only_replies_to_single_post
replies = builder.query_hash(post_id: id, max_reply_level: MAX_REPLY_LEVEL, topic_id: topic_id)
replies.each { |r| r.symbolize_keys! }
secured_ids = Post.secured(guardian).where(id: replies.map { |r| r[:id] }).pluck(:id).to_set
replies.reject { |r| !secured_ids.include?(r[:id]) }
end
2013-12-12 10:41:34 +08:00
def revert_to(number)
return if number >= version
post_revision = PostRevision.find_by(post_id: id, number: (number + 1))
2013-12-12 10:41:34 +08:00
post_revision.modifications.each do |attribute, change|
attribute = "version" if attribute == "cached_version"
write_attribute(attribute, change[0])
end
end
def self.rebake_all_quoted_posts(user_id)
return if user_id.blank?
DB.exec(<<~SQL, user_id)
WITH user_quoted_posts AS (
SELECT post_id
FROM quoted_posts
WHERE quoted_post_id IN (SELECT id FROM posts WHERE user_id = ?)
)
UPDATE posts
SET baked_version = NULL
WHERE baked_version IS NOT NULL
AND id IN (SELECT post_id FROM user_quoted_posts)
SQL
end
def seen?(user)
PostTiming.where(topic_id: topic_id, post_number: post_number, user_id: user.id).exists?
end
def index_search
Scheduler::Defer.later "Index post for search" do
SearchIndexer.index(self)
end
end
def locked?
locked_by_id.present?
end
def link_post_uploads(fragments: nil)
upload_ids = []
each_upload_url(fragments: fragments) do |src, _, sha1|
upload = nil
upload = Upload.find_by(sha1: sha1) if sha1.present?
upload ||= Upload.get_from_url(src)
# Link any video thumbnails
if SiteSetting.video_thumbnails_enabled && upload.present? &&
FileHelper.supported_video.include?(upload.extension&.downcase)
# Video thumbnails have the filename of the video file sha1 with a .png or .jpg extension.
# This is because at time of upload in the composer we don't know the topic/post id yet
# and there is no thumbnail info added to the markdown to tie the thumbnail to the topic/post after
# creation.
thumbnail =
Upload
.where("original_filename like ?", "#{upload.sha1}.%")
.order(id: :desc)
.first if upload.sha1.present?
if thumbnail.present? && self.is_first_post? && !self.topic.image_upload_id
upload_ids << thumbnail.id
self.topic.update_column(:image_upload_id, thumbnail.id)
extra_sizes =
ThemeModifierHelper.new(
theme_ids: Theme.user_selectable.pluck(:id),
).topic_thumbnail_sizes
self.topic.generate_thumbnails!(extra_sizes: extra_sizes)
end
end
upload_ids << upload.id if upload.present?
end
upload_references =
upload_ids.map do |upload_id|
{
target_id: self.id,
target_type: self.class.name,
upload_id: upload_id,
created_at: Time.zone.now,
updated_at: Time.zone.now,
}
end
UploadReference.transaction do
UploadReference.where(target: self).delete_all
UploadReference.insert_all(upload_references) if upload_references.size > 0
FEATURE: Secure media allowing duplicated uploads with category-level privacy and post-based access rules (#8664) ### General Changes and Duplication * We now consider a post `with_secure_media?` if it is in a read-restricted category. * When uploading we now set an upload's secure status straight away. * When uploading if `SiteSetting.secure_media` is enabled, we do not check to see if the upload already exists using the `sha1` digest of the upload. The `sha1` column of the upload is filled with a `SecureRandom.hex(20)` value which is the same length as `Upload::SHA1_LENGTH`. The `original_sha1` column is filled with the _real_ sha1 digest of the file. * Whether an upload `should_be_secure?` is now determined by whether the `access_control_post` is `with_secure_media?` (if there is no access control post then we leave the secure status as is). * When serializing the upload, we now cook the URL if the upload is secure. This is so it shows up correctly in the composer preview, because we set secure status on upload. ### Viewing Secure Media * The secure-media-upload URL will take the post that the upload is attached to into account via `Guardian.can_see?` for access permissions * If there is no `access_control_post` then we just deliver the media. This should be a rare occurrance and shouldn't cause issues as the `access_control_post` is set when `link_post_uploads` is called via `CookedPostProcessor` ### Removed We no longer do any of these because we do not reuse uploads by sha1 if secure media is enabled. * We no longer have a way to prevent cross-posting of a secure upload from a private context to a public context. * We no longer have to set `secure: false` for uploads when uploading for a theme component.
2020-01-16 11:50:27 +08:00
if SiteSetting.secure_uploads?
Upload
.where(id: upload_ids, access_control_post_id: nil)
.where("id NOT IN (SELECT upload_id FROM custom_emojis)")
FEATURE: Secure media allowing duplicated uploads with category-level privacy and post-based access rules (#8664) ### General Changes and Duplication * We now consider a post `with_secure_media?` if it is in a read-restricted category. * When uploading we now set an upload's secure status straight away. * When uploading if `SiteSetting.secure_media` is enabled, we do not check to see if the upload already exists using the `sha1` digest of the upload. The `sha1` column of the upload is filled with a `SecureRandom.hex(20)` value which is the same length as `Upload::SHA1_LENGTH`. The `original_sha1` column is filled with the _real_ sha1 digest of the file. * Whether an upload `should_be_secure?` is now determined by whether the `access_control_post` is `with_secure_media?` (if there is no access control post then we leave the secure status as is). * When serializing the upload, we now cook the URL if the upload is secure. This is so it shows up correctly in the composer preview, because we set secure status on upload. ### Viewing Secure Media * The secure-media-upload URL will take the post that the upload is attached to into account via `Guardian.can_see?` for access permissions * If there is no `access_control_post` then we just deliver the media. This should be a rare occurrance and shouldn't cause issues as the `access_control_post` is set when `link_post_uploads` is called via `CookedPostProcessor` ### Removed We no longer do any of these because we do not reuse uploads by sha1 if secure media is enabled. * We no longer have a way to prevent cross-posting of a secure upload from a private context to a public context. * We no longer have to set `secure: false` for uploads when uploading for a theme component.
2020-01-16 11:50:27 +08:00
.update_all(access_control_post_id: self.id)
end
end
end
def update_uploads_secure_status(source:)
FIX: Secure upload post processing race condition (#23968) * FIX: Secure upload post processing race condition This commit fixes a couple of issues. A little background -- when uploads are created in the composer for posts, regardless of whether the upload will eventually be marked secure or not, if secure_uploads is enabled we always mark the upload secure at first. This is so the upload is by default protected, regardless of post type (regular or PM) or category. This was causing issues in some rare occasions though because of the order of operations of our post creation and processing pipeline. When creating a post, we enqueue a sidekiq job to post-process the post which does various things including converting images to lightboxes. We were also enqueuing a job to update the secure status for all uploads in that post. Sometimes the secure status job would run before the post process job, marking uploads as _not secure_ in the background and changing their ACL before the post processor ran, which meant the users would see a broken image in their posts. This commit fixes that issue by always running the upload security changes inline _within_ the cooked_post_processor job. The other issue was that the lightbox wrapper link for images in the post would end up with a URL like this: ``` href="/secure-uploads/original/2X/4/4e1f00a40b6c952198bbdacae383ba77932fc542.jpeg" ``` Since we weren't actually using the `upload.url` to pass to `UrlHelper.cook_url` here, we weren't converting this href to the CDN URL if the post was not in a secure context (the UrlHelper does not know how to convert a secure-uploads URL to a CDN one). Now we always end up with the correct lightbox href. This was less of an issue than the other one, since the secure-uploads URL works even when the upload has become non-secure, but it was a good inconsistency to fix anyway.
2023-10-19 07:48:01 +08:00
if Discourse.store.external?
self.uploads.each { |upload| upload.update_secure_status(source: source) }
end
end
def each_upload_url(fragments: nil, include_local_upload: true)
current_db = RailsMultisite::ConnectionManagement.current_db
upload_patterns = [
%r{/uploads/#{current_db}/},
%r{/original/},
%r{/optimized/},
%r{/uploads/short-url/[a-zA-Z0-9]+(\.[a-z0-9]+)?},
]
fragments ||= Nokogiri::HTML5.fragment(self.cooked)
selectors =
fragments.css(
"a/@href",
"img/@src",
"source/@src",
"track/@src",
"video/@poster",
"div/@data-video-src",
)
links =
selectors
.map do |media|
src = media.value
next if src.blank?
if src.end_with?("/images/transparent.png") &&
(parent = media.parent)["data-orig-src"].present?
parent["data-orig-src"]
else
src
end
end
.compact
.uniq
links.each do |src|
src = src.split("?")[0]
if src.start_with?("upload://")
sha1 = Upload.sha1_from_short_url(src)
yield(src, nil, sha1)
next
elsif src.include?("/uploads/short-url/")
sha1 = Upload.sha1_from_short_path(src)
yield(src, nil, sha1)
next
end
next if upload_patterns.none? { |pattern| src =~ pattern }
next if Rails.configuration.multisite && src.exclude?(current_db)
src = "#{SiteSetting.force_https ? "https" : "http"}:#{src}" if src.start_with?("//")
if !Discourse.store.has_been_uploaded?(src) && !Upload.secure_uploads_url?(src) &&
!(include_local_upload && src =~ %r{\A/[^/]}i)
next
end
path =
begin
URI(
UrlHelper.unencode(GlobalSetting.cdn_url ? src.sub(GlobalSetting.cdn_url, "") : src),
)&.path
rescue URI::Error
end
next if path.blank?
sha1 =
if path.include? "optimized"
OptimizedImage.extract_sha1(path)
else
Upload.extract_sha1(path) || Upload.sha1_from_short_path(path)
end
yield(src, path, sha1)
end
end
def self.find_missing_uploads(include_local_upload: true)
missing_uploads = []
missing_post_uploads = {}
count = 0
DistributedMutex.synchronize("find_missing_uploads", validity: 30.minutes) do
PostCustomField.where(name: Post::MISSING_UPLOADS).delete_all
query =
Post
.have_uploads
.joins(:topic)
.joins(
"LEFT JOIN post_custom_fields ON posts.id = post_custom_fields.post_id AND post_custom_fields.name = '#{Post::MISSING_UPLOADS_IGNORED}'",
)
.where("post_custom_fields.id IS NULL")
.select(:id, :cooked)
query.find_in_batches do |posts|
ids = posts.pluck(:id)
sha1s =
Upload
.joins(:upload_references)
.where(upload_references: { target_type: "Post" })
.where("upload_references.target_id BETWEEN ? AND ?", ids.min, ids.max)
.pluck(:sha1)
posts.each do |post|
post.each_upload_url do |src, path, sha1|
next if sha1.present? && sha1s.include?(sha1)
missing_post_uploads[post.id] ||= []
if missing_uploads.include?(src)
missing_post_uploads[post.id] << src
next
end
upload_id = nil
upload_id = Upload.where(sha1: sha1).pick(:id) if sha1.present?
upload_id ||= yield(post, src, path, sha1)
if upload_id.blank?
missing_uploads << src
missing_post_uploads[post.id] << src
end
end
end
end
missing_post_uploads =
missing_post_uploads.reject do |post_id, uploads|
if uploads.present?
PostCustomField.create!(
post_id: post_id,
name: Post::MISSING_UPLOADS,
value: uploads.to_json,
)
count += uploads.count
end
uploads.empty?
end
end
{ uploads: missing_uploads, post_uploads: missing_post_uploads, count: count }
end
def owned_uploads_via_access_control
Upload.where(access_control_post_id: self.id)
end
FEATURE: Include optimized thumbnails for topics (#9215) This introduces new APIs for obtaining optimized thumbnails for topics. There are a few building blocks required for this: - Introduces new `image_upload_id` columns on the `posts` and `topics` table. This replaces the old `image_url` column, which means that thumbnails are now restricted to uploads. Hotlinked thumbnails are no longer possible. In normal use (with pull_hotlinked_images enabled), this has no noticeable impact - A migration attempts to match existing urls to upload records. If a match cannot be found then the posts will be queued for rebake - Optimized thumbnails are generated during post_process_cooked. If thumbnails are missing when serializing a topic list, then a sidekiq job is queued - Topic lists and topics now include a `thumbnails` key, which includes all the available images: ``` "thumbnails": [ { "max_width": null, "max_height": null, "url": "//example.com/original-image.png", "width": 1380, "height": 1840 }, { "max_width": 1024, "max_height": 1024, "url": "//example.com/optimized-image.png", "width": 768, "height": 1024 } ] ``` - Themes can request additional thumbnail sizes by using a modifier in their `about.json` file: ``` "modifiers": { "topic_thumbnail_sizes": [ [200, 200], [800, 800] ], ... ``` Remember that these are generated asynchronously, so your theme should include logic to fallback to other available thumbnails if your requested size has not yet been generated - Two new raw plugin outlets are introduced, to improve the customisability of the topic list. `topic-list-before-columns` and `topic-list-before-link`
2020-05-05 16:07:50 +08:00
def image_url
raw_url = image_upload&.url
UrlHelper.cook_url(raw_url, secure: image_upload&.secure?, local: true) if raw_url
FEATURE: Include optimized thumbnails for topics (#9215) This introduces new APIs for obtaining optimized thumbnails for topics. There are a few building blocks required for this: - Introduces new `image_upload_id` columns on the `posts` and `topics` table. This replaces the old `image_url` column, which means that thumbnails are now restricted to uploads. Hotlinked thumbnails are no longer possible. In normal use (with pull_hotlinked_images enabled), this has no noticeable impact - A migration attempts to match existing urls to upload records. If a match cannot be found then the posts will be queued for rebake - Optimized thumbnails are generated during post_process_cooked. If thumbnails are missing when serializing a topic list, then a sidekiq job is queued - Topic lists and topics now include a `thumbnails` key, which includes all the available images: ``` "thumbnails": [ { "max_width": null, "max_height": null, "url": "//example.com/original-image.png", "width": 1380, "height": 1840 }, { "max_width": 1024, "max_height": 1024, "url": "//example.com/optimized-image.png", "width": 768, "height": 1024 } ] ``` - Themes can request additional thumbnail sizes by using a modifier in their `about.json` file: ``` "modifiers": { "topic_thumbnail_sizes": [ [200, 200], [800, 800] ], ... ``` Remember that these are generated asynchronously, so your theme should include logic to fallback to other available thumbnails if your requested size has not yet been generated - Two new raw plugin outlets are introduced, to improve the customisability of the topic list. `topic-list-before-columns` and `topic-list-before-link`
2020-05-05 16:07:50 +08:00
end
def cannot_permanently_delete_reason(user)
if 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 mentions
PrettyText.extract_mentions(Nokogiri::HTML5.fragment(cooked))
end
2013-12-12 10:41:34 +08:00
private
2013-05-23 03:45:31 +08:00
2013-05-23 03:38:45 +08:00
def parse_quote_into_arguments(quote)
return {} unless quote.present?
2014-08-18 11:00:02 +08:00
args = HashWithIndifferentAccess.new
2013-05-23 03:38:45 +08:00
quote.first.scan(/([a-z]+)\:(\d+)/).each { |arg| args[arg[0]] = arg[1].to_i }
args
end
2013-05-23 03:45:31 +08:00
def add_to_quoted_post_numbers(num)
return unless num.present?
self.quoted_post_numbers ||= []
self.quoted_post_numbers << num
end
def create_reply_relationship_with(post)
return if post.nil? || self.deleted_at.present?
post_reply = post.post_replies.new(reply_post_id: id)
if post_reply.save
if Topic.visible_post_types.include?(self.post_type)
Post.where(id: post.id).update_all ["reply_count = reply_count + 1"]
end
end
end
2013-02-06 03:16:51 +08:00
end
# == Schema Information
#
# Table name: posts
#
# id :integer not null, primary key
# user_id :integer
# topic_id :integer not null
# post_number :integer not null
# raw :text not null
# cooked :text not null
2014-08-27 13:30:17 +08:00
# created_at :datetime not null
# updated_at :datetime not null
# reply_to_post_number :integer
# reply_count :integer default(0), not null
# quote_count :integer default(0), not null
# deleted_at :datetime
# off_topic_count :integer default(0), not null
# like_count :integer default(0), not null
# incoming_link_count :integer default(0), not null
# bookmark_count :integer default(0), not null
# score :float
# reads :integer default(0), not null
# post_type :integer default(1), not null
# sort_order :integer
# last_editor_id :integer
# hidden :boolean default(FALSE), not null
# hidden_reason_id :integer
# notify_moderators_count :integer default(0), not null
# spam_count :integer default(0), not null
# illegal_count :integer default(0), not null
# inappropriate_count :integer default(0), not null
# last_version_at :datetime not null
# user_deleted :boolean default(FALSE), not null
# reply_to_user_id :integer
# percent_rank :float default(1.0)
# notify_user_count :integer default(0), not null
2013-06-17 08:48:58 +08:00
# like_score :integer default(0), not null
# deleted_by_id :integer
2019-01-12 03:29:56 +08:00
# edit_reason :string
2014-02-07 08:07:36 +08:00
# word_count :integer
# version :integer default(1), not null
# cook_method :integer default(1), not null
2014-05-22 07:00:38 +08:00
# wiki :boolean default(FALSE), not null
# baked_at :datetime
# baked_version :integer
# hidden_at :datetime
2014-07-15 09:29:44 +08:00
# self_edits :integer default(0), not null
2014-07-31 11:14:40 +08:00
# reply_quoted :boolean default(FALSE), not null
# via_email :boolean default(FALSE), not null
# raw_email :text
# public_version :integer default(1), not null
2019-01-12 03:29:56 +08:00
# action_code :string
2018-02-20 14:28:58 +08:00
# locked_by_id :integer
FEATURE: Include optimized thumbnails for topics (#9215) This introduces new APIs for obtaining optimized thumbnails for topics. There are a few building blocks required for this: - Introduces new `image_upload_id` columns on the `posts` and `topics` table. This replaces the old `image_url` column, which means that thumbnails are now restricted to uploads. Hotlinked thumbnails are no longer possible. In normal use (with pull_hotlinked_images enabled), this has no noticeable impact - A migration attempts to match existing urls to upload records. If a match cannot be found then the posts will be queued for rebake - Optimized thumbnails are generated during post_process_cooked. If thumbnails are missing when serializing a topic list, then a sidekiq job is queued - Topic lists and topics now include a `thumbnails` key, which includes all the available images: ``` "thumbnails": [ { "max_width": null, "max_height": null, "url": "//example.com/original-image.png", "width": 1380, "height": 1840 }, { "max_width": 1024, "max_height": 1024, "url": "//example.com/optimized-image.png", "width": 768, "height": 1024 } ] ``` - Themes can request additional thumbnail sizes by using a modifier in their `about.json` file: ``` "modifiers": { "topic_thumbnail_sizes": [ [200, 200], [800, 800] ], ... ``` Remember that these are generated asynchronously, so your theme should include logic to fallback to other available thumbnails if your requested size has not yet been generated - Two new raw plugin outlets are introduced, to improve the customisability of the topic list. `topic-list-before-columns` and `topic-list-before-link`
2020-05-05 16:07:50 +08:00
# image_upload_id :bigint
FEATURE: Overhaul email threading (#17996) See https://meta.discourse.org/t/discourse-email-messages-are-incorrectly-threaded/233499 for thorough reasoning. This commit changes how we generate Message-IDs and do email threading for emails sent from Discourse. The main changes are as follows: * Introduce an outbound_message_id column on Post that is either a) filled with a Discourse-generated Message-ID the first time that post is used for an outbound email or b) filled with an original Message-ID from an external mail client or service if the post was created from an incoming email. * Change Discourse-generated Message-IDs to be more consistent and static, in the format `discourse/post/:post_id@:host` * Do not send References or In-Reply-To headers for emails sent for the OP of topics. * Make sure that In-Reply-To is filled with either a) the OP's Message-ID if the post is not a direct reply or b) the parent post's Message-ID * Make sure that In-Reply-To has all referenced post's Message-IDs * Make sure that References is filled with a chain of Message-IDs from the OP down to the parent post of the new post. We also are keeping X-Discourse-Post-Id and X-Discourse-Topic-Id, headers that we previously removed, for easier visual debugging of outbound emails. Finally, we backfill the `outbound_message_id` for posts that have a linked `IncomingEmail` record, using the `message_id` of that record. We do not need to do that for posts that don't have an incoming email since they are backfilled at runtime if `outbound_message_id` is missing.
2022-09-26 07:14:24 +08:00
# outbound_message_id :string
#
# Indexes
#
# idx_posts_created_at_topic_id (created_at,topic_id) WHERE (deleted_at IS NULL)
# idx_posts_deleted_posts (topic_id,post_number) WHERE (deleted_at IS NOT NULL)
# idx_posts_user_id_deleted_at (user_id) WHERE (deleted_at IS NULL)
# index_for_rebake_old (id) WHERE (((baked_version IS NULL) OR (baked_version < 2)) AND (deleted_at IS NULL))
# index_posts_on_id_and_baked_version (id DESC,baked_version) WHERE (deleted_at IS NULL)
# index_posts_on_id_topic_id_where_not_deleted_or_empty (id,topic_id) WHERE ((deleted_at IS NULL) AND (raw <> ''::text))
# index_posts_on_image_upload_id (image_upload_id)
# index_posts_on_reply_to_post_number (reply_to_post_number)
# index_posts_on_topic_id_and_created_at (topic_id,created_at)
# index_posts_on_topic_id_and_percent_rank (topic_id,percent_rank)
# index_posts_on_topic_id_and_post_number (topic_id,post_number) UNIQUE
# index_posts_on_topic_id_and_sort_order (topic_id,sort_order)
# index_posts_on_user_id_and_created_at (user_id,created_at)
# index_posts_user_and_likes (user_id,like_count DESC,created_at DESC) WHERE (post_number > 1)
#