discourse/app/controllers/posts_controller.rb

889 lines
27 KiB
Ruby

# frozen_string_literal: true
class PostsController < ApplicationController
requires_login except: [
:show,
:replies,
:by_number,
:by_date,
:short_link,
:reply_history,
:reply_ids,
:revisions,
:latest_revision,
:expand_embed,
:markdown_id,
:markdown_num,
:cooked,
:latest,
:user_posts_feed
]
skip_before_action :preload_json, :check_xhr, only: [
:markdown_id,
:markdown_num,
:short_link,
:latest,
:user_posts_feed
]
MARKDOWN_TOPIC_PAGE_SIZE ||= 100
def markdown_id
markdown Post.find_by(id: params[:id].to_i)
end
def markdown_num
if params[:revision].present?
post_revision = find_post_revision_from_topic_id
render plain: post_revision.modifications[:raw].last
elsif params[:post_number].present?
markdown Post.find_by(topic_id: params[:topic_id].to_i, post_number: params[:post_number].to_i)
else
opts = params.slice(:page)
opts[:limit] = MARKDOWN_TOPIC_PAGE_SIZE
topic_view = TopicView.new(params[:topic_id], current_user, opts)
content = topic_view.posts.map do |p|
<<~MD
#{p.user.username} | #{p.updated_at} | ##{p.post_number}
#{p.raw}
-------------------------
MD
end
render plain: content.join
end
end
def latest
params.permit(:before)
last_post_id = params[:before].to_i
last_post_id = Post.last.id if last_post_id <= 0
if params[:id] == "private_posts"
raise Discourse::NotFound if current_user.nil?
posts = Post.private_posts
.order(created_at: :desc)
.where('posts.id <= ?', last_post_id)
.where('posts.id > ?', last_post_id - 50)
.includes(topic: :category)
.includes(user: [:primary_group, :flair_group])
.includes(:reply_to_user)
.limit(50)
rss_description = I18n.t("rss_description.private_posts")
else
posts = Post.public_posts
.order(created_at: :desc)
.where('posts.id <= ?', last_post_id)
.where('posts.id > ?', last_post_id - 50)
.includes(topic: :category)
.includes(user: [:primary_group, :flair_group])
.includes(:reply_to_user)
.limit(50)
rss_description = I18n.t("rss_description.posts")
end
# Remove posts the user doesn't have permission to see
# This isn't leaking any information we weren't already through the post ID numbers
posts = posts.reject { |post| !guardian.can_see?(post) || post.topic.blank? }
counts = PostAction.counts_for(posts, current_user)
respond_to do |format|
format.rss do
@posts = posts
@title = "#{SiteSetting.title} - #{rss_description}"
@link = Discourse.base_url
@description = rss_description
render 'posts/latest', formats: [:rss]
end
format.json do
render_json_dump(serialize_data(posts,
PostSerializer,
scope: guardian,
root: params[:id],
add_raw: true,
add_title: true,
all_post_actions: counts)
)
end
end
end
def user_posts_feed
params.require(:username)
user = fetch_user_from_params
raise Discourse::NotFound unless guardian.can_see_profile?(user)
posts = Post.public_posts
.where(user_id: user.id)
.where(post_type: Post.types[:regular])
.order(created_at: :desc)
.includes(:user)
.includes(topic: :category)
.limit(50)
posts = posts.reject { |post| !guardian.can_see?(post) || post.topic.blank? }
respond_to do |format|
format.rss do
@posts = posts
@title = "#{SiteSetting.title} - #{I18n.t("rss_description.user_posts", username: user.username)}"
@link = "#{Discourse.base_url}/u/#{user.username}/activity"
@description = I18n.t("rss_description.user_posts", username: user.username)
render 'posts/latest', formats: [:rss]
end
format.json do
render_json_dump(serialize_data(posts,
PostSerializer,
scope: guardian,
add_excerpt: true)
)
end
end
end
def cooked
render json: { cooked: find_post_from_params.cooked }
end
def raw_email
params.require(:id)
post = Post.unscoped.find(params[:id].to_i)
guardian.ensure_can_view_raw_email!(post)
text, html = Email.extract_parts(post.raw_email)
render json: { raw_email: post.raw_email, text_part: text, html_part: html }
end
def short_link
post = Post.find_by(id: params[:post_id].to_i)
raise Discourse::NotFound unless post
# Stuff the user in the request object, because that's what IncomingLink wants
if params[:user_id]
user = User.find_by(id: params[:user_id].to_i)
request['u'] = user.username_lower if user
end
guardian.ensure_can_see!(post)
redirect_to path(post.url)
end
def create
@manager_params = create_params
@manager_params[:first_post_checks] = !is_api?
@manager_params[:advance_draft] = !is_api?
manager = NewPostManager.new(current_user, @manager_params)
if is_api?
memoized_payload = DistributedMemoizer.memoize(signature_for(@manager_params), 120) do
result = manager.perform
MultiJson.dump(serialize_data(result, NewPostResultSerializer, root: false))
end
parsed_payload = JSON.parse(memoized_payload)
backwards_compatible_json(parsed_payload, parsed_payload['success'])
else
result = manager.perform
json = serialize_data(result, NewPostResultSerializer, root: false)
backwards_compatible_json(json, result.success?)
end
end
def update
params.require(:post)
post = Post.where(id: params[:id])
post = post.with_deleted if guardian.is_staff?
post = post.first
raise Discourse::NotFound if post.blank?
post.image_sizes = params[:image_sizes] if params[:image_sizes].present?
if !guardian.public_send("can_edit?", post) &&
post.user_id == current_user.id &&
post.edit_time_limit_expired?(current_user)
return render_json_error(I18n.t('too_late_to_edit'))
end
guardian.ensure_can_edit!(post)
changes = {
raw: params[:post][:raw],
edit_reason: params[:post][:edit_reason]
}
Post.plugin_permitted_update_params.keys.each do |param|
changes[param] = params[:post][param]
end
raw_old = params[:post][:raw_old]
if raw_old.present? && raw_old != post.raw
return render_json_error(I18n.t('edit_conflict'), status: 409)
end
# to stay consistent with the create api, we allow for title & category changes here
if post.is_first_post?
changes[:title] = params[:title] if params[:title]
changes[:category_id] = params[:post][:category_id] if params[:post][:category_id]
if changes[:category_id] && changes[:category_id].to_i != post.topic.category_id.to_i
category = Category.find_by(id: changes[:category_id])
if category || (changes[:category_id].to_i == 0)
guardian.ensure_can_move_topic_to_category!(category)
else
return render_json_error(I18n.t('category.errors.not_found'))
end
end
end
# We don't need to validate edits to small action posts by staff
opts = {}
if post.post_type == Post.types[:small_action] && current_user.staff?
opts[:skip_validations] = true
end
topic = post.topic
topic = Topic.with_deleted.find(post.topic_id) if guardian.is_staff?
revisor = PostRevisor.new(post, topic)
revisor.revise!(current_user, changes, opts)
return render_json_error(post) if post.errors.present?
return render_json_error(topic) if topic.errors.present?
post_serializer = PostSerializer.new(post, scope: guardian, root: false, add_raw: true)
post_serializer.draft_sequence = DraftSequence.current(current_user, topic.draft_key)
link_counts = TopicLink.counts_for(guardian, topic, [post])
post_serializer.single_post_link_counts = link_counts[post.id] if link_counts.present?
result = { post: post_serializer.as_json }
if revisor.category_changed.present?
result[:category] = BasicCategorySerializer.new(revisor.category_changed, scope: guardian, root: false).as_json
end
render_json_dump(result)
end
def show
post = find_post_from_params
display_post(post)
end
def by_number
post = find_post_from_params_by_number
display_post(post)
end
def by_date
post = find_post_from_params_by_date
display_post(post)
end
def reply_history
post = find_post_from_params
reply_history = post.reply_history(params[:max_replies].to_i, guardian)
user_custom_fields = {}
if (added_fields = User.allowed_user_custom_fields(guardian)).present?
user_custom_fields = User.custom_fields_for_ids(reply_history.pluck(:user_id), added_fields)
end
render_serialized(
reply_history,
PostSerializer,
user_custom_fields: user_custom_fields
)
end
def reply_ids
post = find_post_from_params
render json: post.reply_ids(guardian).to_json
end
def all_reply_ids
post = find_post_from_params
render json: post.reply_ids(guardian, only_replies_to_single_post: false).to_json
end
def destroy
post = find_post_from_params
force_destroy = false
if params[:force_destroy].present?
if !guardian.can_permanently_delete?(post)
return render_json_error post.cannot_permanently_delete_reason(current_user), status: 403
end
force_destroy = true
else
guardian.ensure_can_delete!(post)
end
unless guardian.can_moderate_topic?(post.topic)
RateLimiter.new(current_user, "delete_post_per_min", SiteSetting.max_post_deletions_per_minute, 1.minute).performed!
RateLimiter.new(current_user, "delete_post_per_day", SiteSetting.max_post_deletions_per_day, 1.day).performed!
end
destroyer = PostDestroyer.new(current_user, post, context: params[:context], force_destroy: force_destroy)
destroyer.destroy
render body: nil
end
def expand_embed
render json: { cooked: TopicEmbed.expanded_for(find_post_from_params) }
rescue
render_json_error I18n.t('errors.embed.load_from_remote')
end
def recover
post = find_post_from_params
guardian.ensure_can_recover_post!(post)
unless guardian.can_moderate_topic?(post.topic)
RateLimiter.new(current_user, "delete_post_per_min", SiteSetting.max_post_deletions_per_minute, 1.minute).performed!
RateLimiter.new(current_user, "delete_post_per_day", SiteSetting.max_post_deletions_per_day, 1.day).performed!
end
destroyer = PostDestroyer.new(current_user, post)
destroyer.recover
post.reload
render_post_json(post)
end
def destroy_many
params.require(:post_ids)
agree_with_first_reply_flag = (params[:agree_with_first_reply_flag] || true).to_s == "true"
posts = Post.where(id: post_ids_including_replies).order(:id)
raise Discourse::InvalidParameters.new(:post_ids) if posts.blank?
# Make sure we can delete the posts
posts.each { |p| guardian.ensure_can_delete!(p) }
Post.transaction do
posts.each_with_index do |p, i|
PostDestroyer.new(current_user, p, defer_flags: !(agree_with_first_reply_flag && i == 0)).destroy
end
end
render body: nil
end
def merge_posts
params.require(:post_ids)
posts = Post.where(id: params[:post_ids]).order(:id)
raise Discourse::InvalidParameters.new(:post_ids) if posts.pluck(:id) == params[:post_ids]
PostMerger.new(current_user, posts).merge
render body: nil
rescue PostMerger::CannotMergeError => e
render_json_error(e.message)
end
# Direct replies to this post
def replies
post = find_post_from_params
replies = post.replies.secured(guardian)
user_custom_fields = {}
if (added_fields = User.allowed_user_custom_fields(guardian)).present?
user_custom_fields = User.custom_fields_for_ids(replies.pluck(:user_id), added_fields)
end
render_serialized(replies, PostSerializer, user_custom_fields: user_custom_fields)
end
def revisions
post = find_post_from_params
raise Discourse::NotFound if post.hidden && !guardian.can_view_hidden_post_revisions?
post_revision = find_post_revision_from_params
post_revision_serializer = PostRevisionSerializer.new(post_revision, scope: guardian, root: false)
render_json_dump(post_revision_serializer)
end
def latest_revision
post = find_post_from_params
raise Discourse::NotFound if post.hidden && !guardian.can_view_hidden_post_revisions?
post_revision = find_latest_post_revision_from_params
post_revision_serializer = PostRevisionSerializer.new(post_revision, scope: guardian, root: false)
render_json_dump(post_revision_serializer)
end
def hide_revision
post_revision = find_post_revision_from_params
guardian.ensure_can_hide_post_revision!(post_revision)
post_revision.hide!
post = find_post_from_params
post.public_version -= 1
post.save
render body: nil
end
def show_revision
post_revision = find_post_revision_from_params
guardian.ensure_can_show_post_revision!(post_revision)
post_revision.show!
post = find_post_from_params
post.public_version += 1
post.save
render body: nil
end
def revert
raise Discourse::NotFound unless guardian.is_staff?
post_id = params[:id] || params[:post_id]
revision = params[:revision].to_i
raise Discourse::InvalidParameters.new(:revision) if revision < 2
post_revision = PostRevision.find_by(post_id: post_id, number: revision)
raise Discourse::NotFound unless post_revision
post = find_post_from_params
raise Discourse::NotFound if post.blank?
post_revision.post = post
guardian.ensure_can_see!(post_revision)
guardian.ensure_can_edit!(post)
return render_json_error(I18n.t('revert_version_same')) if post_revision.modifications["raw"].blank? && post_revision.modifications["title"].blank? && post_revision.modifications["category_id"].blank?
topic = Topic.with_deleted.find(post.topic_id)
changes = {}
changes[:raw] = post_revision.modifications["raw"][0] if post_revision.modifications["raw"].present? && post_revision.modifications["raw"][0] != post.raw
if post.is_first_post?
changes[:title] = post_revision.modifications["title"][0] if post_revision.modifications["title"].present? && post_revision.modifications["title"][0] != topic.title
changes[:category_id] = post_revision.modifications["category_id"][0] if post_revision.modifications["category_id"].present? && post_revision.modifications["category_id"][0] != topic.category.id
end
return render_json_error(I18n.t('revert_version_same')) unless changes.length > 0
changes[:edit_reason] = "reverted to version ##{post_revision.number.to_i - 1}"
revisor = PostRevisor.new(post, topic)
revisor.revise!(current_user, changes)
return render_json_error(post) if post.errors.present?
return render_json_error(topic) if topic.errors.present?
post_serializer = PostSerializer.new(post, scope: guardian, root: false)
post_serializer.draft_sequence = DraftSequence.current(current_user, topic.draft_key)
link_counts = TopicLink.counts_for(guardian, topic, [post])
post_serializer.single_post_link_counts = link_counts[post.id] if link_counts.present?
result = { post: post_serializer.as_json }
if post.is_first_post?
result[:topic] = BasicTopicSerializer.new(topic, scope: guardian, root: false).as_json if post_revision.modifications["title"].present?
result[:category_id] = post_revision.modifications["category_id"][0] if post_revision.modifications["category_id"].present?
end
render_json_dump(result)
end
def locked
post = find_post_from_params
locker = PostLocker.new(post, current_user)
params[:locked] === "true" ? locker.lock : locker.unlock
render_json_dump(locked: post.locked?)
end
def notice
post = find_post_from_params
raise Discourse::NotFound unless guardian.can_edit_staff_notes?(post.topic)
old_notice = post.custom_fields[Post::NOTICE]
if params[:notice].present?
post.custom_fields[Post::NOTICE] = {
type: Post.notices[:custom],
raw: params[:notice],
cooked: PrettyText.cook(params[:notice], features: { onebox: false })
}
else
post.custom_fields.delete(Post::NOTICE)
end
post.save_custom_fields
StaffActionLogger.new(current_user).log_post_staff_note(
post,
old_value: old_notice&.[]("raw"),
new_value: params[:notice]
)
render body: nil
end
def destroy_bookmark
params.require(:post_id)
bookmark_id = Bookmark.where(post_id: params[:post_id], user_id: current_user.id).pluck_first(:id)
result = BookmarkManager.new(current_user).destroy(bookmark_id)
render json: success_json.merge(result)
end
def wiki
post = find_post_from_params
params.require(:wiki)
guardian.ensure_can_wiki!(post)
post.revise(current_user, wiki: params[:wiki])
render body: nil
end
def post_type
guardian.ensure_can_change_post_type!
post = find_post_from_params
params.require(:post_type)
raise Discourse::InvalidParameters.new(:post_type) if Post.types[params[:post_type].to_i].blank?
post.revise(current_user, post_type: params[:post_type].to_i)
render body: nil
end
def rebake
guardian.ensure_can_rebake!
post = find_post_from_params
post.rebake!(invalidate_oneboxes: true, invalidate_broken_images: true)
render body: nil
end
def unhide
post = find_post_from_params
guardian.ensure_can_unhide!(post)
post.unhide!
render body: nil
end
def flagged_posts
Discourse.deprecate(
'PostsController#flagged_posts is deprecated. Please use /review instead.',
since: '2.8.0.beta4', drop_from: '2.9'
)
params.permit(:offset, :limit)
guardian.ensure_can_see_flagged_posts!
user = fetch_user_from_params
offset = [params[:offset].to_i, 0].max
limit = [(params[:limit] || 60).to_i, 100].min
posts = user_posts(guardian, user.id, offset: offset, limit: limit)
.where(id: PostAction.where(post_action_type_id: PostActionType.notify_flag_type_ids)
.where(disagreed_at: nil)
.select(:post_id))
render_serialized(posts, AdminUserActionSerializer)
end
def deleted_posts
params.permit(:offset, :limit)
guardian.ensure_can_see_deleted_posts!
user = fetch_user_from_params
offset = [params[:offset].to_i, 0].max
limit = [(params[:limit] || 60).to_i, 100].min
posts = user_posts(guardian, user.id, offset: offset, limit: limit).where.not(deleted_at: nil)
render_serialized(posts, AdminUserActionSerializer)
end
def pending
params.require(:username)
user = fetch_user_from_params
raise Discourse::NotFound unless guardian.can_edit_user?(user)
render_serialized(user.pending_posts.order(created_at: :desc), PendingPostSerializer, root: :pending_posts)
end
protected
def markdown(post)
if post && guardian.can_see?(post)
render plain: post.raw
else
raise Discourse::NotFound
end
end
# We can't break the API for making posts. The new, queue supporting API
# doesn't return the post as the root JSON object, but as a nested object.
# If a param is present it uses that result structure.
def backwards_compatible_json(json_obj, success)
json_obj.symbolize_keys!
if params[:nested_post].blank? && json_obj[:errors].blank? && json_obj[:action] != :enqueued
json_obj = json_obj[:post]
end
if !success && GlobalSetting.try(:verbose_api_logging) && (is_api? || is_user_api?)
Rails.logger.error "Error creating post via API:\n\n#{json_obj.inspect}"
end
render json: json_obj, status: (!!success) ? 200 : 422
end
def find_post_revision_from_params
post_id = params[:id] || params[:post_id]
revision = params[:revision].to_i
raise Discourse::InvalidParameters.new(:revision) if revision < 2
post_revision = PostRevision.find_by(post_id: post_id, number: revision)
raise Discourse::NotFound unless post_revision
post_revision.post = find_post_from_params
guardian.ensure_can_see!(post_revision)
post_revision
end
def find_latest_post_revision_from_params
post_id = params[:id] || params[:post_id]
finder = PostRevision.where(post_id: post_id).order(:number)
finder = finder.where(hidden: false) unless guardian.is_staff?
post_revision = finder.last
raise Discourse::NotFound unless post_revision
post_revision.post = find_post_from_params
guardian.ensure_can_see!(post_revision)
post_revision
end
def find_post_revision_from_topic_id
post = Post.find_by(topic_id: params[:topic_id].to_i, post_number: (params[:post_number] || 1).to_i)
raise Discourse::NotFound unless guardian.can_see?(post)
revision = params[:revision].to_i
raise Discourse::NotFound if revision < 2
post_revision = PostRevision.find_by(post_id: post.id, number: revision)
raise Discourse::NotFound unless post_revision
post_revision.post = post
guardian.ensure_can_see!(post_revision)
post_revision
end
private
def user_posts(guardian, user_id, opts)
posts = Post.includes(:user, :topic, :deleted_by, :user_actions)
.where(user_id: user_id)
.with_deleted
.order(created_at: :desc)
if guardian.user.moderator?
# Awful hack, but you can't seem to remove the `default_scope` when joining
# So instead I grab the topics separately
topic_ids = posts.dup.pluck(:topic_id)
topics = Topic.where(id: topic_ids).with_deleted.where.not(archetype: 'private_message')
topics = topics.secured(guardian)
posts = posts.where(topic_id: topics.pluck(:id))
end
posts.offset(opts[:offset])
.limit(opts[:limit])
end
def create_params
permitted = [
:raw,
:topic_id,
:archetype,
:category,
# TODO remove together with 'targetUsername' deprecations
:target_usernames,
:target_recipients,
:reply_to_post_number,
:auto_track,
:typing_duration_msecs,
:composer_open_duration_msecs,
:visible,
:draft_key
]
Post.plugin_permitted_create_params.each do |key, value|
if value[:plugin].enabled?
permitted << case value[:type]
when :string
key.to_sym
when :array
{ key => [] }
when :hash
{ key => {} }
end
end
end
# param munging for WordPress
params[:auto_track] = !(params[:auto_track].to_s == "false") if params[:auto_track]
params[:visible] = (params[:unlist_topic].to_s == "false") if params[:unlist_topic]
if is_api?
# php seems to be sending this incorrectly, don't fight with it
params[:skip_validations] = params[:skip_validations].to_s == "true"
permitted << :skip_validations
params[:import_mode] = params[:import_mode].to_s == "true"
permitted << :import_mode
# We allow `embed_url` via the API
permitted << :embed_url
# We allow `created_at` via the API
permitted << :created_at
# We allow `external_id` via the API
permitted << :external_id
end
result = params.permit(*permitted).tap do |allowed|
allowed[:image_sizes] = params[:image_sizes]
# TODO this does not feel right, we should name what meta_data is allowed
allowed[:meta_data] = params[:meta_data]
end
# Staff are allowed to pass `is_warning`
if current_user.staff?
params.permit(:is_warning)
result[:is_warning] = (params[:is_warning] == "true")
else
result[:is_warning] = false
end
if params[:no_bump] == "true"
raise Discourse::InvalidParameters.new(:no_bump) unless guardian.can_skip_bump?
result[:no_bump] = true
end
if params[:shared_draft] == 'true'
raise Discourse::InvalidParameters.new(:shared_draft) unless guardian.can_create_shared_draft?
result[:shared_draft] = true
end
if params[:whisper] == "true"
raise Discourse::InvalidAccess.new("invalid_whisper_access", nil, custom_message: "invalid_whisper_access") unless guardian.can_create_whisper?
result[:post_type] = Post.types[:whisper]
end
PostRevisor.tracked_topic_fields.each_key do |f|
params.permit(f => [])
result[f] = params[f] if params.has_key?(f)
end
# Stuff we can use in spam prevention plugins
result[:ip_address] = request.remote_ip
result[:user_agent] = request.user_agent
result[:referrer] = request.env["HTTP_REFERER"]
if recipients = result[:target_usernames]
Discourse.deprecate("`target_usernames` is deprecated, use `target_recipients` instead.", output_in_test: true, drop_from: '2.9.0')
else
recipients = result[:target_recipients]
end
if recipients
recipients = recipients.split(",").map(&:downcase)
groups = Group.messageable(current_user).where('lower(name) in (?)', recipients).pluck('lower(name)')
recipients -= groups
emails = recipients.select { |user| user.match(/@/) }
recipients -= emails
result[:target_usernames] = recipients.join(",")
result[:target_emails] = emails.join(",")
result[:target_group_names] = groups.join(",")
end
result.permit!
result.to_h
end
def signature_for(args)
+"post##" << Digest::SHA1.hexdigest(args
.to_h
.to_a
.concat([["user", current_user.id]])
.sort { |x, y| x[0] <=> y[0] }.join do |x, y|
"#{x}:#{y}"
end)
end
def display_post(post)
post.revert_to(params[:version].to_i) if params[:version].present?
render_post_json(post)
end
def find_post_from_params
by_id_finder = Post.where(id: params[:id] || params[:post_id])
find_post_using(by_id_finder)
end
def find_post_from_params_by_number
by_number_finder = Post.where(topic_id: params[:topic_id], post_number: params[:post_number])
find_post_using(by_number_finder)
end
def find_post_from_params_by_date
by_date_finder = TopicView.new(params[:topic_id], current_user)
.filtered_posts
.where("created_at >= ?", Time.zone.parse(params[:date]))
.order("created_at ASC")
.limit(1)
find_post_using(by_date_finder)
end
def find_post_using(finder)
# A deleted post can be seen by staff or a category group moderator for the topic.
# But we must find the deleted post to determine which category it belongs to, so
# we must find.with_deleted
post = finder.with_deleted.first
raise Discourse::NotFound unless post
post.topic = Topic.with_deleted.find_by(id: post.topic_id)
if !post.topic ||
(
(post.deleted_at.present? || post.topic.deleted_at.present?) &&
!guardian.can_moderate_topic?(post.topic)
)
raise Discourse::NotFound
end
guardian.ensure_can_see!(post)
post
end
end