diff --git a/assets/javascripts/discourse/components/post-signature.gjs b/assets/javascripts/discourse/components/post-signature.gjs index cd2ec6f..4533bbd 100644 --- a/assets/javascripts/discourse/components/post-signature.gjs +++ b/assets/javascripts/discourse/components/post-signature.gjs @@ -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`; + } + } diff --git a/assets/javascripts/discourse/connectors/user-custom-preferences/signature-preferences.gjs b/assets/javascripts/discourse/connectors/user-custom-preferences/signature-preferences.gjs index 6bdff43..2e2eddf 100644 --- a/assets/javascripts/discourse/connectors/user-custom-preferences/signature-preferences.gjs +++ b/assets/javascripts/discourse/connectors/user-custom-preferences/signature-preferences.gjs @@ -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 { -
- -
- {{#if this.siteSettings.signatures_advanced_mode}} - - {{else}} - - {{/if}} + {{#if this.canHaveSignature}} +
+ +
+ {{#if this.siteSettings.signatures_advanced_mode}} + + + {{i18n + "signatures.characters_remaining" + count=this.charactersRemaining + }} + + {{else}} + + {{/if}} +
-
+ {{else}} +
+

{{i18n + "signatures.not_allowed" + }}

+
+ {{/if}}
{{/if}} diff --git a/assets/stylesheets/common/signatures.scss b/assets/stylesheets/common/signatures.scss index 1e8e271..e87afff 100644 --- a/assets/stylesheets/common/signatures.scss +++ b/assets/stylesheets/common/signatures.scss @@ -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; +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index b1cf1fa..a95a0de 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 251e20e..6df3757 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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." diff --git a/config/settings.yml b/config/settings.yml index 593eba3..2fc28c3 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -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 diff --git a/plugin.rb b/plugin.rb index d3cbed5..2c31029 100644 --- a/plugin.rb +++ b/plugin.rb @@ -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 # 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 diff --git a/spec/integration/signatures_spec.rb b/spec/integration/signatures_spec.rb new file mode 100644 index 0000000..28d434c --- /dev/null +++ b/spec/integration/signatures_spec.rb @@ -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 diff --git a/spec/system/image_signatures_spec.rb b/spec/system/image_signatures_spec.rb index 5f4e8d7..d8f200f 100644 --- a/spec/system/image_signatures_spec.rb +++ b/spec/system/image_signatures_spec.rb @@ -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 diff --git a/spec/system/signature_restrictions_spec.rb b/spec/system/signature_restrictions_spec.rb new file mode 100644 index 0000000..9160ed7 --- /dev/null +++ b/spec/system/signature_restrictions_spec.rb @@ -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