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 @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 @root_comment # 404 for all low-quality for not signed in not_found if @root_comment.score < Comment::LOW_QUALITY_THRESHOLD && !user_signed_in? # 404 only for < -400 w/o children for signed in not_found if @root_comment.score < Comment::HIDE_THRESHOLD && !@root_comment.has_children? end if @podcast @user = @podcast @commentable = @user.podcast_episodes.find_by(slug: params[:slug]) if @user.podcast_episodes else set_user set_commentable @discussion_lock = @commentable.discussion_lock if @commentable.is_a?(Article) 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 = CommentCreator.build_comment(permitted_attributes(Comment), current_user: current_user) # authorize & permit depend on @comment authorize @comment permit_commenter if @comment.save if @comment.invalid? render json: { error: I18n.t("comments_controller.create.failure") }, status: :unprocessable_entity return end record_feed_event 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: I18n.t("comments_controller.create.failure") }, status: :unprocessable_entity else message = @comment.errors_as_sentence render json: { error: message }, status: :unprocessable_entity end # See https://github.com/forem/forem/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 => e message = I18n.t("comments_controller.create.authorization_error", error: e) render json: { error: message }, status: :unauthorized rescue RateLimitChecker::LimitReached raise rescue StandardError => e skip_authorization message = I18n.t("comments_controller.markdown", error: 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, :use_template_for_moderator_comment? 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 if @comment.save Notification.send_new_comment_notifications_without_delay(@comment) Mention.create_all(@comment) render json: { status: I18n.t("comments_controller.create.success"), path: @comment.path } elsif (@comment = Comment.where(body_markdown: @comment.body_markdown, commentable_id: @comment.commentable.id, ancestry: @comment.ancestry)[0]) render json: { status: I18n.t("comments_controller.create.failure") }, status: :conflict else render json: { status: @comment&.errors&.full_messages&.to_sentence }, status: :unprocessable_entity end rescue StandardError => e skip_authorization message = I18n.t("comments_controller.markdown", error: 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 @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 flash.now[:error] = I18n.t("comments_controller.markdown", error: @commentable.errors_as_sentence) render :edit end rescue StandardError => e @commentable = @comment.commentable flash.now[:error] = I18n.t("comments_controller.markdown", error: 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 Addressable::URI.parse(redirect).path, notice: I18n.t("comments_controller.delete.notice") 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] renderer = ContentRenderer.new(permitted_body_markdown, source: self, user: current_user) processed_html = renderer.process.processed_html rescue StandardError => e processed_html = I18n.t("comments_controller.markdown_html", error: e) 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 success = @comment.update(hidden_by_commentable_user: true) if success @comment&.commentable&.update_column(:any_comments_hidden, true) if params[:hide_children] == "1" @comment.descendants.includes(:user, :commentable).find_each do |c| c.update(hidden_by_commentable_user: true) end end 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] = I18n.t("comments_controller.delete.notice") redirect_to Addressable::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 record_feed_event article = @comment.commentable if @comment.commentable.is_a?(Article) return unless article FeedEvent.record_journey_for(current_user, article: article, category: :comment) 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] = I18n.t("comments_controller.delete.error") 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_commenter return unless user_blocked? raise ModerationUnauthorizedError, I18n.t("comments_controller.moderated") end def user_blocked? return false if current_user.blocked_by_count.zero? blocked_by_commentable_author = UserBlock.blocking?(@comment.commentable.user_id, current_user.id) blocked_by_comment_author = @comment.parent && UserBlock.blocking?(@comment.parent.user_id, current_user.id) blocked_by_commentable_author || blocked_by_comment_author || blocker_upthread?(@comment.parent) end def blocker_upthread?(comment) return false unless comment&.parent thread_authors_ids = comment.ancestors.pluck(:user_id) UserBlock.exists?(blocker_id: thread_authors_ids, blocked_id: current_user.id) end end