mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 07:23:30 +08:00
Previously, the duplicate topic title check compared new titles against every topic on the site regardless of visibility: users were blocked by titles in categories they couldn't see or unlisted topics they couldn't find, the bare "Title has already been used" error gave no way to locate the conflict (and doubled as an existence oracle for hidden titles), and the behavior was controlled by two entangled boolean settings. This change scopes the check to the destination category plus whatever the acting user can actually see, links the conflicting topic in the error — safe by construction, since being blocked now implies being able to see it: > This title has already been used by [another topic](). It also consolidates the two booleans into a single `duplicate_topic_titles` enum (`disallowed` / `allowed_across_categories` / `allowed`), with existing values migrated and the old names kept as hidden deprecated aliases that admin search still resolves. Reported in https://meta.discourse.org/t/title-has-already-been-used-in-a-secure-category/123047 Note for self-hosters: env-provided settings can't be migrated — `DISCOURSE_ALLOW_DUPLICATE_TOPIC_TITLES=true` configs need to switch to `DISCOURSE_DUPLICATE_TOPIC_TITLES=allowed`.
294 lines
8.4 KiB
Ruby
Vendored
294 lines
8.4 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
module SiteSettings
|
|
class LabelFormatter
|
|
HUMANIZED_ACRONYMS =
|
|
Set.new(
|
|
%w[
|
|
2fa
|
|
acl
|
|
ai
|
|
api
|
|
arn
|
|
aws
|
|
bg
|
|
cdn
|
|
cors
|
|
csp
|
|
csrf
|
|
css
|
|
cta
|
|
csv
|
|
cx
|
|
db
|
|
dm
|
|
dns
|
|
eu
|
|
faq
|
|
fg
|
|
ga
|
|
gb
|
|
gif
|
|
gpu
|
|
gpt
|
|
gtm
|
|
hd
|
|
html
|
|
http
|
|
https
|
|
iam
|
|
id
|
|
imap
|
|
ip
|
|
jpg
|
|
json
|
|
kb
|
|
llm
|
|
mb
|
|
mfa
|
|
oauth
|
|
oidc
|
|
pdf
|
|
pm
|
|
png
|
|
pop3
|
|
rest
|
|
rss
|
|
s3
|
|
saml
|
|
smtp
|
|
sso
|
|
svg
|
|
tei
|
|
tl
|
|
tl0
|
|
tl1
|
|
tl2
|
|
tl3
|
|
tl4
|
|
tld
|
|
totp
|
|
txt
|
|
ui
|
|
url
|
|
ux
|
|
vpc
|
|
xml
|
|
yaml
|
|
yml
|
|
],
|
|
).freeze
|
|
|
|
HUMANIZED_MIXED_CASE = [
|
|
%w[apple Apple],
|
|
["adobe analytics", "Adobe Analytics"],
|
|
["amazon web services", "Amazon Web Services"],
|
|
%w[android Android],
|
|
%w[chinese Chinese],
|
|
%w[discord Discord],
|
|
%w[discourse Discourse],
|
|
["discourse connect", "Discourse Connect"],
|
|
["discourse discover", "Discourse Discover"],
|
|
["discourse narrative bot", "Discourse Narrative Bot"],
|
|
%w[facebook Facebook],
|
|
%w[foundation Foundation],
|
|
%w[github GitHub],
|
|
%w[google Google],
|
|
["google analytics", "Google Analytics"],
|
|
["google tag manager", "Google Tag Manager"],
|
|
%w[gravatar Gravatar],
|
|
%w[gravatars Gravatars],
|
|
%w[gitter Gitter],
|
|
%w[horizon Horizon],
|
|
%w[ios iOS],
|
|
%w[japanese Japanese],
|
|
%w[linkedin LinkedIn],
|
|
%w[meta Meta],
|
|
%w[mediaconvert MediaConvert],
|
|
%w[microsoft Microsoft],
|
|
%w[matrix Matrix],
|
|
%w[mattermost Mattermost],
|
|
%w[oauth2 OAuth2],
|
|
["openid connect", "OpenID Connect"],
|
|
%w[openai OpenAI],
|
|
%w[opengraph OpenGraph],
|
|
["powered by discourse", "Powered by Discourse"],
|
|
%w[tiktok TikTok],
|
|
%w[tos ToS],
|
|
%w[twitter Twitter],
|
|
%w[telegram Telegram],
|
|
%w[teams Teams],
|
|
%w[rocketchat RocketChat],
|
|
%w[slack Slack],
|
|
%w[vimeo Vimeo],
|
|
%w[wordpress WordPress],
|
|
%w[webex WebEx],
|
|
%w[youtube YouTube],
|
|
%w[zulip Zulip],
|
|
].freeze
|
|
|
|
HUMANIZED_MIXED_CASE_REGEX =
|
|
HUMANIZED_MIXED_CASE.map { |key, value| [/\b#{Regexp.escape(key)}\b/i, value] }.freeze
|
|
|
|
SETTING_LINK_PATTERN = /\{\{setting:([a-z][a-z0-9_]*)\}\}/
|
|
SETTINGS_LINK_PATTERN =
|
|
/\{\{settings:([a-z][a-z0-9_]*(?:,[a-z][a-z0-9_]*)*)(?:\|([^{}|]+))?\}\}/
|
|
|
|
LINK_ATTRIBUTES = %w[
|
|
class
|
|
data-setting-name
|
|
data-setting-area
|
|
data-setting-category
|
|
data-setting-plugin
|
|
].freeze
|
|
|
|
class << self
|
|
def description(setting)
|
|
desc = I18n.t("site_settings.#{setting}", base_path: Discourse.base_path, default: "")
|
|
expand_setting_links(desc)
|
|
end
|
|
|
|
def settings_filter_href(filter)
|
|
"#{Discourse.base_path}/admin/site_settings/category/all_results?filter=#{CGI.escape(filter)}"
|
|
end
|
|
|
|
def linkify(setting)
|
|
setting = setting.to_sym
|
|
|
|
# The href points at the generic "all settings" page as a fallback that
|
|
# works without JavaScript. In the admin UI the linkify-setting-links
|
|
# modifier rewrites it to the setting's actual config page, using the
|
|
# data attributes below so it doesn't have to look the metadata up.
|
|
attributes = {
|
|
"class" => "site-setting-link",
|
|
"href" => settings_filter_href(setting.to_s),
|
|
"data-setting-name" => setting,
|
|
}
|
|
|
|
if (area = SiteSetting.areas[setting]&.first)
|
|
attributes["data-setting-area"] = area
|
|
end
|
|
if (category = SiteSetting.categories[setting])
|
|
attributes["data-setting-category"] = category
|
|
end
|
|
if (plugin = SiteSetting.plugins[setting])
|
|
attributes["data-setting-plugin"] = plugin
|
|
end
|
|
|
|
attribute_string =
|
|
attributes.map { |name, value| %(#{name}="#{CGI.escapeHTML(value.to_s)}") }.join(" ")
|
|
|
|
%(<a #{attribute_string}>#{CGI.escapeHTML(humanized_name(setting))}</a>).html_safe
|
|
end
|
|
|
|
# Links a group of settings as a single anchor pointing at the
|
|
# all-settings page filtered to every name at once, via the explicit
|
|
# `any:one|two` OR filter syntax (unprefixed pipes stay literal so
|
|
# admins can search pipe-delimited list values exactly). Unlike #linkify
|
|
# there is no per-setting config page to rewrite the href to, so no data
|
|
# attributes are emitted for the linkify-setting-links modifier.
|
|
def linkify_settings(settings, label: nil)
|
|
filter = "any:#{settings.map(&:to_s).join("|")}"
|
|
label ||= settings.map { |setting| humanized_name(setting) }.join(", ")
|
|
|
|
%(<a class="site-setting-link" href="#{CGI.escapeHTML(settings_filter_href(filter))}">#{CGI.escapeHTML(label)}</a>).html_safe
|
|
end
|
|
|
|
def contains_setting_links?(text)
|
|
return false if text.blank?
|
|
text.match?(SETTING_LINK_PATTERN) || text.match?(SETTINGS_LINK_PATTERN)
|
|
end
|
|
|
|
def expand_setting_links(text, escape_text: false)
|
|
return text if text.blank? || !text.include?("{{setting")
|
|
|
|
text = CGI.escapeHTML(text) if escape_text
|
|
|
|
text
|
|
.gsub(SETTINGS_LINK_PATTERN) do
|
|
label = Regexp.last_match(2)&.strip
|
|
label = CGI.unescapeHTML(label) if escape_text && label
|
|
linkify_settings(Regexp.last_match(1).split(","), label:)
|
|
end
|
|
.gsub(SETTING_LINK_PATTERN) { linkify(Regexp.last_match(1)) }
|
|
.html_safe
|
|
end
|
|
|
|
# Renders markers as plain text — the humanized setting names (quoted)
|
|
# for {{setting:...}}, and the label or name list for {{settings:...}} —
|
|
# for surfaces that display error messages outside the admin UI.
|
|
def plain_setting_links(text)
|
|
return text if text.blank? || !text.include?("{{setting")
|
|
|
|
text
|
|
.gsub(SETTINGS_LINK_PATTERN) do
|
|
Regexp.last_match(2)&.strip ||
|
|
Regexp.last_match(1).split(",").map { |s| humanized_name(s) }.join(", ")
|
|
end
|
|
.gsub(SETTING_LINK_PATTERN) { "'#{humanized_name(Regexp.last_match(1))}'" }
|
|
end
|
|
|
|
def humanized_name(setting)
|
|
name = setting.to_s.tr("_", " ")
|
|
words = name.split(" ")
|
|
|
|
words[0] = words[0].capitalize
|
|
|
|
words.map! do |word|
|
|
word_downcase = word.downcase
|
|
|
|
if HUMANIZED_ACRONYMS.include?(word_downcase)
|
|
word.upcase
|
|
elsif word.end_with?("s") && HUMANIZED_ACRONYMS.include?(word_downcase[0...-1])
|
|
word_downcase[0...-1].upcase + "s"
|
|
else
|
|
word
|
|
end
|
|
end
|
|
|
|
result = words.join(" ")
|
|
|
|
HUMANIZED_MIXED_CASE_REGEX.each do |regex, replacement|
|
|
result = result.gsub(regex, replacement)
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
def keywords(setting)
|
|
translated_keywords = I18n.t("site_settings.keywords.#{setting}", default: "")
|
|
english_translated_keywords = []
|
|
|
|
if I18n.locale != :en
|
|
english_translated_keywords =
|
|
I18n.t("site_settings.keywords.#{setting}", default: "", locale: :en).split("|")
|
|
end
|
|
|
|
# TODO (martin) We can remove this workaround of checking if
|
|
# we get an array back once keyword translations in languages other
|
|
# than English have been updated not to use YAML arrays.
|
|
if translated_keywords.is_a?(Array)
|
|
return(
|
|
(
|
|
translated_keywords + SiteSetting.deprecated_setting_aliases(setting) +
|
|
english_translated_keywords
|
|
).compact
|
|
)
|
|
end
|
|
|
|
translated_keywords
|
|
.split("|")
|
|
.concat(SiteSetting.deprecated_setting_aliases(setting) + english_translated_keywords)
|
|
.compact
|
|
end
|
|
|
|
def placeholder(setting)
|
|
if !I18n.t("site_settings.placeholder.#{setting}", default: "").empty?
|
|
I18n.t("site_settings.placeholder.#{setting}")
|
|
elsif SiteIconManager.respond_to?("#{setting}_url")
|
|
SiteIconManager.public_send("#{setting}_url")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|