discourse/app/controllers/posts_controller.rb
Loïc Guitaut a5fbb90df4 FEATURE: Display pending posts on user’s page
Currently when a user creates posts that are moderated (for whatever
reason), a popup is displayed saying the post needs approval and the
total number of the user’s pending posts. But then this piece of
information is kind of lost and there is nowhere for the user to know
what are their pending posts or how many there are.

This patch solves this issue by adding a new “Pending” section to the
user’s activity page when there are some pending posts to display. When
there are none, then the “Pending” section isn’t displayed at all.
2021-11-29 10:26:33 +01:00

864 lines
26 KiB
Ruby

# frozen_string_literal: true
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, :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(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?(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
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
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
# 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
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(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