mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 02:19:55 +08:00
When an edit adds media that `skip_review_media_groups` requires staff to approve, queueing a reviewable is not enough. At creation the check holds the whole post back until it is approved, but on the edit path the media stayed publicly visible until a moderator got to the queue, defeating the point of a review meant to happen before publication. The post is now hidden — and the topic unlisted when it is the first post — under a dedicated hidden reason while the review is pending. Approving through the existing approve-and-unhide action restores both. The author is told what happened in a PM, and every placeholder (app, crawler view, RSS) explains the post is awaiting media review instead of claiming it was flagged by the community. Reusing the hidden flag means the flag system could unhide the post while the media review was still pending: a moderator disagreeing with an unrelated flag, a user withdrawing one, or the author editing a flag-hidden post would all have published the unreviewed media. Unhiding now refuses while a media review is pending unless it is forced, which only the review's approval and a staff member explicitly unhiding the post do. Since this hide is not a flag: - the flag cooldown no longer locks the author out of editing — editing a media-hidden post never unhides it, so there is nothing to game; - hiding no longer overwrites the visibility reason of a topic that was already unlisted, so approving the media cannot silently relist a topic a moderator deliberately unlisted (which also fixes the same long-standing clobber on the flag path); - the system message options only include a flag reason when there is an actual flag, since interpolating `flag_reasons.` with no type stuffed the entire translation subtree into the job arguments. Hiding is only worth doing if the content is actually hidden, so the topic's og:image and the RSS channel description — which rendered the first post's raw — no longer advertise the content of a hidden first post. System-driven revisions (`skip_validations`) are exempt from the check, matching creation where programmatic post creation never goes through the new-post checks; refreshing a topic embed should not unlist a blog topic.
988 lines
30 KiB
Ruby
Vendored
988 lines
30 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
require "edit_rate_limiter"
|
|
require "post_locker"
|
|
|
|
class PostRevisor
|
|
# Helps us track changes to a topic.
|
|
#
|
|
# It's passed to `track_topic_fields` callbacks so they can record if they
|
|
# changed a value or not. This is needed for things like custom fields.
|
|
class TopicChanges
|
|
attr_reader :topic, :user
|
|
attr_accessor :silent
|
|
|
|
def initialize(topic, user)
|
|
@topic = topic
|
|
@user = user
|
|
@changed = {}
|
|
@errored = false
|
|
@silent = false
|
|
end
|
|
|
|
def errored?
|
|
@errored
|
|
end
|
|
|
|
def guardian
|
|
@guardian ||= Guardian.new(@user)
|
|
end
|
|
|
|
def record_change(field_name, previous_val, new_val)
|
|
return if previous_val == new_val
|
|
diff[field_name] = [previous_val, new_val]
|
|
end
|
|
|
|
def check_result(res)
|
|
@errored = true if !res
|
|
end
|
|
|
|
def diff
|
|
@diff ||= {}
|
|
end
|
|
|
|
def apply_tag_changes(tag_value)
|
|
return unless guardian.can_tag_topics?
|
|
|
|
prev_tags = topic.tags.map(&:name).sort
|
|
return if tag_value.blank? && prev_tags.blank?
|
|
|
|
success = DiscourseTagging.tag_topic(topic, guardian, tag_value)
|
|
|
|
unless success
|
|
check_result(false)
|
|
return
|
|
end
|
|
|
|
new_tags = topic.tags.map(&:name).sort
|
|
return if prev_tags == new_tags
|
|
|
|
record_change("tags", prev_tags, new_tags)
|
|
DB.after_commit do
|
|
t = topic.reload
|
|
post = t.ordered_posts.first
|
|
notified_user_ids = [post.user_id, post.last_editor_id].uniq
|
|
|
|
persisted_tag_names = t.tags.pluck(:name)
|
|
added_tags = persisted_tag_names - prev_tags
|
|
removed_tags = prev_tags - persisted_tag_names
|
|
diff_tags = added_tags | removed_tags
|
|
|
|
if diff_tags.present? && !silent
|
|
Jobs.enqueue(:notify_tag_change, post_id: post.id, notified_user_ids:, diff_tags:)
|
|
|
|
PostRevisor.create_small_action_for_tag_changes(
|
|
topic: t,
|
|
user: user,
|
|
added_tags:,
|
|
removed_tags:,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
POST_TRACKED_FIELDS = %w[
|
|
raw
|
|
cooked
|
|
edit_reason
|
|
user_id
|
|
wiki
|
|
post_type
|
|
locale
|
|
reply_to_post_number
|
|
]
|
|
|
|
# Extensions can inspect revision options via the `:post_edited` event payload.
|
|
attr_reader :category_changed, :post_revision, :opts
|
|
|
|
def initialize(post, topic = post.topic)
|
|
@post = post
|
|
@topic = topic
|
|
|
|
# Make sure we have only one Topic instance
|
|
post.topic = topic
|
|
end
|
|
|
|
def self.tracked_topic_fields
|
|
@@tracked_topic_fields ||= {}
|
|
@@tracked_topic_fields
|
|
end
|
|
|
|
def self.valid_post_type?(post_type)
|
|
[Post.types[:regular], Post.types[:moderator_action], Post.types[:whisper]].include?(post_type)
|
|
end
|
|
|
|
def self.track_topic_field(field, &block)
|
|
tracked_topic_fields[field] = block
|
|
|
|
# Define it in the serializer unless it already has been defined
|
|
if PostRevisionSerializer.instance_methods(false).exclude?(:"#{field}_changes")
|
|
PostRevisionSerializer.add_compared_field(field)
|
|
end
|
|
end
|
|
|
|
def self.track_and_revise(topic_changes, field, attribute)
|
|
topic_changes.record_change(field, topic_changes.topic.public_send(field), attribute)
|
|
topic_changes.topic.public_send("#{field}=", attribute)
|
|
end
|
|
|
|
track_topic_field(:title) do |topic_changes, attribute|
|
|
track_and_revise topic_changes, :title, attribute
|
|
end
|
|
|
|
track_topic_field(:archetype) do |topic_changes, attribute|
|
|
if topic_changes.guardian.can_change_archetype?(topic_changes.topic, attribute)
|
|
track_and_revise topic_changes, :archetype, attribute
|
|
else
|
|
topic_changes.check_result(false)
|
|
end
|
|
end
|
|
|
|
track_topic_field(:category_id) do |tc, new_category_id, fields|
|
|
current_category = tc.topic.category
|
|
new_category =
|
|
(new_category_id.nil? || new_category_id.zero?) ? nil : Category.find(new_category_id)
|
|
|
|
if new_category.nil? && tc.topic.private_message?
|
|
tc.record_change("category_id", current_category.id, nil)
|
|
tc.topic.category_id = nil
|
|
elsif new_category.nil? || tc.guardian.can_move_topic_to_category?(new_category_id)
|
|
tags =
|
|
if fields[:tags].present?
|
|
input = fields[:tags]
|
|
if input.first.is_a?(String)
|
|
input
|
|
else
|
|
ids = input.filter_map { |t| t[:id]&.to_i }
|
|
names = input.filter_map { |t| t[:id].blank? && t[:name].presence }
|
|
names += Tag.visible(tc.guardian).where(id: ids).pluck(:name) if ids.present?
|
|
names
|
|
end
|
|
elsif fields.has_key?(:tags)
|
|
[]
|
|
else
|
|
tc.topic.tags.map(&:name)
|
|
end
|
|
if new_category &&
|
|
!DiscourseTagging.validate_category_tags(tc.guardian, tc.topic, new_category, tags)
|
|
tc.check_result(false)
|
|
next
|
|
end
|
|
|
|
tc.record_change("category_id", current_category&.id, new_category&.id)
|
|
tc.check_result(tc.topic.change_category_to_id(new_category_id, silent: tc.silent))
|
|
create_small_action_for_category_change(
|
|
topic: tc.topic,
|
|
user: tc.user,
|
|
old_category: current_category,
|
|
new_category: new_category,
|
|
)
|
|
end
|
|
end
|
|
|
|
track_topic_field(:tags) { |tc, tags| tc.apply_tag_changes(tags) }
|
|
|
|
track_topic_field(:featured_link) do |topic_changes, featured_link|
|
|
if !SiteSetting.topic_featured_link_enabled ||
|
|
!topic_changes.guardian.can_edit_featured_link?(topic_changes.topic.category_id)
|
|
topic_changes.check_result(false)
|
|
else
|
|
topic_changes.record_change("featured_link", topic_changes.topic.featured_link, featured_link)
|
|
topic_changes.topic.featured_link = featured_link
|
|
end
|
|
end
|
|
|
|
def self.create_small_action_for_category_change(topic:, user:, old_category:, new_category:)
|
|
if !old_category || !new_category || !SiteSetting.create_post_for_category_and_tag_changes ||
|
|
SiteSetting.whispers_allowed_groups.blank?
|
|
return
|
|
end
|
|
|
|
topic.add_moderator_post(
|
|
user,
|
|
I18n.t(
|
|
"topic_category_changed",
|
|
from: "##{old_category.slug_ref}",
|
|
to: "##{new_category.slug_ref}",
|
|
),
|
|
post_type: Post.types[:whisper],
|
|
action_code: "category_changed",
|
|
)
|
|
end
|
|
|
|
def self.create_small_action_for_tag_changes(topic:, user:, added_tags:, removed_tags:)
|
|
if !SiteSetting.create_post_for_category_and_tag_changes ||
|
|
SiteSetting.whispers_allowed_groups.blank?
|
|
return
|
|
end
|
|
|
|
topic.add_moderator_post(
|
|
user,
|
|
tags_changed_raw(added: added_tags, removed: removed_tags),
|
|
post_type: Post.types[:whisper],
|
|
action_code: "tags_changed",
|
|
custom_fields: {
|
|
tags_added: added_tags,
|
|
tags_removed: removed_tags,
|
|
},
|
|
)
|
|
end
|
|
|
|
def self.tags_changed_raw(added:, removed:)
|
|
if removed.present? && added.present?
|
|
I18n.t(
|
|
"topic_tag_changed.added_and_removed",
|
|
added: tag_list_to_raw(added),
|
|
removed: tag_list_to_raw(removed),
|
|
)
|
|
elsif added.present?
|
|
I18n.t("topic_tag_changed.added", added: tag_list_to_raw(added))
|
|
elsif removed.present?
|
|
I18n.t("topic_tag_changed.removed", removed: tag_list_to_raw(removed))
|
|
end
|
|
end
|
|
|
|
def self.tag_list_to_raw(tag_list)
|
|
tag_list.sort.map { |tag_name| "##{tag_name}" }.join(", ")
|
|
end
|
|
|
|
def self.tag_change_noop?(topic, incoming)
|
|
return topic.tags.empty? if incoming.blank?
|
|
|
|
ids =
|
|
if incoming.first.is_a?(String)
|
|
unique_names = incoming.map(&:downcase).uniq
|
|
found = Tag.where_name(unique_names).pluck(:id)
|
|
return false if found.size != unique_names.size
|
|
found
|
|
else
|
|
return false if incoming.any? { |t| t[:id].blank? }
|
|
incoming.filter_map { |t| t[:id]&.to_i }
|
|
end
|
|
return false if ids.blank?
|
|
|
|
canonical_ids = Tag.where(id: ids).pluck(Arel.sql("COALESCE(target_tag_id, id)")).uniq.sort
|
|
canonical_ids == topic.tags.pluck(:id).sort
|
|
end
|
|
|
|
# Revises a post with the given fields and options.
|
|
#
|
|
# @param editor [User] The user performing the revision
|
|
# @param fields [Hash] Hash of fields to update
|
|
# @param opts [Hash] Optional parameters for the revision
|
|
# @option opts [Time] :revised_at Changes the date of the revision
|
|
# @option opts [Boolean] :force_new_version Bypass grace period edit window
|
|
# @option opts [Boolean] :bypass_rate_limiter Bypass the max limits per day rate limiter
|
|
# @option opts [Boolean] :bypass_bump Do not bump the topic. Takes precedence over should_bump_topic plugin modifier, and any other should_bump? logic
|
|
# @option opts [Boolean] :skip_validations Ask ActiveRecord to skip validations
|
|
# @option opts [Boolean] :validate_post Validate the post, unless :skip_validations says otherwise
|
|
# @option opts [Boolean] :validate_topic Validate the topic, unless :skip_validations says otherwise
|
|
# @option opts [Boolean] :skip_revision Do not create a new PostRevision record
|
|
# @option opts [Boolean] :skip_staff_log Skip creating an entry in the staff action log
|
|
# @option opts [Boolean] :keep_existing_draft Do not advance the draft sequence, keeping any open draft
|
|
# @option opts [Boolean] :deleting_post Blanking content the author deleted, not a new edit
|
|
# @option opts [Boolean] :recovering_post Restoring content the author had deleted, not a new edit
|
|
# @option opts [Boolean] :silent Don't send notifications to user
|
|
# @option opts [Boolean] :hidden Force the created revision to be hidden from non-staff users
|
|
# @return [Boolean] Returns true if the revision was successful, false otherwise
|
|
def revise!(editor, fields, opts = {})
|
|
@editor = editor
|
|
@fields = fields.with_indifferent_access
|
|
@opts = opts
|
|
|
|
@topic_changes = TopicChanges.new(@topic, editor)
|
|
|
|
# some normalization
|
|
@fields[:raw] = cleanup_whitespaces(@fields[:raw]) if @fields.has_key?(:raw)
|
|
@fields[:user_id] = @fields[:user_id].to_i if @fields.has_key?(:user_id)
|
|
@fields[:category_id] = @fields[:category_id].to_i if @fields.has_key?(:category_id)
|
|
if @fields.has_key?(:post_type)
|
|
@fields[:post_type] = @fields[:post_type].to_i
|
|
return false unless validate_post_type
|
|
end
|
|
if @fields.has_key?(:tags) && PostRevisor.tag_change_noop?(@topic, @fields[:tags])
|
|
@fields.delete(:tags)
|
|
end
|
|
|
|
if @fields.has_key?(:reply_to_post_number)
|
|
normalized = @fields[:reply_to_post_number].presence
|
|
@fields[:reply_to_post_number] = normalized.nil? ? nil : normalized.to_i
|
|
return false unless resolve_reply_to_change
|
|
end
|
|
|
|
# always reset edit_reason unless provided, do not set to nil else
|
|
# previous reasons are lost
|
|
@fields.delete(:edit_reason) if @fields[:edit_reason].blank?
|
|
|
|
Post.plugin_permitted_update_params.each do |field, val|
|
|
val[:handler].call(@post, @fields[field]) if @fields.key?(field) && val[:plugin].enabled?
|
|
end
|
|
|
|
if !should_revise?
|
|
# the draft sequence is advanced here to handle the edge case where a
|
|
# user opens the composer to edit a post and makes some changes (which
|
|
# saves a draft), but then un-does the changes and clicks save. In this
|
|
# case, should_revise? returns false because nothing has really changed
|
|
# in the post, but we want to get rid of the draft so we advance the
|
|
# sequence.
|
|
advance_draft_sequence if !opts[:keep_existing_draft]
|
|
return false
|
|
end
|
|
|
|
@post.acting_user = @editor
|
|
@topic.acting_user = @editor
|
|
@revised_at = @opts[:revised_at] || Time.now
|
|
@last_version_at = @post.last_version_at || Time.now
|
|
|
|
if guardian.affected_by_slow_mode?(@topic) && !grace_period_edit? &&
|
|
SiteSetting.slow_mode_prevents_editing
|
|
@post.errors.add(:base, I18n.t("cannot_edit_on_slow_mode"))
|
|
return false
|
|
end
|
|
|
|
@version_changed = false
|
|
@post_successfully_saved = true
|
|
|
|
@validate_post = true
|
|
@validate_post = @opts[:validate_post] if @opts.has_key?(:validate_post)
|
|
@validate_post = !@opts[:skip_validations] if @opts.has_key?(:skip_validations)
|
|
|
|
@validate_topic = true
|
|
@validate_topic = @opts[:validate_topic] if @opts.has_key?(:validate_topic)
|
|
@validate_topic = !@opts[:skip_validations] if @opts.has_key?(:skip_validations)
|
|
|
|
@skip_revision = false
|
|
@skip_revision = @opts[:skip_revision] if @opts.has_key?(:skip_revision)
|
|
|
|
@silent = false
|
|
@silent = @opts[:silent] if @opts.has_key?(:silent)
|
|
@topic_changes.silent = @silent
|
|
|
|
@previous_last_editor_id = @post.last_editor_id
|
|
|
|
old_raw = @post.raw
|
|
|
|
@should_bump_topic = false
|
|
|
|
Post.transaction do
|
|
revise_post
|
|
|
|
yield if block_given?
|
|
# TODO: these callbacks are being called in a transaction
|
|
# it is kind of odd, because the callback is called "before_edit"
|
|
# but the post is already edited at this point
|
|
# Trouble is that much of the logic of should I edit? is deeper
|
|
# down so yanking this in front of the transaction will lead to
|
|
# false positive.
|
|
plugin_callbacks
|
|
|
|
@should_bump_topic = @version_changed && successfully_saved_post_and_topic && should_bump?
|
|
revise_topic
|
|
advance_draft_sequence if !opts[:keep_existing_draft]
|
|
|
|
raise ActiveRecord::Rollback if !successfully_saved_post_and_topic
|
|
end
|
|
|
|
# bail out if the post or topic failed to save
|
|
return false if !successfully_saved_post_and_topic
|
|
|
|
bump_topic if @should_bump_topic
|
|
|
|
# Lock the post by default if the appropriate setting is true
|
|
if SiteSetting.staff_edit_locks_post? && !@post.wiki? && @fields.has_key?("raw") &&
|
|
@editor.staff? && @editor != Discourse.system_user && !@post.user&.staff?
|
|
PostLocker.new(@post, @editor).lock
|
|
end
|
|
|
|
# We log staff/group moderator edits to posts
|
|
if (
|
|
@editor.staff? ||
|
|
(
|
|
@post.is_category_description? &&
|
|
guardian.can_edit_category_description?(@post.topic.category)
|
|
)
|
|
) && @editor.id != @post.user_id && @fields.has_key?("raw") && !@opts[:skip_staff_log]
|
|
StaffActionLogger.new(@editor).log_post_edit(@post, old_raw: old_raw)
|
|
end
|
|
|
|
# WARNING: do not pull this into the transaction
|
|
# it can fire events in sidekiq before the post is done saving
|
|
# leading to corrupt state
|
|
QuotedPost.extract_from(@post)
|
|
TopicLink.extract_from(@post)
|
|
|
|
# Skip heavy post processing operations if the only change was the post ownership (user merges)
|
|
only_user_id_changed =
|
|
if @fields.has_key?("user_id")
|
|
content_tracked_fields = POST_TRACKED_FIELDS - %w[user_id edit_reason]
|
|
content_tracked_fields.none? { |f| @post.previous_changes.has_key?(f) }
|
|
end
|
|
|
|
Topic.reset_highest(@topic.id) unless only_user_id_changed
|
|
post_process_post
|
|
alert_users
|
|
publish_changes
|
|
grant_badge
|
|
|
|
unless @opts[:recovering_post] || @opts[:skip_validations]
|
|
ReviewablePost.queue_for_media_review_if_possible(@post, @editor, previous_raw: old_raw)
|
|
end
|
|
ReviewablePost.queue_for_review_if_possible(@post, @editor) if should_create_new_version?
|
|
|
|
successfully_saved_post_and_topic
|
|
end
|
|
|
|
def cleanup_whitespaces(raw)
|
|
raw.present? ? TextCleaner.normalize_whitespaces(raw).rstrip : ""
|
|
end
|
|
|
|
def should_revise?
|
|
post_changed? || topic_changed?
|
|
end
|
|
|
|
def post_changed?
|
|
POST_TRACKED_FIELDS.each do |field|
|
|
return true if @fields.has_key?(field) && @fields[field] != @post.public_send(field)
|
|
end
|
|
false
|
|
end
|
|
|
|
def topic_changed?
|
|
PostRevisor.tracked_topic_fields.keys.any? { |f| @fields.has_key?(f) }
|
|
end
|
|
|
|
def revise_post
|
|
if should_create_new_version?
|
|
revise_and_create_new_version
|
|
else
|
|
unless cached_original_raw
|
|
self.original_raw = @post.raw
|
|
self.original_cooked = @post.cooked
|
|
end
|
|
revise
|
|
end
|
|
end
|
|
|
|
def should_create_new_version?
|
|
return false if @skip_revision
|
|
# topic-only changes (without post content changes) should always create a new version
|
|
# since the grace period concept doesn't apply to metadata changes like tags
|
|
if topic_changed? && !post_changed?
|
|
# Allow the same user to collapse a hidden tag-only change into their
|
|
# previous hidden revision (e.g. reverting); a different user's change must
|
|
# still create its own revision so authorship is not folded into theirs.
|
|
if only_hidden_tags_changed? && !edited_by_another_user? &&
|
|
PostRevision.where(post_id: @post.id, number: @post.version).pick(:hidden)
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
edited_by_another_user? || flagged? || !grace_period_edit? || owner_changed? ||
|
|
force_new_version? || edit_reason_specified?
|
|
end
|
|
|
|
def edit_reason_specified?
|
|
@fields[:edit_reason].present? && @fields[:edit_reason] != @post.edit_reason
|
|
end
|
|
|
|
def flagged?
|
|
@post.is_flagged?
|
|
end
|
|
|
|
def edited_by_another_user?
|
|
@post.last_editor_id != @editor.id
|
|
end
|
|
|
|
def original_raw_key
|
|
"original_raw_#{(@last_version_at.to_f * 1000).to_i}#{@post.id}"
|
|
end
|
|
|
|
def original_cooked_key
|
|
"original_cooked_#{(@last_version_at.to_f * 1000).to_i}#{@post.id}"
|
|
end
|
|
|
|
def cached_original_raw
|
|
@cached_original_raw ||= Discourse.redis.get(original_raw_key)
|
|
end
|
|
|
|
def cached_original_cooked
|
|
@cached_original_cooked ||= Discourse.redis.get(original_cooked_key)
|
|
end
|
|
|
|
def original_raw
|
|
cached_original_raw || @post.raw
|
|
end
|
|
|
|
def original_raw=(val)
|
|
@cached_original_raw = val
|
|
Discourse.redis.setex(original_raw_key, SiteSetting.editing_grace_period + 1, val)
|
|
end
|
|
|
|
def original_cooked=(val)
|
|
@cached_original_cooked = val
|
|
Discourse.redis.setex(original_cooked_key, SiteSetting.editing_grace_period + 1, val)
|
|
end
|
|
|
|
def diff_size(before, after)
|
|
@diff_size ||=
|
|
begin
|
|
ONPDiff.new(before, after).short_diff.sum { |str, type| type == :common ? 0 : str.size }
|
|
rescue ONPDiff::DiffLimitExceeded
|
|
Float::INFINITY
|
|
end
|
|
end
|
|
|
|
def grace_period_edit?
|
|
return false if (@revised_at - @last_version_at) > SiteSetting.editing_grace_period.to_i
|
|
|
|
if new_raw = @fields[:raw]
|
|
max_diff = SiteSetting.editing_grace_period_max_diff.to_i
|
|
if @editor.staff? || (@editor.trust_level > 1)
|
|
max_diff = SiteSetting.editing_grace_period_max_diff_high_trust.to_i
|
|
end
|
|
|
|
if (original_raw.size - new_raw.size).abs > max_diff ||
|
|
diff_size(original_raw, new_raw) > max_diff
|
|
return false
|
|
end
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def owner_changed?
|
|
@fields.has_key?(:user_id) && @fields[:user_id] != @post.user_id
|
|
end
|
|
|
|
def force_new_version?
|
|
@opts[:force_new_version] == true
|
|
end
|
|
|
|
def revise_and_create_new_version
|
|
@version_changed = true
|
|
@post.version += 1
|
|
|
|
@hidden_revision = @opts[:hidden] == true || only_hidden_tags_changed?
|
|
@post.public_version += 1 unless @hidden_revision
|
|
|
|
@post.last_version_at = @revised_at
|
|
|
|
revise
|
|
perform_edit if successfully_saved_post_and_topic
|
|
end
|
|
|
|
def revise
|
|
update_post
|
|
update_topic if topic_changed?
|
|
create_or_update_revision
|
|
remove_flags_and_unhide_post
|
|
end
|
|
|
|
USER_ACTIONS_TO_REMOVE = [UserAction::REPLY, UserAction::RESPONSE]
|
|
|
|
def update_post
|
|
if @fields.has_key?("user_id") && @fields["user_id"] != @post.user_id && @post.user_id != nil
|
|
prev_owner = User.find(@post.user_id)
|
|
new_owner = User.find(@fields["user_id"])
|
|
|
|
UserAction
|
|
.where(target_post_id: @post.id)
|
|
.where(user_id: prev_owner.id)
|
|
.where(action_type: USER_ACTIONS_TO_REMOVE)
|
|
.update_all(user_id: new_owner.id)
|
|
|
|
if @post.post_number == 1
|
|
UserAction
|
|
.where(target_topic_id: @post.topic_id)
|
|
.where(user_id: prev_owner.id)
|
|
.where(action_type: UserAction::NEW_TOPIC)
|
|
.update_all(user_id: new_owner.id)
|
|
end
|
|
end
|
|
|
|
POST_TRACKED_FIELDS.each do |field|
|
|
@post.public_send("#{field}=", @fields[field]) if @fields.has_key?(field)
|
|
end
|
|
|
|
@post.reply_to_user_id = @new_reply_to_parent&.user_id if @post.reply_to_post_number_changed?
|
|
|
|
@post.edit_reason = @fields[:edit_reason] if should_create_new_version?
|
|
@post.last_editor_id = @editor.id
|
|
@post.word_count = @fields[:raw].scan(/[[:word:]]+/).size if @fields.has_key?(:raw)
|
|
@post.self_edits += 1 if self_edit?
|
|
|
|
@post.extract_quoted_post_numbers
|
|
|
|
previous_reply_to_post_number = @post.reply_to_post_number_was
|
|
|
|
@post_successfully_saved = @post.save(validate: @validate_post)
|
|
@post_changes = @post.previous_changes.slice(*POST_TRACKED_FIELDS) if @post_successfully_saved
|
|
@post.link_post_uploads
|
|
|
|
if @post_successfully_saved
|
|
@post.save_reply_relationships
|
|
cleanup_previous_reply_to_relationship
|
|
if @post.saved_change_to_reply_to_post_number?
|
|
@post.nested_replies_apply_reparent(previous_reply_to_post_number)
|
|
end
|
|
end
|
|
|
|
# we don't want to increment post count on user merge
|
|
if @post_successfully_saved && @editor.id != Discourse::SYSTEM_USER_ID
|
|
@editor.increment_post_edits_count
|
|
end
|
|
|
|
# post owner changed
|
|
if prev_owner && new_owner && prev_owner != new_owner
|
|
likes =
|
|
UserAction
|
|
.where(target_post_id: @post.id)
|
|
.where(user_id: prev_owner.id)
|
|
.where(action_type: UserAction::WAS_LIKED)
|
|
.update_all(user_id: new_owner.id)
|
|
|
|
private_message = @topic.private_message?
|
|
|
|
prev_owner_user_stat = prev_owner.user_stat
|
|
|
|
unless private_message
|
|
UserStatCountUpdater.decrement!(@post, user_stat: prev_owner_user_stat) if !@post.trashed?
|
|
prev_owner_user_stat.likes_received -= likes
|
|
end
|
|
|
|
if @post.created_at == prev_owner.user_stat.first_post_created_at
|
|
prev_owner_user_stat.update!(
|
|
first_post_created_at: prev_owner.posts.order("created_at ASC").first.try(:created_at),
|
|
)
|
|
end
|
|
|
|
new_owner_user_stat = new_owner.user_stat
|
|
unless private_message
|
|
UserStatCountUpdater.increment!(@post, user_stat: new_owner_user_stat) if !@post.trashed?
|
|
new_owner_user_stat.likes_received += likes
|
|
end
|
|
new_owner_user_stat.save!
|
|
end
|
|
end
|
|
|
|
def self_edit?
|
|
@editor == @post.user
|
|
end
|
|
|
|
def remove_flags_and_unhide_post
|
|
return if @opts[:deleting_post]
|
|
return unless editing_a_flagged_and_hidden_post?
|
|
return if @post.awaiting_media_review?
|
|
|
|
flaggers = []
|
|
@post
|
|
.post_actions
|
|
.where(post_action_type_id: PostActionType.flag_types_without_additional_message.values)
|
|
.each do |action|
|
|
flaggers << action.user if action.user
|
|
action.remove_act!(Discourse.system_user)
|
|
end
|
|
|
|
@post.unhide!
|
|
PostActionNotifier.after_post_unhide(@post, flaggers)
|
|
end
|
|
|
|
def editing_a_flagged_and_hidden_post?
|
|
self_edit? && @post.hidden &&
|
|
@post.hidden_reason_id == Post.hidden_reasons[:flag_threshold_reached]
|
|
end
|
|
|
|
def update_topic
|
|
Topic.transaction do
|
|
PostRevisor.tracked_topic_fields.each do |f, cb|
|
|
if !@topic_changes.errored? && @fields.has_key?(f)
|
|
cb.call(@topic_changes, @fields[f], @fields)
|
|
end
|
|
end
|
|
|
|
unless @topic_changes.errored?
|
|
@topic_changes.check_result(@topic.save(validate: @validate_topic))
|
|
end
|
|
end
|
|
end
|
|
|
|
def create_or_update_revision
|
|
return if @skip_revision
|
|
# don't create an empty revision if something failed
|
|
return unless successfully_saved_post_and_topic
|
|
@version_changed ? create_revision : update_revision
|
|
end
|
|
|
|
def create_revision
|
|
modifications = post_changes.merge(topic_diff)
|
|
|
|
if use_cached_original_for_created_revision?
|
|
if modifications["raw"]
|
|
modifications["raw"][0] = cached_original_raw || modifications["raw"][0]
|
|
end
|
|
|
|
if modifications["cooked"]
|
|
modifications["cooked"][0] = cached_original_cooked || modifications["cooked"][0]
|
|
end
|
|
end
|
|
|
|
@post_revision =
|
|
PostRevision.new(
|
|
user_id: @post.last_editor_id,
|
|
post_id: @post.id,
|
|
number: @post.version,
|
|
modifications:,
|
|
hidden: @hidden_revision,
|
|
)
|
|
@post_revision.silent = @silent
|
|
@post_revision.save!
|
|
end
|
|
|
|
def use_cached_original_for_created_revision?
|
|
@previous_last_editor_id == @editor.id &&
|
|
!PostRevision.exists?(post_id: @post.id, number: @post.version - 1)
|
|
end
|
|
|
|
def update_revision
|
|
return unless revision = PostRevision.find_by(post_id: @post.id, number: @post.version)
|
|
revision.user_id = @post.last_editor_id
|
|
modifications = post_changes.merge(topic_diff)
|
|
|
|
modifications.each_key do |field|
|
|
if revision.modifications.has_key?(field)
|
|
old_value = revision.modifications[field][0]
|
|
new_value = modifications[field][1]
|
|
if old_value.to_s != new_value.to_s
|
|
revision.modifications[field] = [old_value, new_value]
|
|
else
|
|
revision.modifications.delete(field)
|
|
end
|
|
else
|
|
revision.modifications[field] = modifications[field]
|
|
end
|
|
end
|
|
# should probably do this before saving the post!
|
|
if revision.modifications.empty?
|
|
hidden = revision.hidden
|
|
revision.destroy
|
|
@post.last_editor_id =
|
|
PostRevision.where(post_id: @post.id).order(number: :desc).pick(:user_id) || @post.user_id
|
|
@post.version -= 1
|
|
@post.public_version -= 1 unless hidden
|
|
@post.save(validate: @validate_post)
|
|
else
|
|
revision.save
|
|
end
|
|
end
|
|
|
|
def post_changes
|
|
@post_changes || @post.previous_changes.slice(*POST_TRACKED_FIELDS)
|
|
end
|
|
|
|
def topic_diff
|
|
@topic_changes.diff.with_indifferent_access
|
|
end
|
|
|
|
def perform_edit
|
|
return if bypass_rate_limiter?
|
|
EditRateLimiter.new(@editor).performed!
|
|
end
|
|
|
|
def bypass_rate_limiter?
|
|
@opts[:bypass_rate_limiter] == true
|
|
end
|
|
|
|
def bump_topic
|
|
@topic.update_column(:bumped_at, Time.now)
|
|
TopicTrackingState.publish_muted(@topic)
|
|
TopicTrackingState.publish_unmuted(@topic)
|
|
TopicTrackingState.publish_latest(@topic)
|
|
end
|
|
|
|
def should_bump?
|
|
return false if @opts[:bypass_bump] == true
|
|
|
|
should_bump_topic_modifier_result =
|
|
DiscoursePluginRegistry.apply_modifier(
|
|
:should_bump_topic,
|
|
nil,
|
|
@post,
|
|
post_changes,
|
|
@topic_changes,
|
|
@editor,
|
|
)
|
|
return should_bump_topic_modifier_result if !should_bump_topic_modifier_result.nil?
|
|
|
|
return true if @post.is_first_post? && @post.wiki? && post_changes.any?
|
|
|
|
false
|
|
end
|
|
|
|
def only_hidden_tags_changed?
|
|
return false if post_changed?
|
|
|
|
changed_topic_fields = PostRevisor.tracked_topic_fields.keys.select { |f| @fields.key?(f) }
|
|
return false if changed_topic_fields != [:tags]
|
|
|
|
hidden_tag_names = DiscourseTagging.hidden_tag_names
|
|
return false if hidden_tag_names.blank?
|
|
|
|
new_tags = @fields[:tags] || []
|
|
current_tags = @topic.tags.map(&:name)
|
|
added_or_removed = (new_tags - current_tags) | (current_tags - new_tags)
|
|
|
|
(added_or_removed - hidden_tag_names).empty?
|
|
end
|
|
|
|
def plugin_callbacks
|
|
DiscourseEvent.trigger(:before_edit_post, @post, @fields)
|
|
DiscourseEvent.trigger(:validate_post, @post)
|
|
end
|
|
|
|
def revise_topic
|
|
return unless @post.is_first_post?
|
|
|
|
update_topic_excerpt
|
|
update_category_description
|
|
end
|
|
|
|
def update_topic_excerpt
|
|
@topic.update_excerpt(@post.excerpt_for_topic)
|
|
end
|
|
|
|
def update_category_description
|
|
return unless (category = Category.find_by(topic_id: @topic.id))
|
|
|
|
if @post.sync_category_description(category)
|
|
@category_changed = category
|
|
else
|
|
@post.errors.add(:base, I18n.t("category.errors.description_incomplete"))
|
|
end
|
|
end
|
|
|
|
def advance_draft_sequence
|
|
@post.advance_draft_sequence
|
|
end
|
|
|
|
def post_process_post
|
|
@post.invalidate_oneboxes = true
|
|
@post.trigger_post_process
|
|
DiscourseEvent.trigger(:post_edited, @post, topic_changed?, self)
|
|
end
|
|
|
|
def alert_users
|
|
return if @editor.id == Discourse::SYSTEM_USER_ID
|
|
Jobs.enqueue(:post_alert, post_id: @post.id)
|
|
end
|
|
|
|
def publish_changes
|
|
options =
|
|
if !topic_diff.empty? && !@topic_changes.errored?
|
|
{ reload_topic: true }
|
|
else
|
|
{}
|
|
end
|
|
|
|
DiscourseEvent.trigger(:before_post_publish_changes, post_changes, @topic_changes, options)
|
|
|
|
@post.publish_change_to_clients!(:revised, options)
|
|
end
|
|
|
|
def grant_badge
|
|
BadgeGranter.queue_badge_grant(Badge::Trigger::PostRevision, post: @post)
|
|
end
|
|
|
|
def successfully_saved_post_and_topic
|
|
@post_successfully_saved && !@topic_changes.errored?
|
|
end
|
|
|
|
def guardian
|
|
@guardian ||= Guardian.new(@editor)
|
|
end
|
|
|
|
def raw_changed?
|
|
@fields.has_key?(:raw) && @fields[:raw] != cached_original_raw && @post_successfully_saved
|
|
end
|
|
|
|
def topic_title_changed?
|
|
topic_changed? && @fields.has_key?(:title) && topic_diff.has_key?(:title) &&
|
|
!@topic_changes.errored?
|
|
end
|
|
|
|
def topic_category_changed?
|
|
topic_changed? && @fields.has_key?(:category_id) && topic_diff.has_key?(:category_id) &&
|
|
!@topic_changes.errored?
|
|
end
|
|
|
|
def topic_tags_changed?
|
|
topic_changed? && @fields.has_key?(:tags) && topic_diff.has_key?(:tags) &&
|
|
!@topic_changes.errored?
|
|
end
|
|
|
|
def reviewable_content_changed?
|
|
raw_changed? || topic_title_changed?
|
|
end
|
|
|
|
private
|
|
|
|
def validate_post_type
|
|
return true if self.class.valid_post_type?(@fields[:post_type])
|
|
|
|
@post.errors.add(:post_type, :invalid)
|
|
false
|
|
end
|
|
|
|
def resolve_reply_to_change
|
|
new_post_number = @fields[:reply_to_post_number]
|
|
|
|
# Resolve trashed prior parents too — their stale `PostReply` row and
|
|
# `reply_count` need cleanup on reparent.
|
|
@old_reply_to_parent =
|
|
if @post.reply_to_post_number.present?
|
|
Post.with_deleted.find_by(topic_id: @post.topic_id, post_number: @post.reply_to_post_number)
|
|
end
|
|
@new_reply_to_parent = nil
|
|
|
|
return true if new_post_number == @post.reply_to_post_number
|
|
|
|
if new_post_number.present?
|
|
@new_reply_to_parent =
|
|
Post.where(topic_id: @post.topic_id, post_number: new_post_number).first
|
|
|
|
unless valid_reply_to_parent?(@new_reply_to_parent)
|
|
@post.errors.add(:reply_to_post_number, I18n.t("post.errors.invalid_reply_to"))
|
|
return false
|
|
end
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def valid_reply_to_parent?(parent)
|
|
return false if parent.blank?
|
|
return false if parent.id == @post.id
|
|
return false if @post.post_number.present? && parent.post_number >= @post.post_number
|
|
return false unless guardian.can_see?(parent)
|
|
true
|
|
end
|
|
|
|
def cleanup_previous_reply_to_relationship
|
|
return unless @post.saved_change_to_reply_to_post_number?
|
|
return if @old_reply_to_parent.blank?
|
|
|
|
old_post_number = @old_reply_to_parent.post_number
|
|
still_referenced =
|
|
@post.reply_to_post_number == old_post_number ||
|
|
@post.quoted_post_numbers.include?(old_post_number)
|
|
return if still_referenced
|
|
|
|
deleted = PostReply.where(post_id: @old_reply_to_parent.id, reply_post_id: @post.id).delete_all
|
|
|
|
if deleted > 0 && Topic.visible_post_types.include?(@post.post_type)
|
|
Post.where(id: @old_reply_to_parent.id).update_all(
|
|
"reply_count = GREATEST(reply_count - 1, 0)",
|
|
)
|
|
end
|
|
end
|
|
end
|