discourse/app/controllers/posts_controller.rb
Sam Saffron 30990006a9 DEV: enable frozen string literal on all files
This reduces chances of errors where consumers of strings mutate inputs
and reduces memory usage of the app.

Test suite passes now, but there may be some stuff left, so we will run
a few sites on a branch prior to merging
2019-05-13 09:31:32 +08:00

816 lines
25 KiB
Ruby

# frozen_string_literal: true
require_dependency 'new_post_manager'
require_dependency 'post_creator'
require_dependency 'post_action_destroyer'
require_dependency 'post_destroyer'
require_dependency 'post_merger'
require_dependency 'distributed_memoizer'
require_dependency 'new_post_result_serializer'
require_dependency 'post_locker'
class PostsController < ApplicationController
requires_login except: [
:show,
:replies,
:by_number,
:by_date,
:short_link,
:reply_history,
:replyIids,
: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
]
def markdown_id
markdown Post.find(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
else
markdown Post.find_by(topic_id: params[:topic_id].to_i, post_number: (params[:post_number] || 1).to_i)
end
end
def markdown(post)
if post && guardian.can_see?(post)
render plain: post.raw
else
raise Discourse::NotFound
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)
.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)
.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
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(params[:post_id].to_i)
# Stuff the user in the request object, because that's what IncomingLink wants
if params[:user_id]
user = User.find(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 = 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?
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]
}
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)
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.whitelisted_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
unless current_user.staff?
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
guardian.ensure_can_delete!(post)
destroyer = PostDestroyer.new(current_user, post, context: params[:context])
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
unless current_user.staff?
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
guardian.ensure_can_recover_post!(post)
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)
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
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.whitelisted_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
raise Discourse::NotFound unless guardian.is_staff?
post = find_post_from_params
if params[:notice].present?
post.custom_fields["notice_type"] = Post.notices[:custom]
post.custom_fields["notice_args"] = params[:notice]
post.save_custom_fields
else
post.delete_post_notices
end
render body: nil
end
def bookmark
if params[:bookmarked] == "true"
post = find_post_from_params
result = PostActionCreator.create(current_user, post, :bookmark)
return render_json_error(result) if result.failed?
else
post_action = PostAction.find_by(post_id: params[:post_id], user_id: current_user.id)
raise Discourse::NotFound unless post_action
post = Post.with_deleted.find_by(id: post_action&.post_id)
raise Discourse::NotFound unless post
result = PostActionDestroyer.destroy(current_user, post, :bookmark)
return render_json_error(result) if result.failed?
end
topic_user = TopicUser.get(post.topic, current_user)
render_json_dump(topic_bookmarked: topic_user.try(:bookmarked))
end
def wiki
post = find_post_from_params
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
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
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
protected
# 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,
:target_usernames,
:reply_to_post_number,
:auto_track,
:typing_duration_msecs,
:composer_open_duration_msecs,
:visible
]
Post.plugin_permitted_create_params.each do |key, plugin|
permitted << key if plugin.enabled?
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
end
result = params.permit(*permitted).tap do |whitelisted|
whitelisted[:image_sizes] = params[:image_sizes]
# TODO this does not feel right, we should name what meta_data is allowed
whitelisted[: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 usernames = result[:target_usernames]
usernames = usernames.split(",")
groups = Group.messageable(current_user).where('name in (?)', usernames).pluck('name')
usernames -= groups
emails = usernames.select { |user| user.match(/@/) }
usernames -= emails
result[:target_usernames] = usernames.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)
# Include deleted posts if the user is staff
finder = finder.with_deleted if current_user.try(:staff?)
post = finder.first
raise Discourse::NotFound unless post
# load deleted topic
post.topic = Topic.with_deleted.find(post.topic_id) if current_user.try(:staff?)
raise Discourse::NotFound unless post.topic
guardian.ensure_can_see!(post)
post
end
end