ArtOfCode-/qpixel

View on GitHub
app/controllers/posts_controller.rb

Summary

Maintainability
F
4 days
Test Coverage
# rubocop:disable Metrics/ClassLength
class PostsController < ApplicationController
  before_action :authenticate_user!, except: [:document, :help_center, :show]
  before_action :set_post, only: [:toggle_comments, :feature, :lock, :unlock]
  before_action :set_scoped_post, only: [:change_category, :show, :edit, :update, :close, :reopen, :delete, :restore]
  before_action :verify_moderator, only: [:toggle_comments]
  before_action :edit_checks, only: [:edit, :update]
  before_action :unless_locked, only: [:edit, :update, :close, :reopen, :delete, :restore]

  def new
    @post_type = PostType.find(params[:post_type])
    @category = params[:category].present? ? Category.find(params[:category]) : nil
    @parent = Post.where(id: params[:parent]).first
    @post = Post.new(category: @category, post_type: @post_type, parent: @parent)

    if @post_type.has_parent? && @parent.nil?
      flash[:danger] = helpers.i18ns('posts.type_requires_parent', type: @post_type.name)
      redirect_back fallback_location: root_path
      return
    end

    if @post_type.has_category? && @category.nil? && @parent.nil?
      flash[:danger] = helpers.i18ns('posts.type_requires_category', type: @post_type.name)
      redirect_back fallback_location: root_path
      return
    end

    if ['HelpDoc', 'PolicyDoc'].include?(@post_type.name)
      check_permissions
      # return # uncomment if you add more code after this
    end
  end

  def create
    @post_type = PostType.find(params[:post][:post_type_id])
    @parent = Post.where(id: params[:parent]).first
    @category = if @post_type.has_category
                  if params[:post][:category_id].present?
                    Category.find(params[:post][:category_id])
                  elsif @parent.present?
                    @parent.category
                  end
                end || nil
    @post = Post.new(post_params.merge(user: current_user, body: helpers.post_markdown(:post, :body_markdown),
                                       category: @category, post_type: @post_type, parent: @parent))

    if @post_type.has_parent? && @parent.nil?
      flash[:danger] = helpers.i18ns('posts.type_requires_parent', type: @post_type.name)
      redirect_back fallback_location: root_path
      return
    end

    if @post_type.has_category? && @category.nil? && @parent.nil?
      flash[:danger] = helpers.i18ns('posts.type_requires_category', type: @post_type.name)
      redirect_back fallback_location: root_path
      return
    end

    if @category.present? && @category.min_trust_level.present? && @category.min_trust_level > current_user.trust_level
      @post.errors.add(:base, helpers.i18ns('posts.category_low_trust_level', category: @category.name))
      render :new, status: :forbidden
      return
    end

    if ['HelpDoc', 'PolicyDoc'].include?(@post_type.name) && !check_permissions
      return
    end

    level_name = @post_type.is_top_level? ? 'TopLevel' : 'SecondLevel'
    level_type_ids = @post_type.is_top_level? ? top_level_post_types : second_level_post_types
    recent_level_posts = Post.where(created_at: 24.hours.ago..DateTime.now, user: current_user)
                             .where(post_type_id: level_type_ids).count
    setting_name = current_user.privilege?('unrestricted') ? "RL_#{level_name}Posts" : "RL_NewUser#{level_name}Posts"
    max_posts = SiteSetting[setting_name]
    limit_msg = if current_user.privilege?('unrestricted')
                  helpers.i18ns('rate_limit.posts', count: max_posts, level: level_name.underscore.humanize.downcase)
                else
                  helpers.i18ns('rate_limit.new_user_posts',
                                count: max_posts, level: level_name.underscore.humanize.downcase)
                end

    if recent_level_posts >= max_posts
      @post.errors.add :base, limit_msg
      AuditLog.rate_limit_log(event_type: "#{level_name.underscore}_post", related: @category, user: current_user,
                              comment: "limit: #{max_posts}\n\npost:\n#{@post.attributes_print}")
      render :new, status: :forbidden
      return
    end

    if @post.save
      redirect_to helpers.generic_show_link(@post)
    else
      render :new, status: :bad_request
    end
  end

  def show
    if @post.parent_id.present?
      return redirect_to post_path(@post.parent_id)
    end

    if @post.deleted? && !current_user&.has_post_privilege?('flag_curate', @post)
      return not_found
    end

    @children = if current_user&.privilege?('flag_curate')
                  Post.where(parent_id: @post.id)
                else
                  Post.where(parent_id: @post.id).undeleted
                      .or(Post.where(parent_id: @post.id, user_id: current_user&.id))
                end.includes(:votes, :user, :comments, :license, :post_type)
                .user_sort({ term: params[:sort], default: Arel.sql('deleted ASC, score DESC, RAND()') },
                           score: Arel.sql('deleted ASC, score DESC, RAND()'), age: :created_at)
                .paginate(page: params[:page], per_page: 20)
  end

  def edit; end

  def update
    before = { body: @post.body_markdown, title: @post.title, tags: @post.tags }
    after_tags = if @post_type.has_category?
                   Tag.where(tag_set_id: @post.category.tag_set_id, name: params[:post][:tags_cache])
                 end
    body_rendered = helpers.post_markdown(:post, :body_markdown)
    new_tags_cache = params[:post][:tags_cache]&.reject(&:empty?)

    if edit_post_params.to_h.all? { |k, v| @post.send(k) == v }
      flash[:danger] = "No changes were saved because you didn't edit the post."
      return redirect_to post_path(@post)
    end

    if current_user.privilege?('edit_posts') || current_user.is_moderator || current_user == @post.user || \
       (@post_type.is_freely_editable && current_user.privilege?('unrestricted'))
      if @post.update(edit_post_params.merge(body: body_rendered,
                                             last_edited_at: DateTime.now, last_edited_by: current_user,
                                             last_activity: DateTime.now, last_activity_by: current_user))
        PostHistory.post_edited(@post, current_user, before: before[:body],
                                after: @post.body_markdown, comment: params[:edit_comment],
                                before_title: before[:title], after_title: @post.title,
                                before_tags: before[:tags], after_tags: after_tags)
        redirect_to post_path(@post)
      else
        render :edit, status: :bad_request
      end
    else
      new_user = !current_user.privilege?('unrestricted')
      rate_limit = SiteSetting["RL_#{new_user ? 'NewUser' : ''}SuggestedEdits"]
      recent_edits = SuggestedEdit.where(user: current_user, active: true).where('created_at > ?', 24.hours.ago).count
      if recent_edits >= rate_limit
        key = new_user ? 'rate_limit.new_user_suggested_edits' : 'rate_limit.suggested_edits'
        msg = helpers.i18ns key, count: rate_limit
        @post.errors.add :base, msg
        render :edit, status: :forbidden
      else
        data = {
          post: @post,
          user: current_user,
          body: body_rendered == @post.body ? nil : body_rendered,
          title: params[:post][:title] == @post.title ? nil : params[:post][:title],
          tags_cache: new_tags_cache == @post.tags_cache ? @post.tags_cache : new_tags_cache,
          body_markdown: params[:post][:body_markdown] == @post.body_markdown ? nil : params[:post][:body_markdown],
          comment: params[:edit_comment],
          active: true, accepted: false
        }
        edit = SuggestedEdit.new(data)
        if edit.save
          message = "Edit suggested on your #{@post_type.name.underscore.humanize.downcase}"
          if @post_type.has_parent
            message += " on '#{@post.parent.title}'"
          end
          @post.user.create_notification message, suggested_edit_path(edit)
          redirect_to post_path(@post)
        else
          @post.errors = edit.errors
          render :edit, status: :bad_request
        end
      end
    end
  end

  def close
    unless check_your_privilege('flag_close', nil, false)
      render json: { status: 'failed', message: helpers.ability_err_msg(:flag_close, 'close this post') },
             status: :forbidden
      return
    end

    if @post.closed
      render json: { status: 'failed', message: 'Cannot close a closed post.' }, status: :bad_request
      return
    end

    reason = CloseReason.find_by id: params[:reason_id]
    if reason.nil?
      render json: { status: 'failed', message: 'Close reason not found.' }, status: :not_found
      return
    end

    if reason.requires_other_post
      other = Post.find_by(id: params[:other_post])
      if other.nil? || !top_level_post_types.include?(other.post_type_id)
        render json: { status: 'failed', message: 'Invalid input for other post.' }, status: :bad_request
        return
      end

      duplicate_of = Question.find(params[:other_post])
    else
      duplicate_of = nil
    end

    if @post.update(closed: true, closed_by: current_user, closed_at: DateTime.now, last_activity: DateTime.now,
                    last_activity_by: current_user, close_reason: reason, duplicate_post: duplicate_of)
      PostHistory.question_closed(@post, current_user)
      render json: { status: 'success' }
    else
      render json: { status: 'failed', message: "Can't close this question right now. Try again later.",
                     errors: @post.errors.full_messages }
    end
  end

  def reopen
    unless check_your_privilege('flag_close', nil, false)
      flash[:danger] = helpers.ability_err_msg(:flag_close, 'reopen this post')
      redirect_to post_path(@post)
      return
    end

    unless @post.closed
      flash[:danger] = 'Cannot reopen an open post.'
      redirect_to post_path(@post)
      return
    end

    if @post.update(closed: false, closed_by: current_user, closed_at: DateTime.now,
                    last_activity: DateTime.now, last_activity_by: current_user,
                    close_reason: nil, duplicate_post: nil)
      PostHistory.question_reopened(@post, current_user)
    else
      flash[:danger] = "Can't reopen this post right now. Try again later."
    end
    redirect_to post_path(@post)
  end

  def delete
    unless check_your_privilege('flag_curate', @post, false)
      flash[:danger] = helpers.ability_err_msg(:flag_curate, 'delete this post')
      redirect_to post_path(@post)
      return
    end

    if @post.children.any? { |a| a.score >= 0.5 }
      flash[:danger] = 'This post cannot be deleted because it has responses.'
      redirect_to post_path(@post)
      return
    end

    if @post.deleted
      flash[:danger] = "Can't delete a deleted post."
      redirect_to post_path(@post)
      return
    end

    if @post.update(deleted: true, deleted_at: DateTime.now, deleted_by: current_user,
                    last_activity: DateTime.now, last_activity_by: current_user)
      PostHistory.post_deleted(@post, current_user)
      if @post.children.any?
        @post.children.update_all(deleted: true, deleted_at: DateTime.now, deleted_by_id: current_user.id,
                                  last_activity: DateTime.now, last_activity_by_id: current_user.id)
        histories = @post.children.map do |c|
          { post_history_type: PostHistoryType.find_by(name: 'post_deleted'), user: current_user, post: c,
            community: RequestContext.community }
        end
        PostHistory.create(histories)
      end
    else
      flash[:danger] = "Can't delete this post right now. Try again later."
    end

    redirect_to post_path(@post)
  end

  def restore
    unless check_your_privilege('flag_curate', @post, false)
      flash[:danger] = helpers.ability_err_msg(:flag_curate, 'restore this post')
      redirect_to post_path(@post)
      return
    end

    unless @post.deleted
      flash[:danger] = "Can't restore an undeleted post."
      redirect_to post_path(@post)
      return
    end

    if @post.deleted_by.is_moderator && !current_user.is_moderator
      flash[:danger] = 'You cannot restore this post deleted by a moderator.'
      redirect_to post_path(@post)
      return
    end

    deleted_at = @post.deleted_at
    if @post.update(deleted: false, deleted_at: nil, deleted_by: nil,
                    last_activity: DateTime.now, last_activity_by: current_user)
      PostHistory.post_undeleted(@post, current_user)
      restore_children = @post.children.where('deleted_at >= ?', deleted_at)
      restore_children.update_all(deleted: true, deleted_at: DateTime.now, deleted_by_id: current_user.id,
                                 last_activity: DateTime.now, last_activity_by_id: current_user.id)
      histories = restore_children.map do |c|
        { post_history_type: PostHistoryType.find_by(name: 'post_undeleted'), user: current_user, post: c,
          community: RequestContext.community }
      end
      PostHistory.create(histories)
    else
      flash[:danger] = "Can't restore this post right now. Try again later."
    end

    redirect_to post_path(@post)
  end

  def document
    @post = Post.unscoped.where(doc_slug: params[:slug], community_id: [RequestContext.community_id, nil]).first
    not_found && return if @post.nil?

    if @post&.help_category == '$Disabled'
      not_found
    end
    if @post&.help_category == '$Moderator' && !current_user&.is_moderator
      not_found
    end
  end

  def upload
    content_types = ActiveStorage::Variant::WEB_IMAGE_CONTENT_TYPES
    extensions = content_types.map { |ct| ct.gsub('image/', '') }
    unless helpers.valid_image?(params[:file])
      render json: { error: "Images must be one of #{extensions.join(', ')}" }, status: :bad_request
      return
    end
    @blob = ActiveStorage::Blob.create_after_upload!(io: params[:file], filename: params[:file].original_filename,
                                                     content_type: params[:file].content_type)
    render json: { link: uploaded_url(@blob.key) }
  end

  def help_center
    @posts = Post.where(post_type_id: [PolicyDoc.post_type_id, HelpDoc.post_type_id])
                 .or(Post.unscoped.where(post_type_id: [PolicyDoc.post_type_id, HelpDoc.post_type_id],
                                         community_id: nil))
                 .where(Arel.sql("posts.help_category IS NULL OR posts.help_category != '$Disabled'"))
                 .order(:help_ordering, :title)
                 .group_by(&:post_type_id)
                 .transform_values { |posts| posts.group_by { |p| p.help_category.presence } }
  end

  def change_category
    @target = Category.find params[:target_id]
    unless helpers.can_change_category(current_user, @target)
      render json: { success: false, errors: ["You don't have permission to make that change."] }, status: :forbidden
      return
    end

    unless @target.post_type_ids.include? @post.post_type_id
      render json: { success: false, errors: ["This post type is not allowed in the #{@target.name} category."] },
             status: :conflict
      return
    end

    before = @post.category
    @post.category = @target
    new_tags = @post.tags.map do |tag|
      existing = Tag.where(tag_set: @target.tag_set, name: tag.name).first
      existing.nil? ? Tag.create(tag_set: @target.tag_set, name: tag.name) : existing
    end
    @post.tags = new_tags
    @post.save
    AuditLog.action_audit(event_type: 'change_category', related: @post, user: current_user,
                          comment: "from <<#{before.id}>>\nto <<#{@target.id}>>")
    render json: { success: true }
  end

  def toggle_comments
    @post.update(comments_disabled: !@post.comments_disabled)
    if @post.comments_disabled && params[:delete_all_comments]
      @post.comments.update_all(deleted: true)
    end
    render json: { status: 'success', success: true }
  end

  def lock
    return not_found unless current_user.privilege? 'flag_curate'
    return not_found if @post.locked?

    length = params[:length].present? ? params[:length].to_i : nil
    if length
      if !current_user.is_moderator && length > 30
        length = 30
      end
      end_date = length.days.from_now
    elsif current_user.is_moderator
      end_date = nil
    else
      end_date = 7.days.from_now
    end

    @post.update locked: true, locked_by: current_user,
                 locked_at: DateTime.now, locked_until: end_date
    render json: { status: 'success', success: true }
  end

  def unlock
    return not_found(errors: ['no_privilege']) unless current_user.privilege? 'flag_curate'
    return not_found(errors: ['not_locked']) unless @post.locked?
    if @post.locked_by.is_moderator && !current_user.is_moderator
      return not_found(errors: ['locked_by_mod'])
    end

    @post.update locked: false, locked_by: nil,
                 locked_at: nil, locked_until: nil
    render json: { status: 'success', success: true }
  end

  def feature
    return not_found(errors: ['no_privilege']) unless current_user.is_moderator

    data = {
      label: @post.parent.nil? ? @post.title : @post.parent.title,
      link: helpers.generic_show_link(@post),
      post: @post,
      active: true,
      community: RequestContext.community
    }
    @link = PinnedLink.create data

    attr = @link.attributes_print
    AuditLog.moderator_audit(event_type: 'pinned_link_create', related: @link, user: current_user,
                             comment: "<<PinnedLink #{attr}>>\n(using moderator tools on post)")
    flash[:success] = 'Post has been featured. Due to caching, it may take some time until the changes apply.'
    render json: { status: 'success', success: true }
  end

  def save_draft
    key = "saved_post.#{current_user.id}.#{params[:path]}"
    saved_at = "saved_post_at.#{current_user.id}.#{params[:path]}"
    RequestContext.redis.set key, params[:post]
    RequestContext.redis.set saved_at, DateTime.now.iso8601
    RequestContext.redis.expire key, 86_400 * 7
    RequestContext.redis.expire saved_at, 86_400 * 7
    render json: { status: 'success', success: true, key: key }
  end

  def delete_draft
    key = "saved_post.#{current_user.id}.#{params[:path]}"
    saved_at = "saved_post_at.#{current_user.id}.#{params[:path]}"
    RequestContext.redis.del key, saved_at
    render json: { status: 'success', success: true }
  end

  private

  def permitted
    [:post_type_id, :category_id, :parent_id, :title, :body_markdown, :license_id,
     :doc_slug, :help_category, :help_ordering]
  end

  def post_params
    p = params.require(:post).permit(*permitted, tags_cache: [])
    p[:tags_cache] = p[:tags_cache]&.reject { |t| t.empty? }
    p
  end

  def edit_post_params
    p = params.require(:post).permit(*(permitted - [:license_id, :post_type_id, :category_id, :parent_id]),
                                     tags_cache: [])
    p[:tags_cache] = p[:tags_cache]&.reject { |t| t.empty? }
    p
  end

  def set_post
    @post = Post.unscoped.find(params[:id])
  end

  def set_scoped_post
    @post = Post.find(params[:id])
  end

  def check_permissions
    if @post.post_type_id == HelpDoc.post_type_id
      verify_moderator
    elsif @post.post_type_id == PolicyDoc.post_type_id
      verify_admin
    else
      not_found
    end
  end

  def edit_checks
    @category = @post.category
    @parent = @post.parent
    @post_type = @post.post_type

    if @post_type.has_parent? && @parent.nil?
      flash[:danger] = helpers.i18ns('posts.type_requires_parent', type: @post_type.name)
      redirect_back fallback_location: root_path
    end

    if !@post_type.is_public_editable && !(@post.user == current_user || current_user.is_moderator)
      flash[:danger] = helpers.i18ns('posts.not_public_editable')
      redirect_back fallback_location: root_path
    end
  end

  def unless_locked
    check_if_locked(@post)
  end
end
# rubocop:enable Metrics/ClassLength