mirror of
https://github.com/discourse/discourse.git
synced 2026-08-11 02:59:07 +08:00
## Summary Assignment notes longer than the configured maximum post length are now rejected server-side before persistence. The guard in `Assigner` returns a clear error message and prevents the oversized note from being stored on the assignment or appearing in the moderator tracking post, maintaining the expected content boundary across the JSON API and bulk assignment endpoints. ## Source - Patch Triage: https://patch.discourse.org/patch-triage/1574 Co-authored-by: discourse-patch-triage <272280883+discourse-patch-triage[bot]@users.noreply.github.com>
605 lines
18 KiB
Ruby
Vendored
605 lines
18 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
require "email/sender"
|
|
require "nokogiri"
|
|
|
|
require_relative "discourse_assign/assignment_permissions"
|
|
|
|
class ::Assigner
|
|
ASSIGNMENTS_PER_TOPIC_LIMIT = 5
|
|
|
|
def self.backfill_auto_assign
|
|
staff_mention =
|
|
User
|
|
.assign_allowed
|
|
.pluck("username")
|
|
.map { |name| "p.cooked ILIKE '%mention%@#{name}%'" }
|
|
.join(" OR ")
|
|
|
|
sql = <<~SQL
|
|
SELECT p.topic_id, MAX(post_number) post_number
|
|
FROM posts p
|
|
JOIN topics t ON t.id = p.topic_id
|
|
LEFT JOIN assignments a ON a.target_id = p.topic_id AND a.target_type = 'Topic'
|
|
WHERE p.user_id IN (SELECT id FROM users WHERE moderator OR admin)
|
|
AND (#{staff_mention})
|
|
AND a.assigned_to_id IS NULL
|
|
AND NOT t.closed
|
|
AND t.deleted_at IS NULL
|
|
GROUP BY p.topic_id
|
|
SQL
|
|
|
|
puts
|
|
assigned = 0
|
|
|
|
ActiveRecord::Base
|
|
.connection
|
|
.raw_connection
|
|
.exec(sql)
|
|
.to_a
|
|
.each do |row|
|
|
post = Post.find_by(post_number: row["post_number"].to_i, topic_id: row["topic_id"].to_i)
|
|
assigned += 1 if post && auto_assign(post)
|
|
putc "."
|
|
end
|
|
|
|
puts
|
|
puts "#{assigned} topics where automatically assigned to staff members"
|
|
end
|
|
|
|
def self.assigned_self?(text)
|
|
return false if text.blank? || SiteSetting.assign_self_regex.blank?
|
|
regex =
|
|
begin
|
|
Regexp.new(SiteSetting.assign_self_regex)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
!!(regex && text[regex])
|
|
end
|
|
|
|
def self.assigned_other?(text)
|
|
return false if text.blank? || SiteSetting.assign_other_regex.blank?
|
|
regex =
|
|
begin
|
|
Regexp.new(SiteSetting.assign_other_regex)
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
!!(regex && text[regex])
|
|
end
|
|
|
|
def self.auto_assign(post, force: false)
|
|
return unless SiteSetting.assigns_by_staff_mention
|
|
|
|
if post.user && post.topic && post.user.can_assign?(post.topic)
|
|
return if post.topic.assignment.present? && !force
|
|
|
|
# remove quotes, oneboxes and code blocks
|
|
doc = Nokogiri::HTML5.fragment(post.cooked)
|
|
doc.css(".quote, .onebox, pre, code").remove
|
|
text = doc.text.strip
|
|
|
|
assign_other = assigned_other?(text) && mentioned_staff(post)
|
|
assign_self = assigned_self?(text) && post.user
|
|
return unless assign_other || assign_self
|
|
|
|
if is_last_staff_post?(post)
|
|
assigner = new(post.topic, post.user)
|
|
if assign_other
|
|
assigner.assign(assign_other, skip_small_action_post: true)
|
|
elsif assign_self
|
|
assigner.assign(assign_self, skip_small_action_post: true)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.is_last_staff_post?(post)
|
|
allowed_user_ids =
|
|
DiscourseAssign::AssignmentPermissions.allowed_user_ids_for_target(post.topic)
|
|
return false if allowed_user_ids.empty?
|
|
|
|
Post.where(topic_id: post.topic_id, user_id: allowed_user_ids).maximum(:post_number) ==
|
|
post.post_number
|
|
end
|
|
|
|
def self.mentioned_staff(post)
|
|
mentions = post.raw_mentions
|
|
if mentions.present?
|
|
User
|
|
.human_users
|
|
.where(id: DiscourseAssign::AssignmentPermissions.allowed_user_ids_for_target(post.topic))
|
|
.where(username_lower: mentions.map(&:downcase))
|
|
.first
|
|
end
|
|
end
|
|
|
|
def self.failure_message(reason, assign_to)
|
|
name = assign_to.is_a?(User) ? assign_to.username : assign_to&.name
|
|
|
|
I18n.t(
|
|
"discourse_assign.#{reason}",
|
|
username: name,
|
|
group: name,
|
|
limit: ASSIGNMENTS_PER_TOPIC_LIMIT,
|
|
max: SiteSetting.max_assigned_topics,
|
|
note_max: SiteSetting.max_post_length,
|
|
)
|
|
end
|
|
|
|
def self.publish_topic_tracking_state(topic, user_id)
|
|
if topic.private_message?
|
|
MessageBus.publish("/private-messages/assigned", { topic_id: topic.id }, user_ids: [user_id])
|
|
end
|
|
end
|
|
|
|
def initialize(target, user)
|
|
@assigned_by = user
|
|
@target = target
|
|
end
|
|
|
|
def allowed_user_ids
|
|
@allowed_user_ids ||=
|
|
DiscourseAssign::AssignmentPermissions.allowed_user_ids_for_target(@target)
|
|
end
|
|
|
|
def allowed_group_ids
|
|
@allowed_group_ids ||=
|
|
DiscourseAssign::AssignmentPermissions.allowed_group_ids_for_target(@assigned_by, @target)
|
|
end
|
|
|
|
def can_assign_to?(assign_to)
|
|
return true if assign_to.is_a?(Group)
|
|
return true if @assigned_by.id == assign_to.id
|
|
|
|
assigned_total =
|
|
Assignment
|
|
.joins_with_topics
|
|
.where(topics: { deleted_at: nil })
|
|
.where(assigned_to_id: assign_to.id, active: true)
|
|
.count
|
|
|
|
assigned_total < SiteSetting.max_assigned_topics
|
|
end
|
|
|
|
def can_be_assigned?(assign_to)
|
|
if assign_to.is_a?(User)
|
|
allowed_user_ids.include?(assign_to.id)
|
|
else
|
|
allowed_group_ids.include?(assign_to.id)
|
|
end
|
|
end
|
|
|
|
def topic_target?
|
|
@topic_target ||= @target.is_a?(Topic)
|
|
end
|
|
|
|
def post_target?
|
|
@post_target ||= @target.is_a?(Post)
|
|
end
|
|
|
|
def private_message_allowed_user_ids
|
|
@private_message_allowed_user_ids ||= topic.all_allowed_users.pluck(:id)
|
|
end
|
|
|
|
def can_assignee_see_target?(assignee)
|
|
if (topic_target? || post_target?) && topic.private_message? &&
|
|
!private_message_allowed_user_ids.include?(assignee.id)
|
|
return false
|
|
end
|
|
return Guardian.new(assignee).can_see_topic?(@target) if topic_target?
|
|
return Guardian.new(assignee).can_see_post?(@target) if post_target?
|
|
|
|
raise Discourse::InvalidAccess
|
|
end
|
|
|
|
def topic
|
|
return @topic if @topic
|
|
@topic = @target if topic_target?
|
|
@topic = @target.topic if post_target?
|
|
|
|
raise Discourse::InvalidParameters if !@topic
|
|
@topic
|
|
end
|
|
|
|
def first_post
|
|
topic.posts.where(post_number: 1).first
|
|
end
|
|
|
|
def forbidden_reasons(assign_to:, type:, note:, status:, allow_self_reassign:)
|
|
case
|
|
when assign_to.is_a?(User) && !can_assignee_see_target?(assign_to)
|
|
if topic.private_message?
|
|
:forbidden_assignee_not_pm_participant
|
|
else
|
|
:forbidden_assignee_cant_see_topic
|
|
end
|
|
when assign_to.is_a?(Group) && assign_to.users.any? { |user| !can_assignee_see_target?(user) }
|
|
if topic.private_message?
|
|
:forbidden_group_assignee_not_pm_participant
|
|
else
|
|
:forbidden_group_assignee_cant_see_topic
|
|
end
|
|
when !can_be_assigned?(assign_to)
|
|
assign_to.is_a?(User) ? :forbidden_assign_to : :forbidden_group_assign_to
|
|
when note.present? && note.length > SiteSetting.max_post_length
|
|
:assignment_note_too_long
|
|
when !allow_self_reassign && already_assigned?(assign_to, type, note, status)
|
|
assign_to.is_a?(User) ? :already_assigned : :group_already_assigned
|
|
when Assignment.where(topic: topic, active: true).count >= ASSIGNMENTS_PER_TOPIC_LIMIT &&
|
|
!reassign?
|
|
:too_many_assigns_for_topic
|
|
when !can_assign_to?(assign_to)
|
|
:too_many_assigns
|
|
end
|
|
end
|
|
|
|
def update_details(assign_to, note, status, skip_small_action_post: false, should_notify: true)
|
|
case
|
|
when note.present? && status.present? && @target.assignment.note != note &&
|
|
@target.assignment.status != status
|
|
small_action_text = <<~TEXT
|
|
Status: #{@target.assignment.status} → #{status}
|
|
|
|
#{note}
|
|
TEXT
|
|
change_type = "details"
|
|
when note.present? && @target.assignment.note != note
|
|
small_action_text = note
|
|
change_type = "note"
|
|
when @target.assignment.status != status
|
|
small_action_text = "#{@target.assignment.status} → #{status}"
|
|
change_type = "status"
|
|
end
|
|
|
|
@target.assignment.update!(note: note, status: status)
|
|
queue_notification(@target.assignment) if should_notify
|
|
@target.assignment.publish_topic_assignment
|
|
|
|
# email is skipped, for now
|
|
|
|
unless skip_small_action_post
|
|
action_code = "#{change_type}_change"
|
|
add_small_action_post(action_code, assign_to, small_action_text)
|
|
end
|
|
|
|
{ success: true }
|
|
end
|
|
|
|
def assign(
|
|
assign_to,
|
|
note: nil,
|
|
skip_small_action_post: false,
|
|
status: nil,
|
|
allow_self_reassign: false,
|
|
should_notify: true
|
|
)
|
|
return { success: false, reason: :no_assignee } if assign_to.blank?
|
|
|
|
assigned_to_type = assign_to.is_a?(User) ? "User" : "Group"
|
|
|
|
if !guardian.can_assign?(@target)
|
|
return { success: false, reason: :forbidden_assigner_not_allowed }
|
|
end
|
|
|
|
if topic.private_message? && SiteSetting.invite_on_assign
|
|
group = assign_to.is_a?(Group)
|
|
|
|
begin
|
|
group ? invite_group(assign_to, should_notify) : invite_user(assign_to)
|
|
rescue Discourse::InvalidAccess,
|
|
Topic::NotAllowed,
|
|
Topic::UserExists,
|
|
RateLimiter::LimitExceeded
|
|
reason =
|
|
if group
|
|
:forbidden_group_assignee_not_pm_participant
|
|
else
|
|
:forbidden_assignee_not_pm_participant
|
|
end
|
|
return { success: false, reason: }
|
|
end
|
|
end
|
|
|
|
forbidden_reason =
|
|
forbidden_reasons(
|
|
assign_to: assign_to,
|
|
type: assigned_to_type,
|
|
note: note,
|
|
status: status,
|
|
allow_self_reassign: allow_self_reassign,
|
|
)
|
|
return { success: false, reason: forbidden_reason } if forbidden_reason
|
|
|
|
if no_assignee_change?(assign_to) && details_change?(note, status)
|
|
return(
|
|
update_details(
|
|
assign_to,
|
|
note,
|
|
status,
|
|
skip_small_action_post: skip_small_action_post,
|
|
should_notify: should_notify,
|
|
)
|
|
)
|
|
end
|
|
|
|
action_code = {}
|
|
action_code[:user] = topic.assignment.present? ? "reassigned" : "assigned"
|
|
action_code[:group] = topic.assignment.present? ? "reassigned_group" : "assigned_group"
|
|
|
|
skip_small_action_post =
|
|
skip_small_action_post || (!allow_self_reassign && no_assignee_change?(assign_to))
|
|
|
|
if @target.assignment
|
|
Jobs.enqueue(
|
|
:unassign_notification,
|
|
topic_id: topic.id,
|
|
assigned_to_id: @target.assignment.assigned_to_id,
|
|
assigned_to_type: @target.assignment.assigned_to_type,
|
|
assignment_id: @target.assignment.id,
|
|
)
|
|
@target.assignment.destroy!
|
|
end
|
|
|
|
assignment =
|
|
@target.create_assignment!(
|
|
assigned_to: assign_to,
|
|
assigned_by_user: @assigned_by,
|
|
topic: topic,
|
|
note: note,
|
|
status: status,
|
|
)
|
|
|
|
first_post&.publish_change_to_clients!(:revised, reload_topic: true)
|
|
queue_notification(assignment) if should_notify
|
|
|
|
# This assignment should never be notified
|
|
SilencedAssignment.create!(assignment_id: assignment.id) if !should_notify
|
|
|
|
assignment.publish_topic_assignment
|
|
|
|
if assignment.assigned_to_user?
|
|
if !assign_to.user_option.do_nothing_when_assigned?
|
|
notification_level =
|
|
if assign_to.user_option.track_topic_when_assigned?
|
|
TopicUser.notification_levels[:tracking]
|
|
else
|
|
TopicUser.notification_levels[:watching]
|
|
end
|
|
|
|
topic_user = TopicUser.find_by(user_id: assign_to.id, topic:)
|
|
if !topic_user || topic_user.notification_level < notification_level
|
|
notifications_reason_id = TopicUser.notification_reasons[:plugin_changed]
|
|
TopicUser.change(assign_to.id, topic.id, notification_level:, notifications_reason_id:)
|
|
end
|
|
end
|
|
|
|
if SiteSetting.assign_mailer == AssignMailer.levels[:always] ||
|
|
(
|
|
SiteSetting.assign_mailer == AssignMailer.levels[:different_users] &&
|
|
@assigned_by.id != assign_to.id
|
|
)
|
|
if !topic.muted?(assign_to)
|
|
message = AssignMailer.send_assignment(assign_to.email, topic, @assigned_by)
|
|
Email::Sender.new(message, :assign_message).send
|
|
end
|
|
end
|
|
end
|
|
|
|
unless skip_small_action_post
|
|
post_action_code = moderator_post_assign_action_code(assignment, action_code)
|
|
add_small_action_post(post_action_code, assign_to, note)
|
|
end
|
|
|
|
# Create a webhook event
|
|
if WebHook.active_web_hooks(:assigned).exists?
|
|
assigned_to_type = :assigned
|
|
payload = {
|
|
type: assigned_to_type,
|
|
topic_id: topic.id,
|
|
topic_title: topic.title,
|
|
assigned_by_id: @assigned_by.id,
|
|
assigned_by_username: @assigned_by.username,
|
|
}
|
|
if assignment.assigned_to_user?
|
|
payload.merge!({ assigned_to_id: assign_to.id, assigned_to_username: assign_to.username })
|
|
else
|
|
payload.merge!(
|
|
{ assigned_to_group_id: assign_to.id, assigned_to_group_name: assign_to.name },
|
|
)
|
|
end
|
|
WebHook.enqueue_assign_hooks(assigned_to_type, payload.to_json)
|
|
end
|
|
|
|
DiscourseEvent.trigger(:assigned, assignment)
|
|
|
|
{ success: true }
|
|
end
|
|
|
|
def unassign(silent: false, deactivate: false)
|
|
if assignment = @target.assignment
|
|
deactivate ? assignment.update!(active: false) : assignment.destroy!
|
|
|
|
return if first_post.blank?
|
|
|
|
first_post.publish_change_to_clients!(:revised, reload_topic: true)
|
|
|
|
Jobs.enqueue(
|
|
:unassign_notification,
|
|
topic_id: topic.id,
|
|
assigned_to_id: assignment.assigned_to.id,
|
|
assigned_to_type: assignment.assigned_to_type,
|
|
assignment_id: assignment.id,
|
|
)
|
|
|
|
assigned_to = assignment.assigned_to
|
|
|
|
if SiteSetting.unassign_creates_tracking_post && !silent
|
|
post_type = SiteSetting.assigns_public ? Post.types[:small_action] : Post.types[:whisper]
|
|
|
|
custom_fields = small_action_username_or_name(assigned_to)
|
|
|
|
if post_target?
|
|
custom_fields.merge!("action_code_path" => "/p/#{@target.id}")
|
|
custom_fields.merge!("action_code_post_id" => @target.id)
|
|
end
|
|
|
|
topic.add_moderator_post(
|
|
@assigned_by,
|
|
nil,
|
|
bump: false,
|
|
post_type: post_type,
|
|
custom_fields: custom_fields,
|
|
action_code: moderator_post_unassign_action_code(assignment),
|
|
)
|
|
end
|
|
|
|
# Create a webhook event
|
|
if WebHook.active_web_hooks(:unassigned).exists?
|
|
type = :unassigned
|
|
payload = {
|
|
type: type,
|
|
topic_id: topic.id,
|
|
topic_title: topic.title,
|
|
unassigned_by_id: @assigned_by.id,
|
|
unassigned_by_username: @assigned_by.username,
|
|
}
|
|
if assignment.assigned_to_user?
|
|
payload.merge!(
|
|
{ unassigned_to_id: assigned_to.id, unassigned_to_username: assigned_to.username },
|
|
)
|
|
else
|
|
payload.merge!(
|
|
{ unassigned_to_group_id: assigned_to.id, unassigned_to_group_name: assigned_to.name },
|
|
)
|
|
end
|
|
WebHook.enqueue_assign_hooks(type, payload.to_json)
|
|
end
|
|
|
|
if allowed_user_ids.present?
|
|
MessageBus.publish(
|
|
"/staff/topic-assignment",
|
|
{
|
|
type: "unassigned",
|
|
topic_id: topic.id,
|
|
post_id: post_target? && @target.id,
|
|
post_number: post_target? && @target.post_number,
|
|
assigned_type: assignment.assigned_to.is_a?(User) ? "User" : "Group",
|
|
assignment_note: nil,
|
|
assignment_status: nil,
|
|
},
|
|
user_ids: allowed_user_ids,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def invite_user(user)
|
|
return if topic.all_allowed_users.exists?(id: user.id)
|
|
|
|
guardian.ensure_can_invite_to!(topic)
|
|
topic.invite(@assigned_by, user.username)
|
|
end
|
|
|
|
def invite_group(group, should_notify)
|
|
return if topic.topic_allowed_groups.exists?(group_id: group.id)
|
|
if topic
|
|
.all_allowed_users
|
|
.joins("RIGHT JOIN group_users ON group_users.user_id = users.id")
|
|
.where("group_users.group_id = ? AND users.id IS NULL", group.id)
|
|
.empty?
|
|
return # all group members can already see the topic
|
|
end
|
|
|
|
guardian.ensure_can_invite_group_to_private_message!(group, topic)
|
|
topic.invite_group(@assigned_by, group, should_notify: should_notify)
|
|
end
|
|
|
|
def guardian
|
|
@guardian ||= Guardian.new(@assigned_by)
|
|
end
|
|
|
|
def queue_notification(assignment)
|
|
Jobs.enqueue(:assign_notification, assignment_id: assignment.id)
|
|
end
|
|
|
|
def small_action_username_or_name(assign_to)
|
|
if (assign_to.is_a?(User) && SiteSetting.prioritize_full_name_in_ux) ||
|
|
!assign_to.try(:username)
|
|
custom_fields = { "action_code_who" => assign_to.name || assign_to.username }
|
|
else
|
|
custom_fields = {
|
|
"action_code_who" => assign_to.is_a?(User) ? assign_to.username : assign_to.name,
|
|
}
|
|
end
|
|
custom_fields
|
|
end
|
|
|
|
def add_small_action_post(action_code, assign_to, text)
|
|
custom_fields = small_action_username_or_name(assign_to)
|
|
|
|
if post_target?
|
|
custom_fields.merge!(
|
|
{ "action_code_path" => "/p/#{@target.id}", "action_code_post_id" => @target.id },
|
|
)
|
|
end
|
|
|
|
topic.add_moderator_post(
|
|
@assigned_by,
|
|
text,
|
|
bump: false,
|
|
auto_track: false,
|
|
post_type: SiteSetting.assigns_public ? Post.types[:small_action] : Post.types[:whisper],
|
|
action_code: action_code,
|
|
custom_fields: custom_fields,
|
|
)
|
|
end
|
|
|
|
def moderator_post_assign_action_code(assignment, action_code)
|
|
if assignment.target.is_a?(Post)
|
|
# posts do not have to handle conditions of 'assign' or 'reassign'
|
|
assignment.assigned_to_user? ? "assigned_to_post" : "assigned_group_to_post"
|
|
elsif assignment.target.is_a?(Topic)
|
|
assignment.assigned_to_user? ? "#{action_code[:user]}" : "#{action_code[:group]}"
|
|
end
|
|
end
|
|
|
|
def moderator_post_unassign_action_code(assignment)
|
|
suffix =
|
|
if assignment.target.is_a?(Post)
|
|
"_from_post"
|
|
elsif assignment.target.is_a?(Topic)
|
|
""
|
|
end
|
|
return "unassigned#{suffix}" if assignment.assigned_to_user?
|
|
"unassigned_group#{suffix}" if assignment.assigned_to_group?
|
|
end
|
|
|
|
def already_assigned?(assign_to, type, note, status)
|
|
assignment_eq?(@target.assignment, assign_to, type, note, status)
|
|
end
|
|
|
|
def reassign?
|
|
Assignment.exists?(target: @target, active: true)
|
|
end
|
|
|
|
def no_assignee_change?(assignee)
|
|
@target.assignment&.assigned_to_id == assignee.id &&
|
|
@target.assignment&.assigned_to_type == assignee.class.name
|
|
end
|
|
|
|
def details_change?(note, status)
|
|
note.present? || @target.assignment&.status != status
|
|
end
|
|
|
|
def assignment_eq?(assignment, assign_to, type, note, status)
|
|
return false if !assignment&.active
|
|
return false if assignment.assigned_to_id != assign_to.id
|
|
return false if assignment.assigned_to_type != type
|
|
return false if assignment.note != note
|
|
assignment.status == status || !status && assignment.status == Assignment.default_status
|
|
end
|
|
end
|