Program: CWE-863
Project: CVE-2021-41082
Buggy source:
# frozen_string_literal: true

class TopicsController < ApplicationController
  requires_login only: [
    :timings,
    :destroy_timings,
    :update,
    :update_shared_draft,
    :destroy,
    :recover,
    :status,
    :invite,
    :mute,
    :unmute,
    :set_notifications,
    :move_posts,
    :merge_topic,
    :clear_pin,
    :re_pin,
    :status_update,
    :timer,
    :bulk,
    :reset_new,
    :change_post_owners,
    :change_timestamps,
    :archive_message,
    :move_to_inbox,
    :convert_topic,
    :bookmark,
    :publish,
    :reset_bump_date,
    :set_slow_mode
  ]

  before_action :consider_user_for_promotion, only: :show

  skip_before_action :check_xhr, only: [:show, :feed]

  def id_for_slug
    topic = Topic.find_by_slug(params[:slug])
    guardian.ensure_can_see!(topic)
    raise Discourse::NotFound unless topic
    render json: { slug: topic.slug, topic_id: topic.id, url: topic.url }
  end

  def show
    if request.referer
      flash["referer"] ||= request.referer[0..255]
    end

    # We'd like to migrate the wordpress feed to another url. This keeps up backwards compatibility with
    # existing installs.
    return wordpress if params[:best].present?

    # work around people somehow sending in arrays,
    # arrays are not supported
    params[:page] = params[:page].to_i rescue 1

    opts = params.slice(:username_filters, :filter, :page, :post_number, :show_deleted, :replies_to_post_number, :filter_upwards_post_id)
    username_filters = opts[:username_filters]

    opts[:print] = true if params[:print].present?
    opts[:username_filters] = username_filters.split(',') if username_filters.is_a?(String)

    # Special case: a slug with a number in front should look by slug first before looking
    # up that particular number
    if params[:id] && params[:id] =~ /^\d+[^\d\\]+$/
      topic = Topic.find_by_slug(params[:id])
      return redirect_to_correct_topic(topic, opts[:post_number]) if topic
    end

    if opts[:print]
      raise Discourse::InvalidAccess unless SiteSetting.max_prints_per_hour_per_user > 0
      begin
        RateLimiter.new(current_user, "print-topic-per-hour", SiteSetting.max_prints_per_hour_per_user, 1.hour).performed! unless @guardian.is_admin?
      rescue RateLimiter::LimitExceeded
        return render_json_error I18n.t("rate_limiter.slow_down")
      end
    end

    begin
      @topic_view = TopicView.new(params[:id] || params[:topic_id], current_user, opts)
    rescue Discourse::NotFound => ex
      if params[:id]
        topic = Topic.find_by_slug(params[:id])
        return redirect_to_correct_topic(topic, opts[:post_number]) if topic
      end

      raise ex
    rescue Discourse::NotLoggedIn => ex
      raise(SiteSetting.detailed_404 ? ex : Discourse::NotFound)
    rescue Discourse::InvalidAccess => ex
      # If the user can't see the topic, clean up notifications for it.
      Notification.remove_for(current_user.id, params[:topic_id]) if current_user

      deleted = guardian.can_see_topic?(ex.obj, false) ||
        (!guardian.can_see_topic?(ex.obj) &&
         ex.obj&.access_topic_via_group &&
         ex.obj.deleted_at)

      if SiteSetting.detailed_404
        if deleted
          raise Discourse::NotFound.new(
            'deleted topic',
            custom_message: 'deleted_topic',
            status: 410,
            check_permalinks: true,
            original_path: ex.obj.relative_url
          )
        elsif !guardian.can_see_topic?(ex.obj) && group = ex.obj&.access_topic_via_group
          raise Discourse::InvalidAccess.new(
            'not in group',
            ex.obj,
            custom_message: 'not_in_group.title_topic',
            custom_message_params: { group: group.name },
            group: group
          )
        end

        raise ex
      else
        raise Discourse::NotFound.new(
          nil,
          check_permalinks: deleted,
          original_path: ex.obj.relative_url
        )
      end
    end

    page = params[:page]
    if (page < 0) || ((page - 1) * @topic_view.chunk_size >= @topic_view.topic.highest_post_number)
      raise Discourse::NotFound
    end

    discourse_expires_in 1.minute

    if slugs_do_not_match || (!request.format.json? && params[:slug].nil?)
      redirect_to_correct_topic(@topic_view.topic, opts[:post_number])
      return
    end

    track_visit_to_topic

    if should_track_visit_to_topic?
      @topic_view.draft = Draft.get(current_user, @topic_view.draft_key, @topic_view.draft_sequence)
    end

    unless @topic_view.topic.visible
      response.headers['X-Robots-Tag'] = 'noindex'
    end

    canonical_url UrlHelper.absolute_without_cdn(@topic_view.canonical_path)

    # provide hint to crawlers only for now
    # we would like to give them a bit more signal about age of data
    if use_crawler_layout?
      if last_modified = @topic_view.posts&.map { |p| p.updated_at }&.max&.httpdate
        response.headers['Last-Modified'] = last_modified
      end
    end

    perform_show_response
  end

  def publish
    params.permit(:id, :destination_category_id)

    topic = Topic.find(params[:id])
    category = Category.find(params[:destination_category_id])

    raise Discourse::InvalidParameters if category.id == SiteSetting.shared_drafts_category.to_i

    guardian.ensure_can_publish_topic!(topic, category)
    topic = TopicPublisher.new(topic, current_user, category.id).publish!

    render_serialized(topic.reload, BasicTopicSerializer)
  end

  def wordpress
    params.require(:best)
    params.require(:topic_id)
    params.permit(:min_trust_level, :min_score, :min_replies, :bypass_trust_level_score, :only_moderator_liked)

    opts = {
      best: params[:best].to_i,
      min_trust_level: params[:min_trust_level] ? params[:min_trust_level].to_i : 1,
      min_score: params[:min_score].to_i,
      min_replies: params[:min_replies].to_i,
      bypass_trust_level_score: params[:bypass_trust_level_score].to_i, # safe cause 0 means ignore
      only_moderator_liked: params[:only_moderator_liked].to_s == "true",
      exclude_hidden: true
    }

    @topic_view = TopicView.new(params[:topic_id], current_user, opts)
    discourse_expires_in 1.minute

    wordpress_serializer = TopicViewWordpressSerializer.new(@topic_view, scope: guardian, root: false)
    render_json_dump(wordpress_serializer)
  end

  def post_ids
    params.require(:topic_id)
    params.permit(:post_number, :username_filters, :filter)

    options = {
      filter_post_number: params[:post_number],
      filter: params[:filter],
      skip_limit: true,
      asc: true,
      skip_custom_fields: true
    }

    fetch_topic_view(options)
    render_json_dump(post_ids: @topic_view.posts.pluck(:id))
  end

  def posts
    params.require(:topic_id)
    params.permit(:post_ids, :post_number, :username_filters, :filter, :include_suggested)

    include_suggested = params[:include_suggested] == "true"

    options = {
      filter_post_number: params[:post_number],
      post_ids: params[:post_ids],
      asc: ActiveRecord::Type::Boolean.new.deserialize(params[:asc]),
      filter: params[:filter],
      include_suggested: include_suggested,
      include_related: include_suggested,
    }

    fetch_topic_view(options)

    render_json_dump(
      TopicViewPostsSerializer.new(
        @topic_view,
        scope: guardian,
        root: false,
        include_raw: !!params[:include_raw]
      )
    )
  end

  def excerpts
    params.require(:topic_id)
    params.require(:post_ids)

    unless Array === params[:post_ids]
      render_json_error("Expecting post_ids to contain a list of posts ids")
      return
    end
    post_ids = params[:post_ids].map(&:to_i)

    if post_ids.length > 100
      render_json_error("Requested a chunk that is too big")
      return
    end

    @topic = Topic.with_deleted.where(id: params[:topic_id]).first
    guardian.ensure_can_see!(@topic)

    @posts = Post.where(hidden: false, deleted_at: nil, topic_id: @topic.id)
      .where('posts.id in (?)', post_ids)
      .joins("LEFT JOIN users u on u.id = posts.user_id")
      .pluck(:id, :cooked, :username)
      .map do |post_id, cooked, username|
      {
        post_id: post_id,
        username: username,
        excerpt: PrettyText.excerpt(cooked, 800, keep_emoji_images: true)
      }
    end

    render json: @posts.to_json
  end

  def destroy_timings
    topic_id = params[:topic_id].to_i

    if params[:last].to_s == "1"
      PostTiming.destroy_last_for(current_user, topic_id)
    else
      PostTiming.destroy_for(current_user.id, [topic_id])
    end

    last_notification = Notification
      .where(
        user_id: current_user.id,
        topic_id: topic_id
      )
      .order(created_at: :desc)
      .limit(1)
      .first

    if last_notification
      last_notification.update!(read: false)
    end

    render body: nil
  end

  def update_shared_draft
    topic = Topic.find_by(id: params[:id])
    guardian.ensure_can_edit!(topic)

    category = Category.where(id: params[:category_id].to_i).first
    guardian.ensure_can_publish_topic!(topic, category)

    row_count = SharedDraft.where(topic_id: topic.id).update_all(category_id: category.id)
    if row_count == 0
      SharedDraft.create(topic_id: topic.id, category_id: category.id)
    end

    render json: success_json
  end

  def update
    topic = Topic.find_by(id: params[:topic_id])
    guardian.ensure_can_edit!(topic)

    if params[:category_id] && (params[:category_id].to_i != topic.category_id.to_i)
      if topic.shared_draft
        topic.shared_draft.update(category_id: params[:category_id])
        params.delete(:category_id)
      else
        category = Category.find_by(id: params[:category_id])

        if category || (params[: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

        if category && topic_tags = (params[:tags] || topic.tags.pluck(:name)).reject { |c| c.empty? }
          if topic_tags.present?
            allowed_tags = DiscourseTagging.filter_allowed_tags(
              guardian,
              category: category
            ).map(&:name)

            invalid_tags = topic_tags - allowed_tags

            # Do not raise an error on a topic's hidden tags when not modifying tags
            if params[:tags].blank?
              invalid_tags.each do |tag_name|
                if DiscourseTagging.hidden_tag_names.include?(tag_name)
                  invalid_tags.delete(tag_name)
                end
              end
            end

            invalid_tags = Tag.where_name(invalid_tags).pluck(:name)

            if !invalid_tags.empty?
              if (invalid_tags & DiscourseTagging.hidden_tag_names).present?
                return render_json_error(I18n.t('category.errors.disallowed_tags_generic'))
              else
                return render_json_error(I18n.t('category.errors.disallowed_topic_tags', tags: invalid_tags.join(", ")))
              end
            end
          end
        end
      end
    end

    changes = {}

    PostRevisor.tracked_topic_fields.each_key do |f|
      changes[f] = params[f] if params.has_key?(f)
    end

    changes.delete(:title) if topic.title == changes[:title]
    changes.delete(:category_id) if topic.category_id.to_i == changes[:category_id].to_i

    if Tag.include_tags?
      topic_tags = topic.tags.map(&:name).sort
      changes.delete(:tags) if changes[:tags]&.sort == topic_tags
    end

    success = true

    if changes.length > 0
      first_post = topic.ordered_posts.first
      success = PostRevisor.new(first_post, topic).revise!(current_user, changes, validate_post: false)

      if !success && topic.errors.blank?
        topic.errors.add(:base, :unable_to_update)
      end
    end

    # this is used to return the title to the client as it may have been changed by "TextCleaner"
    success ? render_serialized(topic, BasicTopicSerializer) : render_json_error(topic)
  end

  def update_tags
    params.require(:tags)
    topic = Topic.find_by(id: params[:topic_id])
    guardian.ensure_can_edit_tags!(topic)

    success = PostRevisor.new(topic.first_post, topic).revise!(current_user, { tags: params[:tags] }, validate_post: false)

    success ? render_serialized(topic, BasicTopicSerializer) : render_json_error(topic)
  end

  def feature_stats
    params.require(:category_id)
    category_id = params[:category_id].to_i

    visible_topics = Topic.listable_topics.visible

    render json: {
      pinned_in_category_count: visible_topics.where(category_id: category_id).where(pinned_globally: false).where.not(pinned_at: nil).count,
      pinned_globally_count: visible_topics.where(pinned_globally: true).where.not(pinned_at: nil).count,
      banner_count: Topic.listable_topics.where(archetype: Archetype.banner).count,
    }
  end

  def status
    params.require(:status)
    params.require(:enabled)
    params.permit(:until)

    status = params[:status]
    topic_id = params[:topic_id].to_i
    enabled = params[:enabled] == 'true'

    check_for_status_presence(:status, status)
    @topic = Topic.find_by(id: topic_id)

    case status
    when 'closed'
      guardian.ensure_can_close_topic!(@topic)
    when 'archived'
      guardian.ensure_can_archive_topic!(@topic)
    when 'visible'
      guardian.ensure_can_toggle_topic_visibility!(@topic)
    when 'pinned'
      guardian.ensure_can_pin_unpin_topic!(@topic)
    else
      guardian.ensure_can_moderate!(@topic)
    end

    params[:until] === '' ? params[:until] = nil : params[:until]

    @topic.update_status(status, enabled, current_user, until: params[:until])

    render json: success_json.merge!(
      topic_status_update: TopicTimerSerializer.new(
        TopicTimer.find_by(topic: @topic), root: false
      )
    )
  end

  def mute
    toggle_mute
  end

  def unmute
    toggle_mute
  end

  def timer
    params.permit(:time, :based_on_last_post, :category_id)
    params.require(:status_type)

    status_type =
      begin
        TopicTimer.types.fetch(params[:status_type].to_sym)
      rescue
        invalid_param(:status_type)
      end
    based_on_last_post = params[:based_on_last_post]
    params.require(:duration_minutes) if based_on_last_post

    topic = Topic.find_by(id: params[:topic_id])
    guardian.ensure_can_moderate!(topic)

    options = {
      by_user: current_user,
      based_on_last_post: based_on_last_post
    }

    options.merge!(category_id: params[:category_id]) if !params[:category_id].blank?
    options.merge!(duration_minutes: params[:duration_minutes].to_i) if params[:duration_minutes].present?
    options.merge!(duration: params[:duration].to_i) if params[:duration].present?

    begin
      topic_timer = topic.set_or_create_timer(
        status_type,
        params[:time],
        **options
      )
    rescue ActiveRecord::RecordInvalid => e
      return render_json_error(e.message)
    end

    if topic.save
      render json: success_json.merge!(
        execute_at: topic_timer&.execute_at,
        duration_minutes: topic_timer&.duration_minutes,
        based_on_last_post: topic_timer&.based_on_last_post,
        closed: topic.closed,
        category_id: topic_timer&.category_id
      )
    else
      render_json_error(topic)
    end
  end

  def make_banner
    topic = Topic.find_by(id: params[:topic_id].to_i)
    guardian.ensure_can_banner_topic!(topic)

    topic.make_banner!(current_user)

    render body: nil
  end

  def remove_banner
    topic = Topic.find_by(id: params[:topic_id].to_i)
    guardian.ensure_can_banner_topic!(topic)

    topic.remove_banner!(current_user)

    render body: nil
  end

  def remove_bookmarks
    topic = Topic.find(params[:topic_id].to_i)
    BookmarkManager.new(current_user).destroy_for_topic(topic)
    render body: nil
  end

  def archive_message
    toggle_archive_message(true)
  end

  def move_to_inbox
    toggle_archive_message(false)
  end

  def toggle_archive_message(archive)
    topic = Topic.find(params[:id].to_i)

    group_id = nil

    group_ids = current_user.groups.pluck(:id)
    if group_ids.present?
      allowed_groups = topic.allowed_groups
        .where('topic_allowed_groups.group_id IN (?)', group_ids).pluck(:id)

      allowed_groups.each do |id|
        if archive
          GroupArchivedMessage.archive!(
            id,
            topic,
            acting_user_id: current_user.id
          )

          group_id = id
        else
          GroupArchivedMessage.move_to_inbox!(
            id,
            topic,
            acting_user_id: current_user.id
          )
        end
      end
    end

    if topic.allowed_users.include?(current_user)
      if archive
        UserArchivedMessage.archive!(current_user.id, topic)
      else
        UserArchivedMessage.move_to_inbox!(current_user.id, topic)
      end
    end

    if group_id
      name = Group.find_by(id: group_id).try(:name)
      render_json_dump(group_name: name)
    else
      render body: nil
    end
  end

  def bookmark
    topic = Topic.find(params[:topic_id].to_i)
    first_post = topic.ordered_posts.first

    bookmark_manager = BookmarkManager.new(current_user)
    bookmark_manager.create(post_id: first_post.id)

    if bookmark_manager.errors.any?
      return render_json_error(bookmark_manager, status: 400)
    end

    render body: nil
  end

  def destroy
    topic = Topic.find_by(id: params[:id])
    guardian.ensure_can_delete!(topic)

    first_post = topic.ordered_posts.first
    PostDestroyer.new(current_user, first_post, context: params[:context]).destroy

    render body: nil
  rescue Discourse::InvalidAccess
    render_json_error I18n.t("delete_topic_failed")
  end

  def recover
    topic = Topic.where(id: params[:topic_id]).with_deleted.first
    guardian.ensure_can_recover_topic!(topic)

    first_post = topic.posts.with_deleted.order(:post_number).first
    PostDestroyer.new(current_user, first_post, context: params[:context]).recover

    render body: nil
  end

  def excerpt
    render body: nil
  end

  def remove_allowed_user
    params.require(:username)
    topic = Topic.find_by(id: params[:topic_id])
    raise Discourse::NotFound unless topic
    user = User.find_by(username: params[:username])
    raise Discourse::NotFound unless user

    guardian.ensure_can_remove_allowed_users!(topic, user)

    if topic.remove_allowed_user(current_user, user)
      render json: success_json
    else
      render json: failed_json, status: 422
    end
  end

  def remove_allowed_group
    params.require(:name)
    topic = Topic.find_by(id: params[:topic_id])
    guardian.ensure_can_remove_allowed_users!(topic)

    if topic.remove_allowed_group(current_user, params[:name])
      render json: success_json
    else
      render json: failed_json, status: 422
    end
  end

  def invite_notify
    topic = Topic.find_by(id: params[:topic_id])
    guardian.ensure_can_see!(topic)

    usernames = params[:usernames]
    raise Discourse::InvalidParameters.new(:usernames) if !usernames.kind_of?(Array) || (!current_user.staff? && usernames.size > 1)

    users = User.where(username_lower: usernames.map(&:downcase))
    raise Discourse::InvalidParameters.new(:usernames) if usernames.size != users.size

    topic.rate_limit_topic_invitation(current_user)

    users.find_each do |user|
      if !user.guardian.can_see_topic?(topic)
        return render json: failed_json.merge(error: I18n.t('topic_invite.user_cannot_see_topic', username: user.username)), status: 422
      end
    end

    users.find_each do |user|
      last_notification = user.notifications
        .where(notification_type: Notification.types[:invited_to_topic])
        .where(topic_id: topic.id)
        .where(post_number: 1)
        .where('created_at > ?', 1.hour.ago)

      if !last_notification.exists?
        topic.create_invite_notification!(user, Notification.types[:invited_to_topic], current_user.username)
      end
    end

    render json: success_json
  end

  def invite_group
    group = Group.find_by(name: params[:group])
    raise Discourse::NotFound unless group

    topic = Topic.find_by(id: params[:topic_id])

    unless pm_has_slots?(topic)
      return render_json_error(
        I18n.t("pm_reached_recipients_limit", recipients_limit: SiteSetting.max_allowed_message_recipients)
      )
    end

    if topic.private_message?
      guardian.ensure_can_invite_group_to_private_message!(group, topic)
      topic.invite_group(current_user, group)
      render_json_dump BasicGroupSerializer.new(group, scope: guardian, root: 'group')
    else
      render json: failed_json, status: 422
    end
  end

  def invite
    topic = Topic.find_by(id: params[:topic_id])
    raise Discourse::InvalidParameters.new unless topic

    username_or_email = params[:user] ? fetch_username : fetch_email

    groups = Group.lookup_groups(
      group_ids: params[:group_ids],
      group_names: params[:group_names]
    )

    unless pm_has_slots?(topic)
      return render_json_error(
        I18n.t("pm_reached_recipients_limit", recipients_limit: SiteSetting.max_allowed_message_recipients)
      )
    end

    guardian.ensure_can_invite_to!(topic)
    group_ids = groups.map(&:id)

    begin
      if topic.invite(current_user, username_or_email, group_ids, params[:custom_message])
        user = User.find_by_username_or_email(username_or_email)

        if user
          render_json_dump BasicUserSerializer.new(user, scope: guardian, root: 'user')
        else
          render json: success_json
        end
      else
        json = failed_json

        unless topic.private_message?
          group_names = topic.category
            .visible_group_names(current_user)
            .where(automatic: false)
            .pluck(:name)
            .join(", ")

          if group_names.present?
            json.merge!(errors: [
              I18n.t(
                "topic_invite.failed_to_invite",
                group_names: group_names
              )
            ])
          end
        end

        render json: json, status: 422
      end
    rescue Topic::UserExists, Topic::NotAllowed => e
      render json: { errors: [e.message] }, status: 422
    end
  end

  def set_notifications
    topic = Topic.find(params[:topic_id].to_i)
    TopicUser.change(current_user, topic.id, notification_level: params[:notification_level].to_i)
    render json: success_json
  end

  def merge_topic
    topic_id = params.require(:topic_id)
    destination_topic_id = params.require(:destination_topic_id)
    params.permit(:participants)
    params.permit(:archetype)

    raise Discourse::InvalidAccess if params[:archetype] == "private_message" && !guardian.is_staff?

    topic = Topic.find_by(id: topic_id)
    guardian.ensure_can_move_posts!(topic)

    destination_topic = Topic.find_by(id: destination_topic_id)
    guardian.ensure_can_create_post_on_topic!(destination_topic)

    args = {}
    args[:destination_topic_id] = destination_topic_id.to_i

    if params[:archetype].present?
      args[:archetype] = params[:archetype]
      args[:participants] = params[:participants] if params[:participants].present? && params[:archetype] == "private_message"
    end

    destination_topic = topic.move_posts(current_user, topic.posts.pluck(:id), args)
    render_topic_changes(destination_topic)
  end

  def move_posts
    post_ids = params.require(:post_ids)
    topic_id = params.require(:topic_id)
    params.permit(:category_id)
    params.permit(:tags)
    params.permit(:participants)
    params.permit(:archetype)

    raise Discourse::InvalidAccess if params[:archetype] == "private_message" && !guardian.is_staff?

    topic = Topic.with_deleted.find_by(id: topic_id)
    guardian.ensure_can_move_posts!(topic)

    if params[:title].present?
      # when creating a new topic, ensure the 1st post is a regular post
      if Post.where(topic: topic, id: post_ids).order(:post_number).pluck_first(:post_type) != Post.types[:regular]
        return render_json_error("When moving posts to a new topic, the first post must be a regular post.")
      end

      if params[:category_id].present?
        guardian.ensure_can_create_topic_on_category!(params[:category_id])
      end
    end

    destination_topic = move_posts_to_destination(topic)
    render_topic_changes(destination_topic)
  rescue ActiveRecord::RecordInvalid => ex
    render_json_error(ex)
  end

  def change_post_owners
    params.require(:post_ids)
    params.require(:topic_id)
    params.require(:username)

    guardian.ensure_can_change_post_owner!

    begin
      PostOwnerChanger.new(post_ids: params[:post_ids].to_a,
                           topic_id: params[:topic_id].to_i,
                           new_owner: User.find_by(username: params[:username]),
                           acting_user: current_user).change_owner!
      render json: success_json
    rescue ArgumentError
      render json: failed_json, status: 422
    end
  end

  def change_timestamps
    topic_id = params.require(:topic_id).to_i
    timestamp = params.require(:timestamp).to_f

    guardian.ensure_can_change_post_timestamps!

    topic = Topic.with_deleted.find(topic_id)
    previous_timestamp = topic.first_post.created_at

    begin
      TopicTimestampChanger.new(
        topic: topic,
        timestamp: timestamp
      ).change!

      StaffActionLogger.new(current_user).log_topic_timestamps_changed(topic, Time.zone.at(timestamp), previous_timestamp)

      render json: success_json
    rescue ActiveRecord::RecordInvalid, TopicTimestampChanger::InvalidTimestampError
      render json: failed_json, status: 422
    end
  end

  def clear_pin
    topic = Topic.find_by(id: params[:topic_id].to_i)
    guardian.ensure_can_see!(topic)
    topic.clear_pin_for(current_user)
    render body: nil
  end

  def re_pin
    topic = Topic.find_by(id: params[:topic_id].to_i)
    guardian.ensure_can_see!(topic)
    topic.re_pin_for(current_user)
    render body: nil
  end

  def timings
    allowed_params = topic_params

    topic_id = allowed_params[:topic_id].to_i
    topic_time = allowed_params[:topic_time].to_i
    timings = allowed_params[:timings].to_h || {}

    # ensure we capture current user for the block
    user = current_user

    hijack do
      PostTiming.process_timings(
        user,
        topic_id,
        topic_time,
        timings.map { |post_number, t| [post_number.to_i, t.to_i] },
        mobile: view_context.mobile_view?
      )
      render body: nil
    end
  end

  def feed
    raise Discourse::NotFound if !Post.exists?(topic_id: params[:topic_id])

    begin
      @topic_view = TopicView.new(params[:topic_id])
    rescue Discourse::NotLoggedIn
      raise Discourse::NotFound
    rescue Discourse::InvalidAccess => ex

      deleted = guardian.can_see_topic?(ex.obj, false) ||
        (!guardian.can_see_topic?(ex.obj) &&
         ex.obj&.access_topic_via_group &&
         ex.obj.deleted_at)

      raise Discourse::NotFound.new(
        nil,
        check_permalinks: deleted,
        original_path: ex.obj.relative_url
      )
    end

    discourse_expires_in 1.minute
    render 'topics/show', formats: [:rss]
  end

  def bulk
    if params[:topic_ids].present?
      unless Array === params[:topic_ids]
        raise Discourse::InvalidParameters.new(
          "Expecting topic_ids to contain a list of topic ids"
        )
      end
      topic_ids = params[:topic_ids].map { |t| t.to_i }
    elsif params[:filter] == 'unread'
      topic_ids = bulk_unread_topic_ids
    else
      raise ActionController::ParameterMissing.new(:topic_ids)
    end

    operation = params
      .require(:operation)
      .permit(:type, :group, :category_id, :notification_level_id, *DiscoursePluginRegistry.permitted_bulk_action_parameters, tags: [])
      .to_h.symbolize_keys

    raise ActionController::ParameterMissing.new(:operation_type) if operation[:type].blank?
    operator = TopicsBulkAction.new(current_user, topic_ids, operation, group: operation[:group])
    changed_topic_ids = operator.perform!
    render_json_dump topic_ids: changed_topic_ids
  end

  def private_message_reset_new
    topic_query = TopicQuery.new(current_user, limit: false)

    if params[:topic_ids].present?
      unless Array === params[:topic_ids]
        raise Discourse::InvalidParameters.new(
          "Expecting topic_ids to contain a list of topic ids"
        )
      end

      topic_scope = topic_query
        .private_messages_for(current_user, :all)
        .where("topics.id IN (?)", params[:topic_ids].map(&:to_i))
    else
      params.require(:inbox)
      inbox = params[:inbox].to_s
      filter = private_message_filter(topic_query, inbox)
      topic_scope = topic_query.filter_private_message_new(current_user, filter)
    end

    topic_ids = TopicsBulkAction.new(
      current_user,
      topic_scope.distinct(false).pluck(:id),
      type: "dismiss_topics"
    ).perform!

    render json: success_json.merge(topic_ids: topic_ids)
  end

  def reset_new
    topic_scope =
      if params[:category_id].present?
        category_ids = [params[:category_id]]
        if params[:include_subcategories] == 'true'
          category_ids = category_ids.concat(Category.where(parent_category_id: params[:category_id]).pluck(:id))
        end

        scope = Topic.where(category_id: category_ids)
        scope = scope.joins(:tags).where(tags: { name: params[:tag_id] }) if params[:tag_id]
        scope
      elsif params[:tag_id].present?
        Topic.joins(:tags).where(tags: { name: params[:tag_id] })
      else
        new_results = TopicQuery.new(current_user).new_results(limit: false)
        if params[:tracked].to_s == "true"
          TopicQuery.tracked_filter(new_results, current_user.id)
        else
          current_user.user_stat.update_column(:new_since, Time.zone.now)
          new_results
        end
      end

    if params[:topic_ids].present?
      unless Array === params[:topic_ids]
        raise Discourse::InvalidParameters.new(
          "Expecting topic_ids to contain a list of topic ids"
        )
      end

      topic_ids = params[:topic_ids].map { |t| t.to_i }
      topic_scope = topic_scope.where(id: topic_ids)
    end

    dismissed_topic_ids = TopicsBulkAction.new(current_user, topic_scope.pluck(:id), type: "dismiss_topics").perform!
    TopicTrackingState.publish_dismiss_new(current_user.id, topic_ids: dismissed_topic_ids)

    render body: nil
  end

  def convert_topic
    params.require(:id)
    params.require(:type)
    topic = Topic.find_by(id: params[:id])
    guardian.ensure_can_convert_topic!(topic)

    if params[:type] == "public"
      converted_topic = topic.convert_to_public_topic(current_user, category_id: params[:category_id])
    else
      converted_topic = topic.convert_to_private_message(current_user)
    end
    render_topic_changes(converted_topic)
  rescue ActiveRecord::RecordInvalid => ex
    render_json_error(ex)
  end

  def reset_bump_date
    params.require(:id)
    guardian.ensure_can_update_bumped_at!

    topic = Topic.find_by(id: params[:id])
    raise Discourse::NotFound.new unless topic

    topic.reset_bumped_at
    render body: nil
  end

  def set_slow_mode
    topic = Topic.find(params[:topic_id])
    slow_mode_type = TopicTimer.types[:clear_slow_mode]
    timer = TopicTimer.find_by(topic: topic, status_type: slow_mode_type)

    guardian.ensure_can_moderate!(topic)
    topic.update!(slow_mode_seconds: params[:seconds])
    enabled = params[:seconds].to_i > 0

    time = enabled && params[:enabled_until].present? ? params[:enabled_until] : nil

    topic.set_or_create_timer(
      slow_mode_type,
      time,
      by_user: timer&.user
    )

    head :ok
  end

  private

  def topic_params
    params.permit(
      :topic_id,
      :topic_time,
      timings: {}
    )
  end

  def fetch_topic_view(options)
    if (username_filters = params[:username_filters]).present?
      options[:username_filters] = username_filters.split(',')
    end

    @topic_view = TopicView.new(params[:topic_id], current_user, options)
  end

  def toggle_mute
    @topic = Topic.find_by(id: params[:topic_id].to_i)
    guardian.ensure_can_see!(@topic)

    @topic.toggle_mute(current_user)
    render body: nil
  end

  def consider_user_for_promotion
    Promotion.new(current_user).review if current_user.present?
  end

  def slugs_do_not_match
    if SiteSetting.slug_generation_method != "encoded"
      params[:slug] && @topic_view.topic.slug != params[:slug]
    else
      params[:slug] && CGI.unescape(@topic_view.topic.slug) != params[:slug]
    end
  end

  def redirect_to_correct_topic(topic, post_number = nil)
    begin
      guardian.ensure_can_see!(topic)
    rescue Discourse::InvalidAccess => ex
      raise(SiteSetting.detailed_404 ? ex : Discourse::NotFound)
    end

    url = topic.relative_url
    url << "/#{post_number}" if post_number.to_i > 0
    url << ".json" if request.format.json?

    page = params[:page]
    url << "?page=#{page}" if page != 0

    redirect_to url, status: 301
  end

  def track_visit_to_topic
    topic_id =  @topic_view.topic.id
    ip = request.remote_ip
    user_id = (current_user.id if current_user)
    track_visit = should_track_visit_to_topic?

    if !request.format.json?
      hash = {
        referer: request.referer || flash[:referer],
        host: request.host,
        current_user: current_user,
        topic_id: @topic_view.topic.id,
        post_number: @topic_view.current_post_number,
        username: request['u'],
        ip_address: request.remote_ip
      }
      # defer this way so we do not capture the whole controller
      # in the closure
      TopicsController.defer_add_incoming_link(hash)
    end

    TopicsController.defer_track_visit(topic_id, ip, user_id, track_visit)
  end

  def self.defer_track_visit(topic_id, ip, user_id, track_visit)
    Scheduler::Defer.later "Track Visit" do
      TopicViewItem.add(topic_id, ip, user_id)
      TopicUser.track_visit!(topic_id, user_id) if track_visit
    end
  end

  def self.defer_add_incoming_link(hash)
    Scheduler::Defer.later "Track Link" do
      IncomingLink.add(hash)
    end
  end

  def should_track_visit_to_topic?
    !!((!request.format.json? || params[:track_visit]) && current_user)
  end

  def perform_show_response

    if request.head?
      head :ok
      return
    end

    topic_view_serializer = TopicViewSerializer.new(
      @topic_view,
      scope: guardian,
      root: false,
      include_raw: !!params[:include_raw],
      exclude_suggested_and_related: !!params[:replies_to_post_number] || !!params[:filter_upwards_post_id]
    )

    respond_to do |format|
      format.html do
        @tags = SiteSetting.tagging_enabled ? @topic_view.topic.tags : []
        @breadcrumbs = helpers.categories_breadcrumb(@topic_view.topic) || []
        @description_meta = @topic_view.topic.excerpt.present? ? @topic_view.topic.excerpt : @topic_view.summary
        store_preloaded("topic_#{@topic_view.topic.id}", MultiJson.dump(topic_view_serializer))
        render :show
      end

      format.json do
        render_json_dump(topic_view_serializer)
      end
    end
  end

  def render_topic_changes(dest_topic)
    if dest_topic.present?
      render json: { success: true, url: dest_topic.relative_url }
    else
      render json: { success: false }
    end
  end

  def move_posts_to_destination(topic)
    args = {}
    args[:title] = params[:title] if params[:title].present?
    args[:destination_topic_id] = params[:destination_topic_id].to_i if params[:destination_topic_id].present?
    args[:tags] = params[:tags] if params[:tags].present?

    if params[:archetype].present?
      args[:archetype] = params[:archetype]
      args[:participants] = params[:participants] if params[:participants].present? && params[:archetype] == "private_message"
    else
      args[:category_id] = params[:category_id].to_i if params[:category_id].present?
    end

    topic.move_posts(current_user, post_ids_including_replies, args)
  end

  def check_for_status_presence(key, attr)
    invalid_param(key) unless %w(pinned pinned_globally visible closed archived).include?(attr)
  end

  def invalid_param(key)
    raise Discourse::InvalidParameters.new(key.to_sym)
  end

  def fetch_username
    params.require(:user)
    params[:user]
  end

  def fetch_email
    params.require(:email)
    params[:email]
  end

  def pm_has_slots?(pm)
    guardian.is_staff? || !pm.reached_recipients_limit?
  end

  def bulk_unread_topic_ids
    topic_query = TopicQuery.new(current_user)

    if inbox = params[:private_message_inbox]
      filter = private_message_filter(topic_query, inbox)
      topic_query.options[:limit] = false

      topic_query
        .filter_private_messages_unread(current_user, filter)
        .distinct(false)
        .pluck(:id)
    else
      topics = TopicQuery.unread_filter(topic_query.joined_topic_user, staff: guardian.is_staff?).listable_topics
      topics = TopicQuery.tracked_filter(topics, current_user.id) if params[:tracked].to_s == "true"

      if params[:category_id]
        if params[:include_subcategories]
          topics = topics.where(<<~SQL, category_id: params[:category_id])
            category_id in (select id FROM categories WHERE parent_category_id = :category_id) OR
            category_id = :category_id
          SQL
        else
          topics = topics.where('category_id = ?', params[:category_id])
        end
      end

      if params[:tag_name].present?
        topics = topics.joins(:tags).where("tags.name": params[:tag_name])
      end

      topics.pluck(:id)
    end
  end

  def private_message_filter(topic_query, inbox)
    case inbox
    when "group"
      group_name = params[:group_name]
      group = Group.find_by("lower(name) = ?", group_name)
      raise Discourse::NotFound if !group
      raise Discourse::NotFound if !guardian.can_see_group_messages?(group)
      topic_query.options[:group_name] = group_name
      :group
    when "user"
      :user
    else
      :all
    end
  end
end


Fixed version (ground truth):
# frozen_string_literal: true

class TopicsController < ApplicationController
  requires_login only: [
    :timings,
    :destroy_timings,
    :update,
    :update_shared_draft,
    :destroy,
    :recover,
    :status,
    :invite,
    :mute,
    :unmute,
    :set_notifications,
    :move_posts,
    :merge_topic,
    :clear_pin,
    :re_pin,
    :status_update,
    :timer,
    :bulk,
    :reset_new,
    :change_post_owners,
    :change_timestamps,
    :archive_message,
    :move_to_inbox,
    :convert_topic,
    :bookmark,
    :publish,
    :reset_bump_date,
    :set_slow_mode
  ]

  before_action :consider_user_for_promotion, only: :show

  skip_before_action :check_xhr, only: [:show, :feed]

  def id_for_slug
    topic = Topic.find_by_slug(params[:slug])
    guardian.ensure_can_see!(topic)
    raise Discourse::NotFound unless topic
    render json: { slug: topic.slug, topic_id: topic.id, url: topic.url }
  end

  def show
    if request.referer
      flash["referer"] ||= request.referer[0..255]
    end

    # We'd like to migrate the wordpress feed to another url. This keeps up backwards compatibility with
    # existing installs.
    return wordpress if params[:best].present?

    # work around people somehow sending in arrays,
    # arrays are not supported
    params[:page] = params[:page].to_i rescue 1

    opts = params.slice(:username_filters, :filter, :page, :post_number, :show_deleted, :replies_to_post_number, :filter_upwards_post_id)
    username_filters = opts[:username_filters]

    opts[:print] = true if params[:print].present?
    opts[:username_filters] = username_filters.split(',') if username_filters.is_a?(String)

    # Special case: a slug with a number in front should look by slug first before looking
    # up that particular number
    if params[:id] && params[:id] =~ /^\d+[^\d\\]+$/
      topic = Topic.find_by_slug(params[:id])
      return redirect_to_correct_topic(topic, opts[:post_number]) if topic
    end

    if opts[:print]
      raise Discourse::InvalidAccess unless SiteSetting.max_prints_per_hour_per_user > 0
      begin
        RateLimiter.new(current_user, "print-topic-per-hour", SiteSetting.max_prints_per_hour_per_user, 1.hour).performed! unless @guardian.is_admin?
      rescue RateLimiter::LimitExceeded
        return render_json_error I18n.t("rate_limiter.slow_down")
      end
    end

    begin
      @topic_view = TopicView.new(params[:id] || params[:topic_id], current_user, opts)
    rescue Discourse::NotFound => ex
      if params[:id]
        topic = Topic.find_by_slug(params[:id])
        return redirect_to_correct_topic(topic, opts[:post_number]) if topic
      end

      raise ex
    rescue Discourse::NotLoggedIn => ex
      raise(SiteSetting.detailed_404 ? ex : Discourse::NotFound)
    rescue Discourse::InvalidAccess => ex
      # If the user can't see the topic, clean up notifications for it.
      Notification.remove_for(current_user.id, params[:topic_id]) if current_user

      deleted = guardian.can_see_topic?(ex.obj, false) ||
        (!guardian.can_see_topic?(ex.obj) &&
         ex.obj&.access_topic_via_group &&
         ex.obj.deleted_at)

      if SiteSetting.detailed_404
        if deleted
          raise Discourse::NotFound.new(
            'deleted topic',
            custom_message: 'deleted_topic',
            status: 410,
            check_permalinks: true,
            original_path: ex.obj.relative_url
          )
        elsif !guardian.can_see_topic?(ex.obj) && group = ex.obj&.access_topic_via_group
          raise Discourse::InvalidAccess.new(
            'not in group',
            ex.obj,
            custom_message: 'not_in_group.title_topic',
            custom_message_params: { group: group.name },
            group: group
          )
        end

        raise ex
      else
        raise Discourse::NotFound.new(
          nil,
          check_permalinks: deleted,
          original_path: ex.obj.relative_url
        )
      end
    end

    page = params[:page]
    if (page < 0) || ((page - 1) * @topic_view.chunk_size >= @topic_view.topic.highest_post_number)
      raise Discourse::NotFound
    end

    discourse_expires_in 1.minute

    if slugs_do_not_match || (!request.format.json? && params[:slug].nil?)
      redirect_to_correct_topic(@topic_view.topic, opts[:post_number])
      return
    end

    track_visit_to_topic

    if should_track_visit_to_topic?
      @topic_view.draft = Draft.get(current_user, @topic_view.draft_key, @topic_view.draft_sequence)
    end

    unless @topic_view.topic.visible
      response.headers['X-Robots-Tag'] = 'noindex'
    end

    canonical_url UrlHelper.absolute_without_cdn(@topic_view.canonical_path)

    # provide hint to crawlers only for now
    # we would like to give them a bit more signal about age of data
    if use_crawler_layout?
      if last_modified = @topic_view.posts&.map { |p| p.updated_at }&.max&.httpdate
        response.headers['Last-Modified'] = last_modified
      end
    end

    perform_show_response
  end

  def publish
    params.permit(:id, :destination_category_id)

    topic = Topic.find(params[:id])
    category = Category.find(params[:destination_category_id])

    raise Discourse::InvalidParameters if category.id == SiteSetting.shared_drafts_category.to_i

    guardian.ensure_can_publish_topic!(topic, category)
    topic = TopicPublisher.new(topic, current_user, category.id).publish!

    render_serialized(topic.reload, BasicTopicSerializer)
  end

  def wordpress
    params.require(:best)
    params.require(:topic_id)
    params.permit(:min_trust_level, :min_score, :min_replies, :bypass_trust_level_score, :only_moderator_liked)

    opts = {
      best: params[:best].to_i,
      min_trust_level: params[:min_trust_level] ? params[:min_trust_level].to_i : 1,
      min_score: params[:min_score].to_i,
      min_replies: params[:min_replies].to_i,
      bypass_trust_level_score: params[:bypass_trust_level_score].to_i, # safe cause 0 means ignore
      only_moderator_liked: params[:only_moderator_liked].to_s == "true",
      exclude_hidden: true
    }

    @topic_view = TopicView.new(params[:topic_id], current_user, opts)
    discourse_expires_in 1.minute

    wordpress_serializer = TopicViewWordpressSerializer.new(@topic_view, scope: guardian, root: false)
    render_json_dump(wordpress_serializer)
  end

  def post_ids
    params.require(:topic_id)
    params.permit(:post_number, :username_filters, :filter)

    options = {
      filter_post_number: params[:post_number],
      filter: params[:filter],
      skip_limit: true,
      asc: true,
      skip_custom_fields: true
    }

    fetch_topic_view(options)
    render_json_dump(post_ids: @topic_view.posts.pluck(:id))
  end

  def posts
    params.require(:topic_id)
    params.permit(:post_ids, :post_number, :username_filters, :filter, :include_suggested)

    include_suggested = params[:include_suggested] == "true"

    options = {
      filter_post_number: params[:post_number],
      post_ids: params[:post_ids],
      asc: ActiveRecord::Type::Boolean.new.deserialize(params[:asc]),
      filter: params[:filter],
      include_suggested: include_suggested,
      include_related: include_suggested,
    }

    fetch_topic_view(options)

    render_json_dump(
      TopicViewPostsSerializer.new(
        @topic_view,
        scope: guardian,
        root: false,
        include_raw: !!params[:include_raw]
      )
    )
  end

  def excerpts
    params.require(:topic_id)
    params.require(:post_ids)

    unless Array === params[:post_ids]
      render_json_error("Expecting post_ids to contain a list of posts ids")
      return
    end
    post_ids = params[:post_ids].map(&:to_i)

    if post_ids.length > 100
      render_json_error("Requested a chunk that is too big")
      return
    end

    @topic = Topic.with_deleted.where(id: params[:topic_id]).first
    guardian.ensure_can_see!(@topic)

    @posts = Post.where(hidden: false, deleted_at: nil, topic_id: @topic.id)
      .where('posts.id in (?)', post_ids)
      .joins("LEFT JOIN users u on u.id = posts.user_id")
      .pluck(:id, :cooked, :username)
      .map do |post_id, cooked, username|
      {
        post_id: post_id,
        username: username,
        excerpt: PrettyText.excerpt(cooked, 800, keep_emoji_images: true)
      }
    end

    render json: @posts.to_json
  end

  def destroy_timings
    topic_id = params[:topic_id].to_i

    if params[:last].to_s == "1"
      PostTiming.destroy_last_for(current_user, topic_id)
    else
      PostTiming.destroy_for(current_user.id, [topic_id])
    end

    last_notification = Notification
      .where(
        user_id: current_user.id,
        topic_id: topic_id
      )
      .order(created_at: :desc)
      .limit(1)
      .first

    if last_notification
      last_notification.update!(read: false)
    end

    render body: nil
  end

  def update_shared_draft
    topic = Topic.find_by(id: params[:id])
    guardian.ensure_can_edit!(topic)

    category = Category.where(id: params[:category_id].to_i).first
    guardian.ensure_can_publish_topic!(topic, category)

    row_count = SharedDraft.where(topic_id: topic.id).update_all(category_id: category.id)
    if row_count == 0
      SharedDraft.create(topic_id: topic.id, category_id: category.id)
    end

    render json: success_json
  end

  def update
    topic = Topic.find_by(id: params[:topic_id])
    guardian.ensure_can_edit!(topic)

    if params[:category_id] && (params[:category_id].to_i != topic.category_id.to_i)
      if topic.shared_draft
        topic.shared_draft.update(category_id: params[:category_id])
        params.delete(:category_id)
      else
        category = Category.find_by(id: params[:category_id])

        if category || (params[: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

        if category && topic_tags = (params[:tags] || topic.tags.pluck(:name)).reject { |c| c.empty? }
          if topic_tags.present?
            allowed_tags = DiscourseTagging.filter_allowed_tags(
              guardian,
              category: category
            ).map(&:name)

            invalid_tags = topic_tags - allowed_tags

            # Do not raise an error on a topic's hidden tags when not modifying tags
            if params[:tags].blank?
              invalid_tags.each do |tag_name|
                if DiscourseTagging.hidden_tag_names.include?(tag_name)
                  invalid_tags.delete(tag_name)
                end
              end
            end

            invalid_tags = Tag.where_name(invalid_tags).pluck(:name)

            if !invalid_tags.empty?
              if (invalid_tags & DiscourseTagging.hidden_tag_names).present?
                return render_json_error(I18n.t('category.errors.disallowed_tags_generic'))
              else
                return render_json_error(I18n.t('category.errors.disallowed_topic_tags', tags: invalid_tags.join(", ")))
              end
            end
          end
        end
      end
    end

    changes = {}

    PostRevisor.tracked_topic_fields.each_key do |f|
      changes[f] = params[f] if params.has_key?(f)
    end

    changes.delete(:title) if topic.title == changes[:title]
    changes.delete(:category_id) if topic.category_id.to_i == changes[:category_id].to_i

    if Tag.include_tags?
      topic_tags = topic.tags.map(&:name).sort
      changes.delete(:tags) if changes[:tags]&.sort == topic_tags
    end

    success = true

    if changes.length > 0
      first_post = topic.ordered_posts.first
      success = PostRevisor.new(first_post, topic).revise!(current_user, changes, validate_post: false)

      if !success && topic.errors.blank?
        topic.errors.add(:base, :unable_to_update)
      end
    end

    # this is used to return the title to the client as it may have been changed by "TextCleaner"
    success ? render_serialized(topic, BasicTopicSerializer) : render_json_error(topic)
  end

  def update_tags
    params.require(:tags)
    topic = Topic.find_by(id: params[:topic_id])
    guardian.ensure_can_edit_tags!(topic)

    success = PostRevisor.new(topic.first_post, topic).revise!(current_user, { tags: params[:tags] }, validate_post: false)

    success ? render_serialized(topic, BasicTopicSerializer) : render_json_error(topic)
  end

  def feature_stats
    params.require(:category_id)
    category_id = params[:category_id].to_i

    visible_topics = Topic.listable_topics.visible

    render json: {
      pinned_in_category_count: visible_topics.where(category_id: category_id).where(pinned_globally: false).where.not(pinned_at: nil).count,
      pinned_globally_count: visible_topics.where(pinned_globally: true).where.not(pinned_at: nil).count,
      banner_count: Topic.listable_topics.where(archetype: Archetype.banner).count,
    }
  end

  def status
    params.require(:status)
    params.require(:enabled)
    params.permit(:until)

    status = params[:status]
    topic_id = params[:topic_id].to_i
    enabled = params[:enabled] == 'true'

    check_for_status_presence(:status, status)
    @topic = Topic.find_by(id: topic_id)

    case status
    when 'closed'
      guardian.ensure_can_close_topic!(@topic)
    when 'archived'
      guardian.ensure_can_archive_topic!(@topic)
    when 'visible'
      guardian.ensure_can_toggle_topic_visibility!(@topic)
    when 'pinned'
      guardian.ensure_can_pin_unpin_topic!(@topic)
    else
      guardian.ensure_can_moderate!(@topic)
    end

    params[:until] === '' ? params[:until] = nil : params[:until]

    @topic.update_status(status, enabled, current_user, until: params[:until])

    render json: success_json.merge!(
      topic_status_update: TopicTimerSerializer.new(
        TopicTimer.find_by(topic: @topic), root: false
      )
    )
  end

  def mute
    toggle_mute
  end

  def unmute
    toggle_mute
  end

  def timer
    params.permit(:time, :based_on_last_post, :category_id)
    params.require(:status_type)

    status_type =
      begin
        TopicTimer.types.fetch(params[:status_type].to_sym)
      rescue
        invalid_param(:status_type)
      end
    based_on_last_post = params[:based_on_last_post]
    params.require(:duration_minutes) if based_on_last_post

    topic = Topic.find_by(id: params[:topic_id])
    guardian.ensure_can_moderate!(topic)

    options = {
      by_user: current_user,
      based_on_last_post: based_on_last_post
    }

    options.merge!(category_id: params[:category_id]) if !params[:category_id].blank?
    options.merge!(duration_minutes: params[:duration_minutes].to_i) if params[:duration_minutes].present?
    options.merge!(duration: params[:duration].to_i) if params[:duration].present?

    begin
      topic_timer = topic.set_or_create_timer(
        status_type,
        params[:time],
        **options
      )
    rescue ActiveRecord::RecordInvalid => e
      return render_json_error(e.message)
    end

    if topic.save
      render json: success_json.merge!(
        execute_at: topic_timer&.execute_at,
        duration_minutes: topic_timer&.duration_minutes,
        based_on_last_post: topic_timer&.based_on_last_post,
        closed: topic.closed,
        category_id: topic_timer&.category_id
      )
    else
      render_json_error(topic)
    end
  end

  def make_banner
    topic = Topic.find_by(id: params[:topic_id].to_i)
    guardian.ensure_can_banner_topic!(topic)

    topic.make_banner!(current_user)

    render body: nil
  end

  def remove_banner
    topic = Topic.find_by(id: params[:topic_id].to_i)
    guardian.ensure_can_banner_topic!(topic)

    topic.remove_banner!(current_user)

    render body: nil
  end

  def remove_bookmarks
    topic = Topic.find(params[:topic_id].to_i)
    BookmarkManager.new(current_user).destroy_for_topic(topic)
    render body: nil
  end

  def archive_message
    toggle_archive_message(true)
  end

  def move_to_inbox
    toggle_archive_message(false)
  end

  def toggle_archive_message(archive)
    topic = Topic.find(params[:id].to_i)

    group_id = nil

    group_ids = current_user.groups.pluck(:id)
    if group_ids.present?
      allowed_groups = topic.allowed_groups
        .where('topic_allowed_groups.group_id IN (?)', group_ids).pluck(:id)

      allowed_groups.each do |id|
        if archive
          GroupArchivedMessage.archive!(
            id,
            topic,
            acting_user_id: current_user.id
          )

          group_id = id
        else
          GroupArchivedMessage.move_to_inbox!(
            id,
            topic,
            acting_user_id: current_user.id
          )
        end
      end
    end

    if topic.allowed_users.include?(current_user)
      if archive
        UserArchivedMessage.archive!(current_user.id, topic)
      else
        UserArchivedMessage.move_to_inbox!(current_user.id, topic)
      end
    end

    if group_id
      name = Group.find_by(id: group_id).try(:name)
      render_json_dump(group_name: name)
    else
      render body: nil
    end
  end

  def bookmark
    topic = Topic.find(params[:topic_id].to_i)
    first_post = topic.ordered_posts.first

    bookmark_manager = BookmarkManager.new(current_user)
    bookmark_manager.create(post_id: first_post.id)

    if bookmark_manager.errors.any?
      return render_json_error(bookmark_manager, status: 400)
    end

    render body: nil
  end

  def destroy
    topic = Topic.find_by(id: params[:id])
    guardian.ensure_can_delete!(topic)

    first_post = topic.ordered_posts.first
    PostDestroyer.new(current_user, first_post, context: params[:context]).destroy

    render body: nil
  rescue Discourse::InvalidAccess
    render_json_error I18n.t("delete_topic_failed")
  end

  def recover
    topic = Topic.where(id: params[:topic_id]).with_deleted.first
    guardian.ensure_can_recover_topic!(topic)

    first_post = topic.posts.with_deleted.order(:post_number).first
    PostDestroyer.new(current_user, first_post, context: params[:context]).recover

    render body: nil
  end

  def excerpt
    render body: nil
  end

  def remove_allowed_user
    params.require(:username)
    topic = Topic.find_by(id: params[:topic_id])
    raise Discourse::NotFound unless topic
    user = User.find_by(username: params[:username])
    raise Discourse::NotFound unless user

    guardian.ensure_can_remove_allowed_users!(topic, user)

    if topic.remove_allowed_user(current_user, user)
      render json: success_json
    else
      render json: failed_json, status: 422
    end
  end

  def remove_allowed_group
    params.require(:name)
    topic = Topic.find_by(id: params[:topic_id])
    guardian.ensure_can_remove_allowed_users!(topic)

    if topic.remove_allowed_group(current_user, params[:name])
      render json: success_json
    else
      render json: failed_json, status: 422
    end
  end

  def invite_notify
    topic = Topic.find_by(id: params[:topic_id])
    guardian.ensure_can_see!(topic)

    usernames = params[:usernames]
    raise Discourse::InvalidParameters.new(:usernames) if !usernames.kind_of?(Array) || (!current_user.staff? && usernames.size > 1)

    users = User.where(username_lower: usernames.map(&:downcase))
    raise Discourse::InvalidParameters.new(:usernames) if usernames.size != users.size

    topic.rate_limit_topic_invitation(current_user)

    users.find_each do |user|
      if !user.guardian.can_see_topic?(topic)
        return render json: failed_json.merge(error: I18n.t('topic_invite.user_cannot_see_topic', username: user.username)), status: 422
      end
    end

    users.find_each do |user|
      last_notification = user.notifications
        .where(notification_type: Notification.types[:invited_to_topic])
        .where(topic_id: topic.id)
        .where(post_number: 1)
        .where('created_at > ?', 1.hour.ago)

      if !last_notification.exists?
        topic.create_invite_notification!(user, Notification.types[:invited_to_topic], current_user.username)
      end
    end

    render json: success_json
  end

  def invite_group
    group = Group.find_by(name: params[:group])
    raise Discourse::NotFound unless group

    topic = Topic.find_by(id: params[:topic_id])

    unless pm_has_slots?(topic)
      return render_json_error(
        I18n.t("pm_reached_recipients_limit", recipients_limit: SiteSetting.max_allowed_message_recipients)
      )
    end

    if topic.private_message?
      guardian.ensure_can_invite_group_to_private_message!(group, topic)
      topic.invite_group(current_user, group)
      render_json_dump BasicGroupSerializer.new(group, scope: guardian, root: 'group')
    else
      render json: failed_json, status: 422
    end
  end

  def invite
    topic = Topic.find_by(id: params[:topic_id])
    raise Discourse::InvalidParameters.new unless topic

    username_or_email = params[:user] ? fetch_username : fetch_email

    groups = Group.lookup_groups(
      group_ids: params[:group_ids],
      group_names: params[:group_names]
    )

    unless pm_has_slots?(topic)
      return render_json_error(
        I18n.t("pm_reached_recipients_limit", recipients_limit: SiteSetting.max_allowed_message_recipients)
      )
    end

    guardian.ensure_can_invite_to!(topic)
    group_ids = groups.map(&:id)

    begin
      if topic.invite(current_user, username_or_email, group_ids, params[:custom_message])
        user = User.find_by_username_or_email(username_or_email)

        if user
          render_json_dump BasicUserSerializer.new(user, scope: guardian, root: 'user')
        else
          render json: success_json
        end
      else
        json = failed_json

        unless topic.private_message?
          group_names = topic.category
            .visible_group_names(current_user)
            .where(automatic: false)
            .pluck(:name)
            .join(", ")

          if group_names.present?
            json.merge!(errors: [
              I18n.t(
                "topic_invite.failed_to_invite",
                group_names: group_names
              )
            ])
          end
        end

        render json: json, status: 422
      end
    rescue Topic::UserExists, Topic::NotAllowed => e
      render json: { errors: [e.message] }, status: 422
    end
  end

  def set_notifications
    topic = Topic.find(params[:topic_id].to_i)
    TopicUser.change(current_user, topic.id, notification_level: params[:notification_level].to_i)
    render json: success_json
  end

  def merge_topic
    topic_id = params.require(:topic_id)
    destination_topic_id = params.require(:destination_topic_id)
    params.permit(:participants)
    params.permit(:archetype)

    raise Discourse::InvalidAccess if params[:archetype] == "private_message" && !guardian.is_staff?

    topic = Topic.find_by(id: topic_id)
    guardian.ensure_can_move_posts!(topic)

    destination_topic = Topic.find_by(id: destination_topic_id)
    guardian.ensure_can_create_post_on_topic!(destination_topic)

    args = {}
    args[:destination_topic_id] = destination_topic_id.to_i

    if params[:archetype].present?
      args[:archetype] = params[:archetype]
      args[:participants] = params[:participants] if params[:participants].present? && params[:archetype] == "private_message"
    end

    destination_topic = topic.move_posts(current_user, topic.posts.pluck(:id), args)
    render_topic_changes(destination_topic)
  end

  def move_posts
    post_ids = params.require(:post_ids)
    topic_id = params.require(:topic_id)
    params.permit(:category_id)
    params.permit(:tags)
    params.permit(:participants)
    params.permit(:archetype)

    raise Discourse::InvalidAccess if params[:archetype] == "private_message" && !guardian.is_staff?

    topic = Topic.with_deleted.find_by(id: topic_id)
    guardian.ensure_can_move_posts!(topic)

    if params[:title].present?
      # when creating a new topic, ensure the 1st post is a regular post
      if Post.where(topic: topic, id: post_ids).order(:post_number).pluck_first(:post_type) != Post.types[:regular]
        return render_json_error("When moving posts to a new topic, the first post must be a regular post.")
      end

      if params[:category_id].present?
        guardian.ensure_can_create_topic_on_category!(params[:category_id])
      end
    end

    destination_topic = move_posts_to_destination(topic)
    render_topic_changes(destination_topic)
  rescue ActiveRecord::RecordInvalid => ex
    render_json_error(ex)
  end

  def change_post_owners
    params.require(:post_ids)
    params.require(:topic_id)
    params.require(:username)

    guardian.ensure_can_change_post_owner!

    begin
      PostOwnerChanger.new(post_ids: params[:post_ids].to_a,
                           topic_id: params[:topic_id].to_i,
                           new_owner: User.find_by(username: params[:username]),
                           acting_user: current_user).change_owner!
      render json: success_json
    rescue ArgumentError
      render json: failed_json, status: 422
    end
  end

  def change_timestamps
    topic_id = params.require(:topic_id).to_i
    timestamp = params.require(:timestamp).to_f

    guardian.ensure_can_change_post_timestamps!

    topic = Topic.with_deleted.find(topic_id)
    previous_timestamp = topic.first_post.created_at

    begin
      TopicTimestampChanger.new(
        topic: topic,
        timestamp: timestamp
      ).change!

      StaffActionLogger.new(current_user).log_topic_timestamps_changed(topic, Time.zone.at(timestamp), previous_timestamp)

      render json: success_json
    rescue ActiveRecord::RecordInvalid, TopicTimestampChanger::InvalidTimestampError
      render json: failed_json, status: 422
    end
  end

  def clear_pin
    topic = Topic.find_by(id: params[:topic_id].to_i)
    guardian.ensure_can_see!(topic)
    topic.clear_pin_for(current_user)
    render body: nil
  end

  def re_pin
    topic = Topic.find_by(id: params[:topic_id].to_i)
    guardian.ensure_can_see!(topic)
    topic.re_pin_for(current_user)
    render body: nil
  end

  def timings
    allowed_params = topic_params

    topic_id = allowed_params[:topic_id].to_i
    topic_time = allowed_params[:topic_time].to_i
    timings = allowed_params[:timings].to_h || {}

    # ensure we capture current user for the block
    user = current_user

    hijack do
      PostTiming.process_timings(
        user,
        topic_id,
        topic_time,
        timings.map { |post_number, t| [post_number.to_i, t.to_i] },
        mobile: view_context.mobile_view?
      )
      render body: nil
    end
  end

  def feed
    raise Discourse::NotFound if !Post.exists?(topic_id: params[:topic_id])

    begin
      @topic_view = TopicView.new(params[:topic_id])
    rescue Discourse::NotLoggedIn
      raise Discourse::NotFound
    rescue Discourse::InvalidAccess => ex

      deleted = guardian.can_see_topic?(ex.obj, false) ||
        (!guardian.can_see_topic?(ex.obj) &&
         ex.obj&.access_topic_via_group &&
         ex.obj.deleted_at)

      raise Discourse::NotFound.new(
        nil,
        check_permalinks: deleted,
        original_path: ex.obj.relative_url
      )
    end

    discourse_expires_in 1.minute
    render 'topics/show', formats: [:rss]
  end

  def bulk
    if params[:topic_ids].present?
      unless Array === params[:topic_ids]
        raise Discourse::InvalidParameters.new(
          "Expecting topic_ids to contain a list of topic ids"
        )
      end
      topic_ids = params[:topic_ids].map { |t| t.to_i }
    elsif params[:filter] == 'unread'
      topic_ids = bulk_unread_topic_ids
    else
      raise ActionController::ParameterMissing.new(:topic_ids)
    end

    operation = params
      .require(:operation)
      .permit(:type, :group, :category_id, :notification_level_id, *DiscoursePluginRegistry.permitted_bulk_action_parameters, tags: [])
      .to_h.symbolize_keys

    raise ActionController::ParameterMissing.new(:operation_type) if operation[:type].blank?
    operator = TopicsBulkAction.new(current_user, topic_ids, operation, group: operation[:group])
    changed_topic_ids = operator.perform!
    render_json_dump topic_ids: changed_topic_ids
  end

  def private_message_reset_new
    topic_query = TopicQuery.new(current_user, limit: false)

    if params[:topic_ids].present?
      unless Array === params[:topic_ids]
        raise Discourse::InvalidParameters.new(
          "Expecting topic_ids to contain a list of topic ids"
        )
      end

      topic_scope = topic_query
        .private_messages_for(current_user, :all)
        .where("topics.id IN (?)", params[:topic_ids].map(&:to_i))
    else
      params.require(:inbox)
      inbox = params[:inbox].to_s
      filter = private_message_filter(topic_query, inbox)
      topic_scope = topic_query.filter_private_message_new(current_user, filter)
    end

    topic_ids = TopicsBulkAction.new(
      current_user,
      topic_scope.pluck(:id),
      type: "dismiss_topics"
    ).perform!

    render json: success_json.merge(topic_ids: topic_ids)
  end

  def reset_new
    topic_scope =
      if params[:category_id].present?
        category_ids = [params[:category_id]]
        if params[:include_subcategories] == 'true'
          category_ids = category_ids.concat(Category.where(parent_category_id: params[:category_id]).pluck(:id))
        end

        scope = Topic.where(category_id: category_ids)
        scope = scope.joins(:tags).where(tags: { name: params[:tag_id] }) if params[:tag_id]
        scope
      elsif params[:tag_id].present?
        Topic.joins(:tags).where(tags: { name: params[:tag_id] })
      else
        new_results = TopicQuery.new(current_user).new_results(limit: false)
        if params[:tracked].to_s == "true"
          TopicQuery.tracked_filter(new_results, current_user.id)
        else
          current_user.user_stat.update_column(:new_since, Time.zone.now)
          new_results
        end
      end

    if params[:topic_ids].present?
      unless Array === params[:topic_ids]
        raise Discourse::InvalidParameters.new(
          "Expecting topic_ids to contain a list of topic ids"
        )
      end

      topic_ids = params[:topic_ids].map { |t| t.to_i }
      topic_scope = topic_scope.where(id: topic_ids)
    end

    dismissed_topic_ids = TopicsBulkAction.new(current_user, topic_scope.pluck(:id), type: "dismiss_topics").perform!
    TopicTrackingState.publish_dismiss_new(current_user.id, topic_ids: dismissed_topic_ids)

    render body: nil
  end

  def convert_topic
    params.require(:id)
    params.require(:type)
    topic = Topic.find_by(id: params[:id])
    guardian.ensure_can_convert_topic!(topic)

    if params[:type] == "public"
      converted_topic = topic.convert_to_public_topic(current_user, category_id: params[:category_id])
    else
      converted_topic = topic.convert_to_private_message(current_user)
    end
    render_topic_changes(converted_topic)
  rescue ActiveRecord::RecordInvalid => ex
    render_json_error(ex)
  end

  def reset_bump_date
    params.require(:id)
    guardian.ensure_can_update_bumped_at!

    topic = Topic.find_by(id: params[:id])
    raise Discourse::NotFound.new unless topic

    topic.reset_bumped_at
    render body: nil
  end

  def set_slow_mode
    topic = Topic.find(params[:topic_id])
    slow_mode_type = TopicTimer.types[:clear_slow_mode]
    timer = TopicTimer.find_by(topic: topic, status_type: slow_mode_type)

    guardian.ensure_can_moderate!(topic)
    topic.update!(slow_mode_seconds: params[:seconds])
    enabled = params[:seconds].to_i > 0

    time = enabled && params[:enabled_until].present? ? params[:enabled_until] : nil

    topic.set_or_create_timer(
      slow_mode_type,
      time,
      by_user: timer&.user
    )

    head :ok
  end

  private

  def topic_params
    params.permit(
      :topic_id,
      :topic_time,
      timings: {}
    )
  end

  def fetch_topic_view(options)
    if (username_filters = params[:username_filters]).present?
      options[:username_filters] = username_filters.split(',')
    end

    @topic_view = TopicView.new(params[:topic_id], current_user, options)
  end

  def toggle_mute
    @topic = Topic.find_by(id: params[:topic_id].to_i)
    guardian.ensure_can_see!(@topic)

    @topic.toggle_mute(current_user)
    render body: nil
  end

  def consider_user_for_promotion
    Promotion.new(current_user).review if current_user.present?
  end

  def slugs_do_not_match
    if SiteSetting.slug_generation_method != "encoded"
      params[:slug] && @topic_view.topic.slug != params[:slug]
    else
      params[:slug] && CGI.unescape(@topic_view.topic.slug) != params[:slug]
    end
  end

  def redirect_to_correct_topic(topic, post_number = nil)
    begin
      guardian.ensure_can_see!(topic)
    rescue Discourse::InvalidAccess => ex
      raise(SiteSetting.detailed_404 ? ex : Discourse::NotFound)
    end

    url = topic.relative_url
    url << "/#{post_number}" if post_number.to_i > 0
    url << ".json" if request.format.json?

    page = params[:page]
    url << "?page=#{page}" if page != 0

    redirect_to url, status: 301
  end

  def track_visit_to_topic
    topic_id =  @topic_view.topic.id
    ip = request.remote_ip
    user_id = (current_user.id if current_user)
    track_visit = should_track_visit_to_topic?

    if !request.format.json?
      hash = {
        referer: request.referer || flash[:referer],
        host: request.host,
        current_user: current_user,
        topic_id: @topic_view.topic.id,
        post_number: @topic_view.current_post_number,
        username: request['u'],
        ip_address: request.remote_ip
      }
      # defer this way so we do not capture the whole controller
      # in the closure
      TopicsController.defer_add_incoming_link(hash)
    end

    TopicsController.defer_track_visit(topic_id, ip, user_id, track_visit)
  end

  def self.defer_track_visit(topic_id, ip, user_id, track_visit)
    Scheduler::Defer.later "Track Visit" do
      TopicViewItem.add(topic_id, ip, user_id)
      TopicUser.track_visit!(topic_id, user_id) if track_visit
    end
  end

  def self.defer_add_incoming_link(hash)
    Scheduler::Defer.later "Track Link" do
      IncomingLink.add(hash)
    end
  end

  def should_track_visit_to_topic?
    !!((!request.format.json? || params[:track_visit]) && current_user)
  end

  def perform_show_response

    if request.head?
      head :ok
      return
    end

    topic_view_serializer = TopicViewSerializer.new(
      @topic_view,
      scope: guardian,
      root: false,
      include_raw: !!params[:include_raw],
      exclude_suggested_and_related: !!params[:replies_to_post_number] || !!params[:filter_upwards_post_id]
    )

    respond_to do |format|
      format.html do
        @tags = SiteSetting.tagging_enabled ? @topic_view.topic.tags : []
        @breadcrumbs = helpers.categories_breadcrumb(@topic_view.topic) || []
        @description_meta = @topic_view.topic.excerpt.present? ? @topic_view.topic.excerpt : @topic_view.summary
        store_preloaded("topic_#{@topic_view.topic.id}", MultiJson.dump(topic_view_serializer))
        render :show
      end

      format.json do
        render_json_dump(topic_view_serializer)
      end
    end
  end

  def render_topic_changes(dest_topic)
    if dest_topic.present?
      render json: { success: true, url: dest_topic.relative_url }
    else
      render json: { success: false }
    end
  end

  def move_posts_to_destination(topic)
    args = {}
    args[:title] = params[:title] if params[:title].present?
    args[:destination_topic_id] = params[:destination_topic_id].to_i if params[:destination_topic_id].present?
    args[:tags] = params[:tags] if params[:tags].present?

    if params[:archetype].present?
      args[:archetype] = params[:archetype]
      args[:participants] = params[:participants] if params[:participants].present? && params[:archetype] == "private_message"
    else
      args[:category_id] = params[:category_id].to_i if params[:category_id].present?
    end

    topic.move_posts(current_user, post_ids_including_replies, args)
  end

  def check_for_status_presence(key, attr)
    invalid_param(key) unless %w(pinned pinned_globally visible closed archived).include?(attr)
  end

  def invalid_param(key)
    raise Discourse::InvalidParameters.new(key.to_sym)
  end

  def fetch_username
    params.require(:user)
    params[:user]
  end

  def fetch_email
    params.require(:email)
    params[:email]
  end

  def pm_has_slots?(pm)
    guardian.is_staff? || !pm.reached_recipients_limit?
  end

  def bulk_unread_topic_ids
    topic_query = TopicQuery.new(current_user)

    if inbox = params[:private_message_inbox]
      filter = private_message_filter(topic_query, inbox)
      topic_query.options[:limit] = false
      topics = topic_query.filter_private_messages_unread(current_user, filter)
    else
      topics = TopicQuery.unread_filter(topic_query.joined_topic_user, staff: guardian.is_staff?).listable_topics
      topics = TopicQuery.tracked_filter(topics, current_user.id) if params[:tracked].to_s == "true"

      if params[:category_id]
        if params[:include_subcategories]
          topics = topics.where(<<~SQL, category_id: params[:category_id])
            category_id in (select id FROM categories WHERE parent_category_id = :category_id) OR
            category_id = :category_id
          SQL
        else
          topics = topics.where('category_id = ?', params[:category_id])
        end
      end

      if params[:tag_name].present?
        topics = topics.joins(:tags).where("tags.name": params[:tag_name])
      end
    end

    topics.pluck(:id)
  end

  def private_message_filter(topic_query, inbox)
    case inbox
    when "group"
      group_name = params[:group_name]
      group = Group.find_by("lower(name) = ?", group_name)
      raise Discourse::NotFound if !group
      raise Discourse::NotFound if !guardian.can_see_group_messages?(group)
      topic_query.options[:group_name] = group_name
      :group
    when "user"
      :user
    else
      :all
    end
  end
end