* Add a failing test for comments index of deleted article After https://github.com/forem/forem/pull/15052 removed the (also broken) deleted commentable template and the redirect, requests for comments from deleted articles raise no method errors (initially trying to set the page title to "Comments for commentable.title" but the assumption that @commentable is non-nil is present in several other places. This test currently fails (by design). Either we want to update the controller so that comments from deleted articles are no longer viewable (returning not found, as we did prior to https://github.com/forem/forem/pull/5199 or making the view safe for the case where @commentable is nil and no @root_comment is present. These requests are happening quite frequently (@maestromac and I suspect the sitemap may contain these pages and crawlers are visiting the site from a published link), as evidenced by https://app.honeybadger.io/projects/66984/faults/81994265 * Add request spec for deleted article scenario Additionally, relabel the legacy spec (updated during #15052) to clarify we do not render the deleted_commentable_comment view (this is testing the comment.path, rather than the article.path/comments index The scenario with comment.path sets `@root_comment` in the controller, so does not trigger errors on the `@commentable` method calls. * Update view pass 1: get the errors to stop (need to check the rendered page is also usable, the comment tree is not shown when commentable is nil and this might be a huge usability/correctness issue). * extract logic from comments index to methods and update tests We no longer expect deleted article's comments index to render (should return 404), only direct links to comments of deleted articles. Only assign @article in the index for the view if it is in fact an article (not a podcast episode, it's possible `@root_comment` was for a podcast episode). * remove unneeded (and soon to be incorrect spec) No longer want or need to test for the case where root comment is nil and so is commentable. This was an exploratory spec (scaffolding) and can be removed. * Undo nil safety changes to comments index and fix typo in comments spec
321 lines
9.9 KiB
Ruby
321 lines
9.9 KiB
Ruby
class CommentsController < ApplicationController
|
|
before_action :set_comment, only: %i[update destroy]
|
|
before_action :set_cache_control_headers, only: [:index]
|
|
before_action :authenticate_user!, only: %i[preview create hide unhide]
|
|
after_action :verify_authorized
|
|
after_action only: %i[moderator_create admin_delete] do
|
|
Audit::Logger.log(:moderator, current_user, params.dup)
|
|
end
|
|
|
|
# GET /comments
|
|
# GET /comments.json
|
|
def index
|
|
skip_authorization
|
|
@on_comments_page = true
|
|
@comment = Comment.new
|
|
@podcast = Podcast.find_by(slug: params[:username])
|
|
|
|
@root_comment = Comment.find(params[:id_code].to_i(26)) if params[:id_code].present?
|
|
|
|
if @podcast
|
|
@user = @podcast
|
|
@commentable = @user.podcast_episodes.find_by(slug: params[:slug]) if @user.podcast_episodes
|
|
else
|
|
set_user
|
|
set_commentable
|
|
not_found unless comment_should_be_visible?
|
|
end
|
|
|
|
@commentable_type = @commentable.class.name if @commentable
|
|
|
|
set_surrogate_key_header "comments-for-#{@commentable.id}-#{@commentable_type}" if @commentable
|
|
end
|
|
# GET /comments/1
|
|
# GET /comments/1.json
|
|
# GET /comments/1/edit
|
|
|
|
def edit
|
|
@comment = Comment.find(params[:id_code].to_i(26))
|
|
authorize @comment
|
|
@parent_comment = @comment.parent
|
|
@commentable = @comment.commentable
|
|
end
|
|
|
|
# POST /comments
|
|
# POST /comments.json
|
|
def create
|
|
rate_limit!(rate_limit_to_use)
|
|
|
|
@comment = Comment.includes(user: :profile).new(permitted_attributes(Comment))
|
|
@comment.user_id = current_user.id
|
|
|
|
authorize @comment
|
|
permit_commentor
|
|
|
|
if @comment.save
|
|
checked_code_of_conduct = params[:checked_code_of_conduct].present? && !current_user.checked_code_of_conduct
|
|
current_user.update(checked_code_of_conduct: true) if checked_code_of_conduct
|
|
|
|
NotificationSubscription.create(
|
|
user: current_user, notifiable_id: @comment.id, notifiable_type: "Comment", config: "all_comments",
|
|
)
|
|
Notification.send_new_comment_notifications_without_delay(@comment)
|
|
Mention.create_all(@comment)
|
|
|
|
if @comment.invalid?
|
|
@comment.destroy
|
|
render json: { error: "comment already exists" }, status: :unprocessable_entity
|
|
return
|
|
end
|
|
|
|
render partial: "comments/comment", formats: :json
|
|
|
|
elsif (comment = Comment.where(
|
|
body_markdown: @comment.body_markdown,
|
|
commentable_id: @comment.commentable_id,
|
|
ancestry: @comment.ancestry,
|
|
)[1])
|
|
|
|
comment.destroy
|
|
render json: { error: "comment already exists" }, status: :unprocessable_entity
|
|
else
|
|
message = @comment.errors_as_sentence
|
|
render json: { error: message }, status: :unprocessable_entity
|
|
end
|
|
# See https://github.com/thepracticaldev/dev.to/pull/5485#discussion_r366056925
|
|
# for details as to why this is necessary
|
|
rescue ModerationUnauthorizedError => e
|
|
render json: { error: e.message }, status: :unprocessable_entity
|
|
rescue Pundit::NotAuthorizedError, RateLimitChecker::LimitReached
|
|
raise
|
|
rescue StandardError => e
|
|
skip_authorization
|
|
|
|
message = "There was an error in your markdown: #{e}"
|
|
render json: { error: message }, status: :unprocessable_entity
|
|
end
|
|
|
|
def moderator_create
|
|
return if rate_limiter.limit_by_action(:comment_creation)
|
|
|
|
response_template = ResponseTemplate.find(params[:response_template][:id])
|
|
authorize response_template, :moderator_create?
|
|
|
|
moderator = User.find(Settings::General.mascot_user_id)
|
|
@comment = Comment.new(permitted_attributes(Comment))
|
|
@comment.user_id = moderator.id
|
|
@comment.body_markdown = response_template.content
|
|
authorize @comment
|
|
permit_commentor
|
|
|
|
if @comment.save
|
|
Notification.send_new_comment_notifications_without_delay(@comment)
|
|
Mention.create_all(@comment)
|
|
|
|
render json: { status: "created", path: @comment.path }
|
|
elsif (@comment = Comment.where(body_markdown: @comment.body_markdown,
|
|
commentable_id: @comment.commentable.id,
|
|
ancestry: @comment.ancestry)[0])
|
|
render json: { status: "comment already exists" }, status: :conflict
|
|
else
|
|
render json: { status: @comment&.errors&.full_messages&.to_sentence }, status: :unprocessable_entity
|
|
end
|
|
rescue ModerationUnauthorizedError => e
|
|
render json: { error: e.message }, status: :unprocessable_entity
|
|
rescue StandardError => e
|
|
skip_authorization
|
|
|
|
message = "There was an error in your markdown: #{e}"
|
|
render json: { error: "error", status: message }, status: :unprocessable_entity
|
|
end
|
|
|
|
# PATCH/PUT /comments/1
|
|
# PATCH/PUT /comments/1.json
|
|
def update
|
|
authorize @comment
|
|
|
|
if @comment.update(permitted_attributes(@comment).merge(edited_at: Time.zone.now))
|
|
Mention.create_all(@comment)
|
|
|
|
# The following sets variables used in the index view. We render the
|
|
# index view directly to avoid having to redirect.
|
|
#
|
|
# Redirects lead to a race condition where we redirect to a cached view
|
|
# after updating data and we don't bust the cache fast enough before
|
|
# hitting the view, therefore stale content ends up being served from
|
|
# cache.
|
|
#
|
|
# https://github.com/forem/forem/issues/10338#issuecomment-693401481
|
|
@on_comments_page = true
|
|
@root_comment = @comment
|
|
@commentable = @comment.commentable
|
|
@commentable_type = @comment.commentable_type
|
|
|
|
case @commentable_type
|
|
when "PodcastEpisode"
|
|
@user = @commentable&.podcast
|
|
when "Article"
|
|
# user could be a user or an organization
|
|
@user = @commentable&.user
|
|
@article = @commentable
|
|
else
|
|
@user = @commentable&.user
|
|
end
|
|
|
|
render :index
|
|
else
|
|
@commentable = @comment.commentable
|
|
render :edit
|
|
end
|
|
rescue StandardError => e
|
|
@commentable = @comment.commentable
|
|
flash.now[:error] = "There was an error in your markdown: #{e}"
|
|
render :edit
|
|
end
|
|
|
|
# DELETE /comments/1
|
|
# DELETE /comments/1.json
|
|
def destroy
|
|
authorize @comment
|
|
if @comment.is_childless?
|
|
@comment.destroy
|
|
else
|
|
@comment.deleted = true
|
|
@comment.save!
|
|
end
|
|
redirect = @comment.commentable&.path || user_path(current_user)
|
|
# NOTE: Brakeman doesn't like redirecting to a path, because of a "possible
|
|
# unprotected redirect". Using URI.parse().path is the recommended workaround.
|
|
redirect_to URI.parse(redirect).path, notice: "Comment was successfully deleted."
|
|
end
|
|
|
|
def delete_confirm
|
|
@comment = Comment.find(params[:id_code].to_i(26))
|
|
authorize @comment
|
|
end
|
|
|
|
def preview
|
|
skip_authorization
|
|
begin
|
|
permitted_body_markdown = permitted_attributes(Comment)[:body_markdown]
|
|
fixed_body_markdown = MarkdownProcessor::Fixer::FixForPreview.call(permitted_body_markdown)
|
|
parsed_markdown = MarkdownProcessor::Parser.new(fixed_body_markdown, source: Comment.new, user: current_user)
|
|
processed_html = parsed_markdown.finalize
|
|
rescue StandardError => e
|
|
processed_html = "<p>😔 There was an error in your markdown</p><hr><p>#{e}</p>"
|
|
end
|
|
respond_to do |format|
|
|
format.json { render json: { processed_html: processed_html }, status: :ok }
|
|
end
|
|
end
|
|
|
|
def settings
|
|
@comment = Comment.find(params[:id_code].to_i(26))
|
|
authorize @comment
|
|
@notification_subscription = NotificationSubscription.find_or_initialize_by(
|
|
user_id: @comment.user_id,
|
|
notifiable_id: @comment.id,
|
|
notifiable_type: "Comment",
|
|
config: "all_comments",
|
|
)
|
|
render :settings
|
|
end
|
|
|
|
def hide
|
|
@comment = Comment.find(params[:comment_id])
|
|
authorize @comment
|
|
@comment.hidden_by_commentable_user = true
|
|
@comment&.commentable&.update_column(:any_comments_hidden, true)
|
|
|
|
if @comment.save
|
|
render json: { hidden: "true" }, status: :ok
|
|
else
|
|
render json: { errors: @comment.errors_as_sentence, status: 422 }, status: :unprocessable_entity
|
|
end
|
|
end
|
|
|
|
def unhide
|
|
@comment = Comment.find(params[:comment_id])
|
|
authorize @comment
|
|
@comment.hidden_by_commentable_user = false
|
|
if @comment.save
|
|
@commentable = @comment&.commentable
|
|
@commentable&.update_columns(
|
|
any_comments_hidden: @commentable.comments.pluck(:hidden_by_commentable_user).include?(true),
|
|
)
|
|
render json: { hidden: "false" }, status: :ok
|
|
else
|
|
render json: { errors: @comment.errors_as_sentence, status: 422 }, status: :unprocessable_entity
|
|
end
|
|
end
|
|
|
|
def admin_delete
|
|
@comment = Comment.find(params[:comment_id])
|
|
authorize @comment
|
|
@comment.deleted = true
|
|
|
|
if @comment.save
|
|
redirect_url = @comment.commentable&.path
|
|
if redirect_url
|
|
flash[:success] = "Comment was successfully deleted."
|
|
redirect_to URI.parse(redirect_url).path
|
|
else
|
|
redirect_to_comment_path
|
|
end
|
|
else
|
|
redirect_to_comment_path
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def comment_should_be_visible?
|
|
if @article
|
|
@article.published?
|
|
else
|
|
@root_comment
|
|
end
|
|
end
|
|
|
|
def set_user
|
|
@user = User.find_by(username: params[:username]) ||
|
|
Organization.find_by(slug: params[:username]) ||
|
|
not_found
|
|
end
|
|
|
|
def set_commentable
|
|
@commentable = @root_comment&.commentable ||
|
|
@user.articles.find_by(slug: params[:slug]) || nil
|
|
@article = @commentable if @commentable.is_a?(Article)
|
|
end
|
|
|
|
# Use callbacks to share common setup or constraints between actions.
|
|
def set_comment
|
|
@comment = Comment.find(params[:id])
|
|
end
|
|
|
|
def redirect_to_comment_path
|
|
flash[:error] = "Something went wrong; Comment NOT deleted."
|
|
redirect_to "#{@comment.path}/mod"
|
|
end
|
|
|
|
def rate_limit_to_use
|
|
if current_user.decorate.considered_new?
|
|
:comment_antispam_creation
|
|
else
|
|
:comment_creation
|
|
end
|
|
end
|
|
|
|
def permit_commentor
|
|
return unless user_blocked?
|
|
|
|
raise ModerationUnauthorizedError, "Not allowed due to moderation action"
|
|
end
|
|
|
|
def user_blocked?
|
|
return false if current_user.blocked_by_count.zero?
|
|
|
|
UserBlock.blocking?(@comment.commentable.user_id, current_user.id)
|
|
end
|
|
end
|