1
0
mirror of synced 2026-05-22 22:13:15 +00:00

FEATURE: Add group, category, and display restrictions for signatures (#105)

* FEATURE: Add group, category, and display restrictions for signatures

Adds five new site settings to control signature behavior:

- signatures_allowed_groups: restrict signatures to specific groups
  (server-side enforced in both serializers and user update hook)
- signatures_show_in_categories: limit signature display to specific categories
- signatures_first_post_only: only show signatures on the OP
- signatures_max_length: cap advanced mode signature length
- signatures_max_image_height: constrain signature image height

Also hardens URL validation (HTTP/HTTPS only) and reduces
signature_url max_length from 32KB to 2048.

All defaults are backward-compatible (empty/false = no change).
This commit is contained in:
Rafael dos Santos Silva
2026-03-12 16:52:35 -03:00
committed by GitHub
parent 50ef7ff0b3
commit 3af3307708
10 changed files with 474 additions and 26 deletions
@@ -8,7 +8,30 @@ export default class PostSignature extends Component {
context.currentUser?.custom_fields?.see_signatures ??
context.siteSettings.signatures_visible_by_default;
return enabled && args.post.user_signature;
if (!enabled || !args.post.user_signature) {
return false;
}
if (
context.siteSettings.signatures_first_post_only &&
args.post.post_number !== 1
) {
return false;
}
const allowedCategories =
context.siteSettings.signatures_show_in_categories;
if (allowedCategories) {
const categoryIds = allowedCategories
.split("|")
.map((id) => parseInt(id, 10));
const postCategoryId = args.post.topic?.category_id;
if (!categoryIds.includes(postCategoryId)) {
return false;
}
}
return true;
}
@service siteSettings;
@@ -17,6 +40,10 @@ export default class PostSignature extends Component {
return this.siteSettings.signatures_advanced_mode;
}
get imageMaxHeight() {
return `max-height: ${this.siteSettings.signatures_max_image_height}px`;
}
<template>
<hr />
{{#if this.isAdvancedModeEnabled}}
@@ -26,7 +53,11 @@ export default class PostSignature extends Component {
</div>
</div>
{{else}}
<img class="signature-img" src={{@post.user_signature}} />
<img
class="signature-img"
src={{@post.user_signature}}
style={{this.imageMaxHeight}}
/>
{{/if}}
</template>
}
@@ -6,8 +6,22 @@ import DEditor from "discourse/components/d-editor";
import { i18n } from "discourse-i18n";
export default class SignaturePreferences extends Component {
@service currentUser;
@service siteSettings;
get canHaveSignature() {
return this.currentUser?.can_have_signature !== false;
}
get maxLength() {
return this.siteSettings.signatures_max_length;
}
get charactersRemaining() {
const raw = this.args.model.custom_fields?.signature_raw || "";
return this.maxLength - raw.length;
}
@action
updateSeeSignatures(event) {
const model = this.args.model;
@@ -38,23 +52,38 @@ export default class SignaturePreferences extends Component {
</label>
</div>
</div>
<div class="control-group signatures">
<label class="control-label">{{i18n
"signatures.my_signature"
}}</label>
<div class="controls input-xxlarge">
{{#if this.siteSettings.signatures_advanced_mode}}
<DEditor @value={{@model.custom_fields.signature_raw}} />
{{else}}
<input
type="text"
placeholder={{i18n "signatures.signature_placeholder"}}
value={{@model.custom_fields.signature_url}}
{{on "input" this.updateSignatureUrl}}
/>
{{/if}}
{{#if this.canHaveSignature}}
<div class="control-group signatures">
<label class="control-label">{{i18n
"signatures.my_signature"
}}</label>
<div class="controls input-xxlarge">
{{#if this.siteSettings.signatures_advanced_mode}}
<DEditor @value={{@model.custom_fields.signature_raw}} />
<span class="signature-char-count">
{{i18n
"signatures.characters_remaining"
count=this.charactersRemaining
}}
</span>
{{else}}
<input
type="url"
maxlength="2048"
placeholder={{i18n "signatures.signature_placeholder"}}
value={{@model.custom_fields.signature_url}}
{{on "input" this.updateSignatureUrl}}
/>
{{/if}}
</div>
</div>
</div>
{{else}}
<div class="control-group signatures">
<p class="signature-not-allowed">{{i18n
"signatures.not_allowed"
}}</p>
</div>
{{/if}}
</div>
{{/if}}
</template>
+16
View File
@@ -2,6 +2,10 @@
max-width: calc(100% - 5px);
}
.signature-img {
max-width: calc(100% - 5px);
}
.signature-preferences .d-editor {
width: 100%;
@@ -13,3 +17,15 @@
flex: 1;
}
}
.signature-char-count {
display: block;
margin-top: 0.25em;
font-size: var(--font-down-1);
color: var(--primary-medium);
}
.signature-not-allowed {
color: var(--primary-medium);
font-style: italic;
}
+4
View File
@@ -5,3 +5,7 @@ en:
show_signatures: "See user signatures below posts"
my_signature: "My Signature"
signature_placeholder: "A valid URL to an image"
not_allowed: "Your account is not in a group that is allowed to use signatures."
characters_remaining:
one: "%{count} character remaining"
other: "%{count} characters remaining"
+5
View File
@@ -3,3 +3,8 @@ en:
signatures_enabled: "Enable user-made signatures below posts?"
signatures_advanced_mode: "Let users use a full blown post editor to create signatures. RESETS all existing signatures."
signatures_visible_by_default: "Make signatures visible by default. This makes signatures visible for anonymous visitors as well."
signatures_allowed_groups: "Only allow users in these groups to have signatures. Leave empty to allow all users."
signatures_show_in_categories: "Only show signatures in these categories. Leave empty to show in all categories."
signatures_first_post_only: "Only show signatures on the first post in a topic."
signatures_max_length: "Maximum number of characters allowed in advanced mode signatures."
signatures_max_image_height: "Maximum height in pixels for signature images."
+22
View File
@@ -8,3 +8,25 @@ plugins:
signatures_visible_by_default:
default: false
client: true
signatures_allowed_groups:
type: group_list
list_type: compact
default: ""
allow_any: false
refresh: true
signatures_show_in_categories:
type: category_list
client: true
default: ""
signatures_first_post_only:
default: false
client: true
signatures_max_length:
default: 500
min: 50
max: 10000
signatures_max_image_height:
default: 150
min: 20
max: 1000
client: true
+41 -7
View File
@@ -3,7 +3,7 @@
# name: discourse-signatures
# about: Adds signatures to Discourse posts
# meta_topic_id: 42263
# version: 2.1.0
# version: 2.2.0
# author: Rafael Silva <xfalcox@gmail.com>
# url: https://github.com/discourse/discourse-signatures
@@ -15,8 +15,8 @@ DiscoursePluginRegistry.serialized_current_user_fields << "signature_raw"
after_initialize do
register_user_custom_field_type("see_signatures", :boolean)
register_user_custom_field_type("signature_url", :string, max_length: 32_000)
register_user_custom_field_type("signature_raw", :string, max_length: 1000)
register_user_custom_field_type("signature_url", :string, max_length: 2048)
register_user_custom_field_type("signature_raw", :string, max_length: 10_000)
# add to class and serializer to allow for default value for the setting
add_to_class(:user, :see_signatures) do
@@ -29,6 +29,11 @@ after_initialize do
add_to_serializer(:user, :see_signatures) { object.see_signatures }
add_to_serializer(:current_user, :can_have_signature) do
allowed_groups = SiteSetting.signatures_allowed_groups_map
allowed_groups.blank? || object.in_any_groups?(allowed_groups)
end
register_editable_user_custom_field :see_signatures
register_editable_user_custom_field :signature_url
register_editable_user_custom_field :signature_raw
@@ -37,19 +42,48 @@ after_initialize do
allow_public_user_custom_field :signature_url
add_to_serializer(:post, :user_signature) do
return nil unless object.user
allowed_groups = SiteSetting.signatures_allowed_groups_map
return nil if allowed_groups.present? && !object.user.in_any_groups?(allowed_groups)
if SiteSetting.signatures_advanced_mode
object.user.custom_fields["signature_cooked"] if object.user
object.user.custom_fields["signature_cooked"]
else
object.user.custom_fields["signature_url"] if object.user
object.user.custom_fields["signature_url"]
end
end
# This is the code responsible for cooking a new advanced mode sig on user update
on(:user_updated) do |user|
allowed_groups = SiteSetting.signatures_allowed_groups_map
if allowed_groups.present? && !user.in_any_groups?(allowed_groups)
user.custom_fields.delete("signature_url")
user.custom_fields.delete("signature_raw")
user.custom_fields.delete("signature_cooked")
user.save
next
end
if user.custom_fields["signature_url"].present?
url = user.custom_fields["signature_url"]
begin
parsed = URI.parse(url)
raise URI::InvalidURIError unless parsed.is_a?(URI::HTTP) || parsed.is_a?(URI::HTTPS)
rescue URI::InvalidURIError
user.custom_fields.delete("signature_url")
user.save
end
end
if SiteSetting.signatures_advanced_mode && user.custom_fields["signature_raw"]
raw = user.custom_fields["signature_raw"]
max_length = SiteSetting.signatures_max_length
raw = raw[0...max_length] if raw.length > max_length
user.custom_fields["signature_raw"] = raw
cooked_sig =
PrettyText.cook(
user.custom_fields["signature_raw"],
raw,
omit_nofollow: user.has_trust_level?(TrustLevel[3]) && !SiteSetting.tl3_links_no_follow,
)
# avoid infinite recursion
+163
View File
@@ -0,0 +1,163 @@
# frozen_string_literal: true
RSpec.describe "Discourse Signatures" do
before do
enable_current_plugin
SiteSetting.signatures_enabled = true
end
describe "group-based restriction" do
fab!(:user)
fab!(:group)
fab!(:topic)
before do
SiteSetting.signatures_advanced_mode = false
user.custom_fields["signature_url"] = "https://example.com/sig.png"
user.save_custom_fields
end
context "when signatures_allowed_groups is empty" do
before { SiteSetting.signatures_allowed_groups = "" }
it "includes user_signature in the post serializer" do
post = Fabricate(:post, topic:, user:)
json = PostSerializer.new(post, scope: Guardian.new(user), root: false).as_json
expect(json[:user_signature]).to eq("https://example.com/sig.png")
end
it "sets can_have_signature to true for current user" do
json = CurrentUserSerializer.new(user, scope: Guardian.new(user), root: false).as_json
expect(json[:can_have_signature]).to eq(true)
end
end
context "when user is in the allowed group" do
before do
SiteSetting.signatures_allowed_groups = group.id.to_s
group.add(user)
end
it "includes user_signature in the post serializer" do
post = Fabricate(:post, topic:, user:)
json = PostSerializer.new(post, scope: Guardian.new(user), root: false).as_json
expect(json[:user_signature]).to eq("https://example.com/sig.png")
end
it "sets can_have_signature to true" do
json = CurrentUserSerializer.new(user, scope: Guardian.new(user), root: false).as_json
expect(json[:can_have_signature]).to eq(true)
end
end
context "when user is not in the allowed group" do
before { SiteSetting.signatures_allowed_groups = group.id.to_s }
it "returns nil for user_signature in the post serializer" do
post = Fabricate(:post, topic:, user:)
json = PostSerializer.new(post, scope: Guardian.new(user), root: false).as_json
expect(json[:user_signature]).to be_nil
end
it "sets can_have_signature to false" do
json = CurrentUserSerializer.new(user, scope: Guardian.new(user), root: false).as_json
expect(json[:can_have_signature]).to eq(false)
end
end
end
describe "signature length enforcement" do
fab!(:user)
before do
SiteSetting.signatures_advanced_mode = true
SiteSetting.signatures_max_length = 100
end
it "truncates signature_raw to max_length on user update" do
user.custom_fields["signature_raw"] = "a" * 200
user.save_custom_fields
DiscourseEvent.trigger(:user_updated, user)
user.reload
expect(user.custom_fields["signature_raw"].length).to eq(100)
end
it "does not truncate signatures within the limit" do
user.custom_fields["signature_raw"] = "short signature"
user.save_custom_fields
DiscourseEvent.trigger(:user_updated, user)
user.reload
expect(user.custom_fields["signature_raw"]).to eq("short signature")
end
end
describe "URL validation" do
fab!(:user)
before { SiteSetting.signatures_advanced_mode = false }
it "removes invalid URLs on user update" do
user.custom_fields["signature_url"] = "not a valid url %%"
user.save_custom_fields
DiscourseEvent.trigger(:user_updated, user)
user.reload
expect(user.custom_fields["signature_url"]).to be_nil
end
it "keeps valid URLs on user update" do
user.custom_fields["signature_url"] = "https://example.com/sig.png"
user.save_custom_fields
DiscourseEvent.trigger(:user_updated, user)
user.reload
expect(user.custom_fields["signature_url"]).to eq("https://example.com/sig.png")
end
it "removes URLs with non-HTTP schemes on user update" do
user.custom_fields["signature_url"] = "javascript:alert(1)"
user.save_custom_fields
DiscourseEvent.trigger(:user_updated, user)
user.reload
expect(user.custom_fields["signature_url"]).to be_nil
end
end
describe "group restriction on user update" do
fab!(:user)
fab!(:group)
before do
SiteSetting.signatures_advanced_mode = false
user.custom_fields["signature_url"] = "https://example.com/sig.png"
user.save_custom_fields
end
it "clears signature data when user is not in the allowed group" do
SiteSetting.signatures_allowed_groups = group.id.to_s
DiscourseEvent.trigger(:user_updated, user)
user.reload
expect(user.custom_fields["signature_url"]).to be_nil
end
it "keeps signature data when user is in the allowed group" do
SiteSetting.signatures_allowed_groups = group.id.to_s
group.add(user)
DiscourseEvent.trigger(:user_updated, user)
user.reload
expect(user.custom_fields["signature_url"]).to eq("https://example.com/sig.png")
end
end
end
+3 -1
View File
@@ -4,7 +4,9 @@ RSpec.describe "Image signatures" do
fab!(:user)
fab!(:topic) { Fabricate(:topic, category: Fabricate(:category)) }
fab!(:post) { Fabricate(:post, topic:) }
let(:signature_image_url) { "data:abcdef," }
let(:signature_image_url) do
"http://#{Discourse.current_hostname}/images/discourse-logo-sketch-small.png"
end
context "when signatures plugin is enabled" do
before do
+142
View File
@@ -0,0 +1,142 @@
# frozen_string_literal: true
RSpec.describe "Signature restrictions" do
fab!(:user)
fab!(:category)
fab!(:topic) { Fabricate(:topic, category:) }
let(:signature_image_url) { "data:abcdef," }
before do
enable_current_plugin
SiteSetting.signatures_enabled = true
SiteSetting.signatures_advanced_mode = false
SiteSetting.signatures_visible_by_default = true
end
describe "group-based restriction" do
fab!(:group)
fab!(:post_with_sig) { Fabricate(:post, topic:, user:) }
before do
user.custom_fields["signature_url"] = signature_image_url
user.save_custom_fields
end
context "when signatures_allowed_groups is empty" do
before { SiteSetting.signatures_allowed_groups = "" }
it "shows signatures for all users" do
sign_in(user)
visit topic.url
expect(page).to have_css("img.signature-img")
end
end
context "when user is in an allowed group" do
before do
SiteSetting.signatures_allowed_groups = group.id.to_s
group.add(user)
end
it "shows the signature below their post" do
sign_in(user)
visit topic.url
expect(page).to have_css("img.signature-img[src='#{signature_image_url}']")
end
it "shows the signature editing UI in preferences" do
sign_in(user)
visit "/my/preferences/profile"
expect(page).to have_content(I18n.t("js.signatures.my_signature"))
expect(page).to have_no_content(I18n.t("js.signatures.not_allowed"))
end
end
context "when user is not in an allowed group" do
before { SiteSetting.signatures_allowed_groups = group.id.to_s }
it "does not show the signature below their post" do
sign_in(Fabricate(:user))
visit topic.url
expect(page).to have_no_css("img.signature-img")
end
it "shows the restriction message in preferences" do
sign_in(user)
visit "/my/preferences/profile"
expect(page).to have_content(I18n.t("js.signatures.not_allowed"))
expect(page).to have_no_field(placeholder: I18n.t("js.signatures.signature_placeholder"))
end
end
end
describe "category-based display" do
fab!(:other_category, :category)
fab!(:other_topic) { Fabricate(:topic, category: other_category) }
before do
user.custom_fields["signature_url"] = signature_image_url
user.save_custom_fields
end
context "when signatures_show_in_categories is empty" do
before { SiteSetting.signatures_show_in_categories = "" }
it "shows signatures in all categories" do
Fabricate(:post, topic:, user:)
sign_in(user)
visit topic.url
expect(page).to have_css("img.signature-img")
end
end
context "when signatures_show_in_categories is set" do
before { SiteSetting.signatures_show_in_categories = category.id.to_s }
it "shows signatures in the allowed category" do
Fabricate(:post, topic:, user:)
sign_in(user)
visit topic.url
expect(page).to have_css("img.signature-img")
end
it "does not show signatures in other categories" do
Fabricate(:post, topic: other_topic, user:)
sign_in(user)
visit other_topic.url
expect(page).to have_no_css("img.signature-img")
end
end
end
describe "first-post-only display" do
before do
user.custom_fields["signature_url"] = signature_image_url
user.save_custom_fields
end
context "when signatures_first_post_only is false" do
before { SiteSetting.signatures_first_post_only = false }
it "shows signatures on all posts" do
Fabricate(:post, topic:, user:)
Fabricate(:post, topic:, user:)
sign_in(user)
visit topic.url
expect(page).to have_css("img.signature-img", count: 2)
end
end
context "when signatures_first_post_only is true" do
before { SiteSetting.signatures_first_post_only = true }
it "shows signature only on the first post" do
Fabricate(:post, topic:, user:)
Fabricate(:post, topic:, user:)
sign_in(user)
visit topic.url
expect(page).to have_css("img.signature-img", count: 1)
end
end
end
end