mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 04:02:30 +08:00
Previously, a tag whose only usage was in personal messages was silently dropped by `TagsController.tag_counts_json` — a display rule from 2020 meant to keep such tags off the `/tags` browse page for users who cannot tag messages (and `pm_tags_allowed_for_groups` has no staff bypass, so by default that includes admins). Every surface reusing that method as a plain serializer inherited the rule by accident: - the composer tag search treated the missing row as unauthorized and showed the tag disabled with a bogus **"Can't be used in this category"** reason (the reported bug), - every "show all tags" chooser (tag groups, synonyms, watched tags, category allowed tags, webhooks, automations, …) silently refused to offer such tags at all, - the `#` autocomplete would not suggest a tag that nonetheless cooked into a working hashtag link when typed in full. This change makes `tag_counts_json` a pure serializer and moves the rule into an explicit, named helper (`DiscourseTagging.without_pm_only_tags`) applied only where it belongs — the `/tags` browse lists — with an exemption for the admin "show all tags" view so the admin inventory is complete. Selection and search surfaces now offer every tag the user is allowed to use, and tag-group visibility rules still apply everywhere. It also fixes two adjacent inconsistencies uncovered along the way: - **Topic→message conversion counter drift.** Converting only adjusted `public_topic_count`, so a converted topic's tags kept working until the periodic consistency job recounted them into the broken state — the "worked at first, broke a day later" in the report. The converter now moves all three counters immediately, and rolls back cleanly when the underlying post revision fails (its return value was previously ignored, and `Topic#valid?` clears the errors it adds, so a failed conversion still applied its side effects). - **Crawler/print tag leak.** The crawler layout leaked a message's tag names in the page title and `og:article:tag` metadata to participants the serializer already hides tags from; both now flow through `TopicView#visible_tags`, gated on `guardian.can_see_tags?`. Reported in https://meta.discourse.org/t/407050
1720 lines
51 KiB
Ruby
Vendored
1720 lines
51 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
class TopicsController < ApplicationController
|
|
include TagParamLimit
|
|
include EmbedModeHandler
|
|
|
|
MAX_BULK_TOPIC_IDS = 1_000
|
|
|
|
requires_login only: %i[
|
|
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
|
|
remove_bookmarks
|
|
publish
|
|
reset_bump_date
|
|
set_slow_mode
|
|
]
|
|
|
|
before_action :consider_user_for_promotion, only: :show
|
|
before_action :set_embed_class, only: :show
|
|
after_action :allow_embed_mode, only: :show
|
|
|
|
around_action :hide_inaccessible_topic_when_detailed_404_disabled,
|
|
only: %i[
|
|
archive_message
|
|
move_to_inbox
|
|
post_ids
|
|
posts
|
|
publish
|
|
set_notifications
|
|
set_slow_mode
|
|
wordpress
|
|
]
|
|
|
|
skip_before_action :check_xhr, only: %i[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_by_external_id
|
|
topic = Topic.find_by(external_id: params[:external_id])
|
|
raise Discourse::NotFound unless topic
|
|
guardian.ensure_can_see!(topic)
|
|
redirect_to_correct_topic(topic, params[:post_number])
|
|
end
|
|
|
|
def show
|
|
if params[:id].is_a?(Array) || params[:id].is_a?(ActionController::Parameters)
|
|
raise Discourse::InvalidParameters.new("Show only accepts a single ID")
|
|
end
|
|
|
|
# TODO: 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] = begin
|
|
params[:page].to_i
|
|
rescue StandardError
|
|
1
|
|
end
|
|
|
|
opts =
|
|
params.slice(
|
|
:username_filters,
|
|
:filter,
|
|
:page,
|
|
:post_number,
|
|
:show_deleted,
|
|
:replies_to_post_number,
|
|
:filter_upwards_post_id,
|
|
:filter_top_level_replies,
|
|
)
|
|
username_filters = opts[:username_filters]
|
|
|
|
opts[:print] = true if params[:print] == "true"
|
|
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] =~ /\A\d+[^\d\\]+\z/
|
|
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 if SiteSetting.max_prints_per_hour_per_user.zero?
|
|
begin
|
|
unless @guardian.is_admin?
|
|
RateLimiter.new(
|
|
current_user,
|
|
"print-topic-per-hour",
|
|
SiteSetting.max_prints_per_hour_per_user,
|
|
1.hour,
|
|
).performed!
|
|
end
|
|
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
|
|
|
|
topic_id = params[:topic_id] || params[:id]
|
|
original_path =
|
|
if params[:slug].present? && topic_id.present?
|
|
"/t/#{params[:slug]}/#{topic_id}"
|
|
else
|
|
request.path
|
|
end
|
|
|
|
raise Discourse::NotFound.new(ex.message, check_permalinks: true, original_path:)
|
|
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: serialize_data(group, BasicGroupSerializer, root: false),
|
|
)
|
|
end
|
|
|
|
raise ex
|
|
else
|
|
raise Discourse::NotFound.new(
|
|
nil,
|
|
check_permalinks: deleted,
|
|
original_path: ex.obj.relative_url,
|
|
)
|
|
end
|
|
end
|
|
|
|
page = params[:page]
|
|
raise Discourse::NotFound if page < 0
|
|
|
|
if page > 1
|
|
visible_posts_count = @topic_view.filtered_posts.count
|
|
chunk_size = @topic_view.chunk_size
|
|
if (page - 1) * chunk_size >= visible_posts_count
|
|
last_page = visible_posts_count > 0 ? ((visible_posts_count - 1) / chunk_size) + 1 : 1
|
|
url = @topic_view.topic.relative_url
|
|
url += ".json" if request.format.json?
|
|
url += "?page=#{last_page}" if last_page > 1
|
|
return redirect_to url, status: :moved_permanently
|
|
end
|
|
end
|
|
|
|
discourse_expires_in 1.minute
|
|
|
|
if slugs_do_not_match ||
|
|
(!request.format.json? && params[:slug].nil? && @topic_view.topic.slug.present?)
|
|
redirect_to_correct_topic(@topic_view.topic, opts[:post_number])
|
|
return
|
|
end
|
|
|
|
# Nested topics render through the normal topic route. The Ember topic
|
|
# route decides whether to mount the flat post stream or the nested tree;
|
|
# crawlers still receive the flat topic HTML from this controller.
|
|
|
|
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
|
|
|
|
response.headers["X-Robots-Tag"] = "noindex" unless @topic_view.topic.visible
|
|
|
|
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,
|
|
)
|
|
|
|
begin
|
|
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,
|
|
}
|
|
rescue NoMethodError
|
|
raise Discourse::InvalidParameters
|
|
end
|
|
|
|
@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
|
|
.secured(guardian)
|
|
.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, :action_code, :created_at)
|
|
.map do |post_id, cooked, username, action_code, created_at|
|
|
attrs = {
|
|
post_id: post_id,
|
|
username: username,
|
|
excerpt: PrettyText.excerpt(cooked, 800, keep_emoji_images: true),
|
|
}
|
|
|
|
if action_code
|
|
attrs[:action_code] = action_code
|
|
attrs[:created_at] = created_at
|
|
end
|
|
|
|
attrs
|
|
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: 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
|
|
|
|
last_notification.update!(read: false) if last_notification
|
|
|
|
topic = Topic.find_by(id: topic_id)
|
|
|
|
if topic&.private_message?
|
|
topic_user = TopicUser.find_by(user: current_user, topic:)
|
|
|
|
PrivateMessageTopicTrackingState.publish_read(
|
|
topic_id,
|
|
topic_user&.last_read_post_number,
|
|
current_user,
|
|
topic_user&.notification_level,
|
|
)
|
|
end
|
|
|
|
render body: nil
|
|
end
|
|
|
|
def update_shared_draft
|
|
topic = Topic.find_by(id: params[:id])
|
|
guardian.ensure_can_edit!(topic)
|
|
|
|
category = Category.find_by(id: params[:category_id].to_i)
|
|
guardian.ensure_can_publish_topic!(topic, category)
|
|
|
|
row_count = SharedDraft.where(topic_id: topic.id).update_all(category_id: category.id)
|
|
SharedDraft.create(topic_id: topic.id, category_id: category.id) if row_count == 0
|
|
|
|
render json: success_json
|
|
end
|
|
|
|
def update
|
|
topic = Topic.find_by(id: params[:topic_id])
|
|
|
|
guardian.ensure_can_edit!(topic)
|
|
return if reject_too_many_tags!(:tags, :original_tags)
|
|
|
|
original_title = params[:original_title]
|
|
if original_title.present? && original_title != topic.title
|
|
return render_json_error(I18n.t("edit_conflict"), status: 409)
|
|
end
|
|
|
|
original_tag_ids = params[:original_tags]&.map { |t| t["id"] }&.sort
|
|
if original_tag_ids.present? && original_tag_ids != topic.tags.pluck(:id).sort
|
|
return render_json_error(I18n.t("edit_conflict"), status: 409)
|
|
end
|
|
|
|
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)
|
|
begin
|
|
guardian.ensure_can_move_topic_to_category!(category)
|
|
rescue Discourse::InvalidAccess
|
|
return(
|
|
render_json_error I18n.t("category.errors.move_topic_to_category_disallowed"),
|
|
status: :forbidden
|
|
)
|
|
end
|
|
else
|
|
return render_json_error(I18n.t("category.errors.not_found"))
|
|
end
|
|
|
|
if category
|
|
topic_tag_names = resolve_tag_names(topic)
|
|
|
|
if topic_tag_names.present?
|
|
allowed_tags =
|
|
DiscourseTagging.filter_allowed_tags(guardian, category: category).map(&:name)
|
|
|
|
invalid_tags = topic_tag_names - allowed_tags
|
|
|
|
# Do not raise an error on a topic's hidden tags when not modifying tags
|
|
if !params.has_key?(:tags)
|
|
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 { |f| changes[f] = params[f] if params.has_key?(f) }
|
|
|
|
changes.delete(:title) if topic.title == changes[:title]
|
|
changes.delete(:category_id) if topic.category_id.to_i == changes[:category_id].to_i
|
|
|
|
tags_submitted = Tag.include_tags? && changes.has_key?(:tags)
|
|
|
|
if tags_submitted
|
|
if changes[:tags].present?
|
|
incoming = changes[:tags]
|
|
|
|
if incoming.first.is_a?(String)
|
|
Discourse.deprecate(
|
|
"Passing tag names as strings to the tags param is deprecated, use tag objects ({id, name}) instead",
|
|
since: "2026.01",
|
|
drop_from: "2026.07",
|
|
)
|
|
end
|
|
|
|
changes.delete(:tags) if PostRevisor.tag_change_noop?(topic, incoming)
|
|
|
|
# resolve to name strings before passing to PostRevisor
|
|
changes[:tags] = resolve_tag_names(topic) if changes.has_key?(:tags)
|
|
elsif topic.tags.empty?
|
|
changes.delete(:tags)
|
|
end
|
|
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,
|
|
keep_existing_draft: params[:keep_existing_draft].to_s == "true",
|
|
)
|
|
|
|
topic.errors.add(:base, :unable_to_update) if !success && topic.errors.blank?
|
|
end
|
|
|
|
# this is used to return the title to the client as it may have been changed by "TextCleaner"
|
|
if !success
|
|
render_json_error(topic)
|
|
elsif tags_submitted
|
|
render_topic_with_tags(topic)
|
|
else
|
|
render_serialized(topic, BasicTopicSerializer)
|
|
end
|
|
end
|
|
|
|
def update_tags
|
|
topic = Topic.find_by(id: params[:topic_id])
|
|
guardian.ensure_can_edit_tags!(topic)
|
|
return if reject_too_many_tags!(:tags)
|
|
|
|
tags =
|
|
if params[:tags].is_a?(ActionController::Parameters)
|
|
params[:tags].values
|
|
else
|
|
params[:tags] || []
|
|
end
|
|
|
|
if tags.present? && tags.first.is_a?(String)
|
|
Discourse.deprecate(
|
|
"Passing tag names as strings to the tags param is deprecated, use tag objects ({id, name}) instead",
|
|
since: "2026.01",
|
|
drop_from: "2026.07",
|
|
)
|
|
end
|
|
|
|
unless PostRevisor.tag_change_noop?(topic, tags)
|
|
PostRevisor.new(topic.first_post, topic).revise!(
|
|
current_user,
|
|
{ tags: },
|
|
validate_post: false,
|
|
)
|
|
end
|
|
|
|
if topic.errors.blank?
|
|
render_topic_with_tags(topic)
|
|
else
|
|
render_json_error(topic)
|
|
end
|
|
end
|
|
|
|
def feature_stats
|
|
category_id = params[:category_id]&.to_i
|
|
|
|
visible_topics = Topic.listable_topics.visible.secured(guardian)
|
|
|
|
result = {
|
|
pinned_globally_count:
|
|
visible_topics.where(pinned_globally: true).where.not(pinned_at: nil).count,
|
|
banner_count:
|
|
Topic.listable_topics.secured(guardian).where(archetype: Archetype.banner).count,
|
|
}
|
|
|
|
if category_id
|
|
result[:pinned_in_category_count] = visible_topics
|
|
.where(category_id: category_id)
|
|
.where(pinned_globally: false)
|
|
.where.not(pinned_at: nil)
|
|
.count
|
|
end
|
|
|
|
render json: result
|
|
end
|
|
|
|
def status
|
|
params.require(:status)
|
|
params.require(:enabled)
|
|
params.permit(:until)
|
|
|
|
status = params[:status]
|
|
topic_id = params[:topic_id].to_i
|
|
enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled])
|
|
|
|
check_for_status_presence(:status, status)
|
|
@topic =
|
|
if params[:category_id]
|
|
Topic.find_by(id: topic_id, category_id: params[:category_id].to_i)
|
|
else
|
|
Topic.find_by(id: topic_id)
|
|
end
|
|
|
|
status_opts = { until: params[:until].presence }
|
|
|
|
if status == "visible"
|
|
status_opts[:visibility_reason_id] = (
|
|
if enabled
|
|
Topic.visibility_reasons[:manually_relisted]
|
|
else
|
|
Topic.visibility_reasons[:manually_unlisted]
|
|
end
|
|
)
|
|
end
|
|
|
|
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
|
|
|
|
@topic.update_status(status, enabled, current_user, status_opts)
|
|
|
|
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 StandardError
|
|
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_set_topic_timer!(topic)
|
|
|
|
guardian.ensure_can_delete!(topic) if TopicTimer.destructive_types.values.include?(status_type)
|
|
|
|
creating_timer = params[:time].present? || params[:duration_minutes].present?
|
|
|
|
if status_type == TopicTimer.types[:publish_to_category] && creating_timer
|
|
category = Category.find_by(id: params[:category_id])
|
|
raise Discourse::NotFound if !category
|
|
raise Discourse::InvalidAccess if !guardian.can_create_topic_on_category?(category)
|
|
if topic.private_message? && !guardian.can_convert_topic?(topic)
|
|
raise Discourse::InvalidAccess
|
|
end
|
|
end
|
|
|
|
options = { by_user: current_user, based_on_last_post: based_on_last_post }
|
|
|
|
options.merge!(category_id: params[:category_id]) if params[:category_id].present?
|
|
if params[:duration_minutes].present?
|
|
options.merge!(duration_minutes: params[:duration_minutes].to_i)
|
|
end
|
|
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 = find_visible_topic_from_topic_id
|
|
|
|
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)
|
|
guardian.ensure_can_see!(topic)
|
|
|
|
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 = find_visible_topic_from_topic_id
|
|
|
|
bookmark_manager = BookmarkManager.new(current_user)
|
|
bookmark_manager.create_for(bookmarkable_id: topic.id, bookmarkable_type: "Topic")
|
|
|
|
return render_json_error(bookmark_manager, status: 400) if bookmark_manager.errors.any?
|
|
|
|
render body: nil
|
|
end
|
|
|
|
def destroy
|
|
topic = Topic.with_deleted.find_by(id: params[:id])
|
|
force_destroy = ActiveModel::Type::Boolean.new.cast(params[:force_destroy])
|
|
|
|
if force_destroy
|
|
if !topic
|
|
raise Discourse::InvalidAccess
|
|
elsif !guardian.can_permanently_delete?(topic)
|
|
return render_json_error topic.cannot_permanently_delete_reason(current_user), status: 403
|
|
end
|
|
else
|
|
guardian.ensure_can_delete!(topic)
|
|
end
|
|
|
|
PostDestroyer.new(
|
|
current_user,
|
|
topic.ordered_posts.with_deleted.first,
|
|
context: params[:context].presence || I18n.t("staff_action_logs.api_post_delete"),
|
|
force_destroy: force_destroy,
|
|
).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: :unprocessable_entity
|
|
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: :unprocessable_entity
|
|
end
|
|
end
|
|
|
|
def invite_group
|
|
group = Group.find_by(name: params[:group])
|
|
raise Discourse::NotFound if !group
|
|
|
|
topic = Topic.find_by(id: params[:topic_id])
|
|
raise Discourse::NotFound if !topic
|
|
|
|
if !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)
|
|
should_notify =
|
|
if params[:should_notify].blank?
|
|
true
|
|
else
|
|
params[:should_notify].to_s == "true"
|
|
end
|
|
topic.invite_group(current_user, group, should_notify: should_notify)
|
|
render_json_dump BasicGroupSerializer.new(group, scope: guardian, root: "group")
|
|
else
|
|
render json: failed_json, status: :unprocessable_entity
|
|
end
|
|
end
|
|
|
|
def invite
|
|
topic = Topic.find_by(id: params[:topic_id])
|
|
raise Discourse::NotFound if !topic
|
|
|
|
return render_json_error(I18n.t("topic_invite.not_pm")) if !topic.private_message?
|
|
|
|
if !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)
|
|
|
|
username_or_email = params[:user] ? fetch_username : fetch_email
|
|
group_ids =
|
|
Group.lookup_groups(group_ids: params[:group_ids], group_names: params[:group_names]).pluck(
|
|
:id,
|
|
)
|
|
|
|
if Email.is_valid?(username_or_email)
|
|
if !guardian.can_invite_via_email?(topic)
|
|
return render(json: failed_json, status: :unprocessable_entity)
|
|
end
|
|
|
|
return render(json: success_json) if User.find_by_email(username_or_email)
|
|
end
|
|
|
|
begin
|
|
if topic.invite(current_user, username_or_email, group_ids, params[:custom_message])
|
|
if user = User.find_by_username_or_email(username_or_email)
|
|
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: :unprocessable_entity
|
|
end
|
|
rescue Topic::UserExists, Topic::NotAllowed => e
|
|
render json: { errors: [e.message] }, status: :unprocessable_entity
|
|
end
|
|
end
|
|
|
|
def set_notifications
|
|
user = fetch_target_user
|
|
|
|
topic = Topic.find(params[:topic_id].to_i)
|
|
guardian.ensure_can_see!(topic)
|
|
TopicUser.change(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(:chronological_order)
|
|
params.permit(:archetype)
|
|
params.permit(:freeze_original)
|
|
|
|
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
|
|
args[:chronological_order] = params[:chronological_order] == "true"
|
|
args[:freeze_original] = params[:freeze_original] == "true"
|
|
|
|
if params[:archetype].present?
|
|
args[:archetype] = params[:archetype]
|
|
args[:participants] = params[:participants] if params[:participants].present? &&
|
|
params[:archetype] == "private_message"
|
|
end
|
|
|
|
acting_user = current_user
|
|
hijack(info: "merging topic #{topic_id.inspect} into #{destination_topic_id.inspect}") do
|
|
destination_topic = topic.move_posts(acting_user, topic.posts.pluck(:id), args)
|
|
render_topic_changes(destination_topic)
|
|
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => ex
|
|
render_json_error(ex)
|
|
end
|
|
end
|
|
|
|
def move_posts
|
|
post_ids = params.require(:post_ids)
|
|
topic_id = params.require(:topic_id)
|
|
params.permit(:category_id)
|
|
params.permit(:tag_ids)
|
|
params.permit(:tags)
|
|
params.permit(:participants)
|
|
params.permit(:chronological_order)
|
|
params.permit(:archetype)
|
|
params.permit(:freeze_original)
|
|
|
|
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).pick(: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
|
|
|
|
if params[:destination_topic_id].present?
|
|
destination_topic = Topic.find_by(id: params[:destination_topic_id])
|
|
guardian.ensure_can_create_post_on_topic!(destination_topic)
|
|
end
|
|
|
|
hijack do
|
|
destination_topic = move_posts_to_destination(topic)
|
|
render_topic_changes(destination_topic)
|
|
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => ex
|
|
render_json_error(ex)
|
|
end
|
|
end
|
|
|
|
def change_post_owners
|
|
post_ids = params.require(:post_ids).map(&:to_i)
|
|
topic_id = params.require(:topic_id).to_i
|
|
username = params.require(:username)
|
|
|
|
guardian.ensure_can_change_post_owner!
|
|
|
|
begin
|
|
PostOwnerChanger.new(
|
|
post_ids:,
|
|
topic_id:,
|
|
new_owner: User.find_by(username:),
|
|
acting_user: current_user,
|
|
).change_owner!
|
|
render json: success_json
|
|
rescue ArgumentError
|
|
render json: failed_json, status: :unprocessable_entity
|
|
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: :unprocessable_entity
|
|
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_device?,
|
|
)
|
|
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
|
|
|
|
response.headers["X-Robots-Tag"] = "noindex, nofollow"
|
|
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 { |topic_id| topic_id.to_i }.uniq
|
|
if topic_ids.size > MAX_BULK_TOPIC_IDS
|
|
raise Discourse::InvalidParameters.new(
|
|
I18n.t("topics_bulk_action.too_many_topic_ids", limit: MAX_BULK_TOPIC_IDS),
|
|
)
|
|
end
|
|
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,
|
|
:message,
|
|
:silent,
|
|
:pinned_globally,
|
|
:pinned_until,
|
|
:remove_all_tags,
|
|
*DiscoursePluginRegistry.permitted_bulk_action_parameters,
|
|
tag_ids: [],
|
|
tags: [],
|
|
add_tag_ids: [],
|
|
remove_tag_ids: [],
|
|
replace_tags: %i[from_tag_id to_tag_id],
|
|
)
|
|
.to_h
|
|
.deep_symbolize_keys
|
|
|
|
%i[silent pinned_globally remove_all_tags].each do |key|
|
|
operation[key] = ActiveModel::Type::Boolean.new.cast(operation[key]) if operation.has_key?(
|
|
key,
|
|
)
|
|
end
|
|
|
|
raise ActionController::ParameterMissing.new(:operation_type) if operation[:type].blank?
|
|
|
|
operator = TopicsBulkAction.new(current_user, topic_ids, operation, group: operation[:group])
|
|
hijack(info: "topics bulk action #{operation[:type]}") do
|
|
changed_topic_ids = operator.perform!
|
|
result = { topic_ids: changed_topic_ids }
|
|
result[:errors] = operator.errors if operator.errors.present?
|
|
if operator.tag_category_errors.present?
|
|
result[:tag_category_errors] = operator.tag_category_errors
|
|
end
|
|
render_json_dump result
|
|
rescue Discourse::InvalidParameters => ex
|
|
render_json_error(ex, status: 400)
|
|
rescue ActiveRecord::RecordInvalid => ex
|
|
render_json_error(ex, type: :record_invalid, status: 422)
|
|
rescue Discourse::InvalidAccess
|
|
render_json_error(I18n.t("invalid_access"), type: :invalid_access, status: 403)
|
|
rescue Discourse::NotFound
|
|
render_json_error(I18n.t("not_found"), type: :not_found, status: 404)
|
|
end
|
|
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 current_user.unified_new_enabled?
|
|
if params[:dismiss_topics] && params[:dismiss_posts]
|
|
TopicQuery.new(current_user).new_and_unread_results(limit: false)
|
|
elsif params[:dismiss_topics]
|
|
TopicQuery.new(current_user).new_results(limit: false)
|
|
elsif params[:dismiss_posts]
|
|
TopicQuery.new(current_user).unread_results(limit: false)
|
|
else
|
|
Topic.none
|
|
end
|
|
else
|
|
TopicQuery.new(current_user).new_results(limit: false)
|
|
end
|
|
if tag_name = params[:tag_name]
|
|
tag_name = DiscourseTagging.visible_tags(guardian).where(name: tag_name).pluck(:name).first
|
|
end
|
|
|
|
topic_scope =
|
|
if params[:category_id].present?
|
|
category_id = params[:category_id].to_i
|
|
|
|
category_ids =
|
|
if ActiveModel::Type::Boolean.new.cast(params[:include_subcategories])
|
|
Category.subcategory_ids(category_id)
|
|
else
|
|
[category_id]
|
|
end
|
|
|
|
category_ids &= guardian.allowed_category_ids
|
|
if category_ids.blank?
|
|
scope = topic_scope.none
|
|
else
|
|
scope = topic_scope.where(category_id: category_ids)
|
|
scope = scope.joins(:tags).where(tags: { name: tag_name }) if tag_name
|
|
end
|
|
scope
|
|
elsif tag_name.present?
|
|
topic_scope.joins(:tags).where(tags: { name: tag_name })
|
|
else
|
|
if params[:tracked].to_s == "true"
|
|
TopicQuery.tracked_filter(topic_scope, current_user.id)
|
|
else
|
|
current_user.user_stat.update_column(:new_since, Time.zone.now)
|
|
topic_scope
|
|
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(&:to_i)
|
|
topic_scope = topic_scope.where(id: topic_ids)
|
|
end
|
|
|
|
dismissed_topic_ids = []
|
|
dismissed_post_topic_ids = []
|
|
|
|
if !current_user.unified_new_enabled? || params[:dismiss_topics]
|
|
dismissed_topic_ids =
|
|
TopicsBulkAction.new(current_user, topic_scope.pluck(:id), type: "dismiss_topics").perform!
|
|
end
|
|
|
|
if params[:dismiss_posts]
|
|
if params[:untrack]
|
|
dismissed_post_topic_ids =
|
|
TopicsBulkAction.new(
|
|
current_user,
|
|
topic_scope.pluck(:id),
|
|
type: "change_notification_level",
|
|
notification_level_id: NotificationLevels.topic_levels[:regular],
|
|
).perform!
|
|
else
|
|
dismissed_post_topic_ids =
|
|
TopicsBulkAction.new(current_user, topic_scope.pluck(:id), type: "dismiss_posts").perform!
|
|
end
|
|
end
|
|
|
|
render_json_dump topic_ids: dismissed_topic_ids.concat(dismissed_post_topic_ids).uniq
|
|
end
|
|
|
|
def convert_topic
|
|
params.require(:id)
|
|
params.require(:type)
|
|
|
|
topic = Topic.find_by(id: params[:id])
|
|
guardian.ensure_can_convert_topic!(topic)
|
|
|
|
topic =
|
|
if params[:type] == "public"
|
|
topic.convert_to_public_topic(current_user, category_id: params[:category_id])
|
|
else
|
|
topic.convert_to_private_message(current_user)
|
|
end
|
|
|
|
topic.errors.present? ? render_json_error(topic) : render_topic_changes(topic)
|
|
end
|
|
|
|
def reset_bump_date
|
|
params.require(:id)
|
|
params.permit(:post_id)
|
|
|
|
guardian.ensure_can_update_bumped_at!
|
|
|
|
topic = Topic.find_by(id: params[:id])
|
|
raise Discourse::NotFound.new unless topic
|
|
|
|
guardian.ensure_can_see!(topic)
|
|
|
|
topic.reset_bumped_at(params[:post_id])
|
|
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)
|
|
|
|
StaffActionLogger.new(current_user).log_topic_slow_mode(
|
|
topic,
|
|
enabled:,
|
|
seconds: params[:seconds],
|
|
until: time,
|
|
)
|
|
|
|
head :ok
|
|
end
|
|
|
|
private
|
|
|
|
def hide_inaccessible_topic_when_detailed_404_disabled
|
|
yield
|
|
rescue ActiveRecord::RecordNotFound
|
|
raise Discourse::NotFound
|
|
rescue Discourse::InvalidAccess => exception
|
|
raise exception if SiteSetting.detailed_404
|
|
raise exception if !requested_topic_hidden_from_user?(exception)
|
|
|
|
raise Discourse::NotFound
|
|
end
|
|
|
|
def requested_topic_hidden_from_user?(exception)
|
|
topic = exception.obj if exception.obj.is_a?(Topic)
|
|
topic ||= Topic.find_by(id: requested_topic_id)
|
|
|
|
topic.present? && !guardian.can_see?(topic)
|
|
end
|
|
|
|
def requested_topic_id
|
|
topic_id = params[:topic_id].presence || params[:id].presence
|
|
topic_id.to_s if topic_id.to_s.match?(/\A\d+\z/)
|
|
end
|
|
|
|
def render_topic_with_tags(topic)
|
|
payload = serialize_data(topic, BasicTopicSerializer)
|
|
payload[:tags] = topic
|
|
.visible_tags(guardian)
|
|
.map { |t| { id: t.id, name: t.name, slug: t.slug_for_url } }
|
|
render_json_dump(payload)
|
|
end
|
|
|
|
def resolve_tag_names(topic)
|
|
@resolved_tag_names ||=
|
|
if params[:tags].present?
|
|
incoming = params[:tags]
|
|
if incoming.first.is_a?(String)
|
|
Discourse.deprecate(
|
|
"Passing tag names as strings to the tags param is deprecated, use tag objects ({id, name}) instead",
|
|
since: "2026.01",
|
|
drop_from: "2026.07",
|
|
)
|
|
incoming.reject(&:empty?)
|
|
else
|
|
ids = incoming.filter_map { |t| t[:id]&.to_i }
|
|
names = incoming.filter_map { |t| t[:id].blank? && t[:name].presence }
|
|
names += Tag.visible(guardian).where(id: ids).pluck(:name) if ids.present?
|
|
names
|
|
end
|
|
elsif params.has_key?(:tags)
|
|
[]
|
|
else
|
|
topic.tags.pluck(:name)
|
|
end
|
|
end
|
|
|
|
def topic_params
|
|
params.permit(:topic_id, :topic_time, timings: {})
|
|
end
|
|
|
|
def find_visible_topic_from_topic_id
|
|
topic = Topic.find_by(id: params[:topic_id].to_i)
|
|
raise Discourse::NotFound unless guardian.can_see?(topic)
|
|
|
|
topic
|
|
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
|
|
|
|
# Allow plugins to append allowed query parameters, so they aren't scrubbed on redirect to proper topic URL
|
|
additional_allowed_query_parameters =
|
|
DiscoursePluginRegistry.apply_modifier(
|
|
:redirect_to_correct_topic_additional_query_parameters,
|
|
[],
|
|
)
|
|
|
|
opts =
|
|
params.slice(
|
|
*%i[page print filter_top_level_replies preview_theme_id include_raw].concat(
|
|
additional_allowed_query_parameters,
|
|
),
|
|
)
|
|
opts.delete(:page) if params[:page] == 0
|
|
|
|
url = topic.relative_url
|
|
url << "/#{post_number}" if post_number.to_i > 0
|
|
url << ".json" if request.format.json?
|
|
|
|
opts.each do |k, v|
|
|
s = url.include?("?") ? "&" : "?"
|
|
url << "#{s}#{k}=#{v}"
|
|
end
|
|
|
|
redirect_to url, status: :moved_permanently
|
|
end
|
|
|
|
def track_visit_to_topic
|
|
topic_id = @topic_view.topic.id
|
|
user_id = (current_user.id if current_user)
|
|
|
|
if !request.format.json?
|
|
hash = {
|
|
referer: request.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, user_id) if should_track_visit_to_topic?
|
|
end
|
|
|
|
def self.defer_track_visit(topic_id, user_id)
|
|
Scheduler::Defer.later "Track Visit" do
|
|
TopicUser.track_visit!(topic_id, user_id)
|
|
end
|
|
end
|
|
|
|
def self.defer_topic_view(topic_id, ip, user_id = nil)
|
|
Scheduler::Defer.later "Topic View" do
|
|
topic = Topic.find_by(id: topic_id)
|
|
next if topic.blank?
|
|
next if topic.shared_draft?
|
|
|
|
# We need to make sure that we aren't allowing recording
|
|
# random topic views against topics the user cannot see.
|
|
user = User.find_by(id: user_id) if user_id.present?
|
|
next if user_id.present? && user.blank?
|
|
next if !Guardian.new(user).can_see_topic?(topic)
|
|
|
|
TopicViewItem.add(topic_id, ip, user_id)
|
|
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
|
|
|
|
if params[:replies_to_post_number] || params[:filter_upwards_post_id] ||
|
|
params[:filter_top_level_replies] || @topic_view.next_page.present?
|
|
@topic_view.include_suggested = false
|
|
@topic_view.include_related = false
|
|
end
|
|
|
|
topic_view_serializer =
|
|
TopicViewSerializer.new(
|
|
@topic_view,
|
|
scope: guardian,
|
|
root: false,
|
|
include_raw: !!params[:include_raw],
|
|
)
|
|
|
|
respond_to do |format|
|
|
format.html do
|
|
@tags = @topic_view.visible_tags
|
|
|
|
if SiteSetting.content_localization_enabled && use_crawler_layout?
|
|
helpers.localize_topic_view_content(@topic_view)
|
|
end
|
|
@breadcrumbs = helpers.categories_breadcrumb(@topic_view.topic) || []
|
|
@description_meta = @topic_view.topic.plain_text_excerpt || @topic_view.summary
|
|
store_preloaded("topic_#{@topic_view.topic.id}", MultiJson.dump(topic_view_serializer))
|
|
render :show
|
|
end
|
|
|
|
format.json { render_json_dump(topic_view_serializer) }
|
|
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?
|
|
if params[:tag_ids].present?
|
|
args[:tag_ids] = params[:tag_ids]
|
|
elsif params[:tags].present?
|
|
Discourse.deprecate(
|
|
"the tags param for move_posts is deprecated, use tag_ids instead",
|
|
since: "2026.01",
|
|
drop_from: "2026.07",
|
|
)
|
|
args[:tags] = params[:tags]
|
|
end
|
|
args[:chronological_order] = params[:chronological_order] == "true"
|
|
args[:freeze_original] = true if params[:freeze_original] == "true"
|
|
|
|
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) if %w[pinned pinned_globally visible closed archived].exclude?(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,
|
|
whisperer: guardian.is_whisperer?,
|
|
).listable_topics
|
|
|
|
topics = TopicQuery.tracked_filter(topics, current_user.id) if params[:tracked].to_s == "true"
|
|
|
|
if params[:category_id]
|
|
category_ids =
|
|
if params[:include_subcategories]
|
|
Category.subcategory_ids(params[:category_id].to_i)
|
|
else
|
|
params[:category_id]
|
|
end
|
|
|
|
topics = topics.where(category_id: category_ids)
|
|
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
|