0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 00:15:24 +08:00
discourse/app/models/flag.rb
Régis Hanol 41db2f76f3
FIX: Ensure each flag gets a unique name_key (#40899)
Previously, flag names with no ASCII word characters (e.g. Chinese) all
normalized to the same `name_key` of `custom_`, because `set_name_key`
stripped non-`\w` characters and Ruby's `\w` is ASCII-only. The flag
system keys lookups by `name_key` (the `disabled_flag_types` enum, the
frontend `actionByName` map), so once two flags shared a key, disabling
one of them flipped `can_act` for the whole group and hid every other
flag that shared it.

This makes `name_key` reliably unique:

- `set_name_key` falls back to `custom_flag` when the slug is empty and
appends a counter (`_2`, `_3`, ...) on collision, so distinct names
always produce distinct keys. It now only re-derives the key when the
name actually changes, keeping it stable across other saves.
- A post-deploy migration backfills existing duplicate keys and adds the
unique index on `flags.name_key` that the original `create_flags`
migration intended (`unique: true` is a no-op in `create_table`).

https://meta.discourse.org/t/405254
2026-06-24 16:37:59 +02:00

108 lines
2.9 KiB
Ruby
Vendored

# frozen_string_literal: true
class Flag < ActiveRecord::Base
DEFAULT_VALID_APPLIES_TO = %w[Post Topic]
MAX_SYSTEM_FLAG_ID = 1000
MAX_NAME_LENGTH = 200
MAX_DESCRIPTION_LENGTH = 1000
scope :enabled, -> { where(enabled: true) }
scope :system, -> { where("id < 1000") }
scope :custom, -> { where("id >= 1000") }
before_save :set_position
before_save :set_name_key
after_commit { reset_flag_settings! if !skip_reset_flag_callback }
attr_accessor :skip_reset_flag_callback
default_scope do
order(:position).where(score_type: false).where.not(id: PostActionType::LIKE_POST_ACTION_ID)
end
def used?
PostAction.exists?(post_action_type_id: id) ||
ReviewableScore.exists?(reviewable_score_type: id)
end
def self.valid_applies_to_types
Set.new(DEFAULT_VALID_APPLIES_TO | DiscoursePluginRegistry.flag_applies_to_types)
end
def self.reset_flag_settings!
# Flags are cached in Redis for better performance. After the update,
# we need to reload them in all processes.
PostActionType.reload_types
end
def self.used_flag_ids(flag_ids)
sql =
flag_ids
.reduce([]) do |queries, flag_id|
queries << "(SELECT #{flag_id} FROM post_actions WHERE post_action_type_id = #{flag_id} LIMIT 1)"
queries << "(SELECT #{flag_id} FROM reviewable_scores WHERE reviewable_score_type = #{flag_id} LIMIT 1)"
queries
end
.join(" UNION ")
DB.query_single(sql)
end
def system?
id.present? && id < MAX_SYSTEM_FLAG_ID
end
def applies_to?(type)
applies_to.include?(type)
end
private
def reset_flag_settings!
self.class.reset_flag_settings!
end
def set_position
self.position = Flag.maximum(:position).to_i + 1 if !position
end
def set_name_key
return if name_key.present? && !will_save_change_to_name?
prefix = system? ? "" : "custom_"
slug = name.squeeze(" ").gsub(" ", "_").gsub(/[^\w]/, "").downcase
base = slug.present? ? "#{prefix}#{slug}" : "#{prefix}flag"
suffix = 1
candidate = base
while Flag.unscoped.where(name_key: candidate).where.not(id: id).exists?
suffix += 1
candidate = "#{base}_#{suffix}"
end
self.name_key = candidate
end
end
# == Schema Information
#
# Table name: flags
#
# id :bigint not null, primary key
# applies_to :string not null, is an Array
# auto_action_type :boolean default(FALSE), not null
# description :text
# enabled :boolean default(TRUE), not null
# name :string
# name_key :string
# notify_type :boolean default(FALSE), not null
# position :integer not null
# require_message :boolean default(FALSE), not null
# score_type :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_flags_on_name_key (name_key) UNIQUE
#