Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/admin/badges_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def update
private

def badge_params
params.require(:badge).permit(:title, :description, :badge_image, :credits_awarded, :allow_multiple_awards)
params.require(:badge).permit(:title, :description, :badge_image, :credits_awarded, :allow_multiple_awards, :bonus_weight)
end
end
end
16 changes: 8 additions & 8 deletions app/javascript/packs/billboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import {
} from './billboardAfterRenderActions';

export async function getBillboard() {
const placeholderElements = document.getElementsByClassName(
'js-billboard-container',
const placeholderElements = document.querySelectorAll(
'.js-bb-c, .js-billboard-container, .new-bb-container, .sidebar-bb, .below-post-bb, .feed-bb-c'
);

const promises = [...placeholderElements].map(generateBillboard);
Expand Down Expand Up @@ -40,7 +40,7 @@ async function generateBillboard(element) {
}
});
}

// Attach a MutationObserver to a specific billboard element
function observeThisBillboard(element) {
// Avoid attaching multiple observers to the same element
Expand Down Expand Up @@ -74,7 +74,7 @@ async function generateBillboard(element) {
if (
asyncUrl?.includes('post_fixed_bottom') &&
(currentParams?.includes('context=digest') || isInternalNav || isNativeUserAgent)
) {
) {
return;
}

Expand All @@ -84,7 +84,7 @@ async function generateBillboard(element) {
generatedElement.innerHTML = htmlContent;
element.innerHTML = '';
element.appendChild(generatedElement);

// Set article ID from article container if present
const articleContainer = document.getElementById('article-show-container');
if (articleContainer && articleContainer.dataset.articleId) {
Expand All @@ -93,7 +93,7 @@ async function generateBillboard(element) {
billboardElement.dataset.articleId = articleContainer.dataset.articleId;
}
}

element.querySelectorAll('img').forEach((img) => {
img.onerror = function () {
this.style.display = 'none';
Expand Down Expand Up @@ -137,7 +137,7 @@ async function generateBillboard(element) {
// *** Beginning of where we guard against disallowed attributes
// Initially attach observers to all existing billboard elements
document.querySelectorAll('.js-billboard').forEach(observeThisBillboard);

// To handle new billboard elements that are added dynamically,
// observe the document body for added nodes.
const bodyObserver = new MutationObserver(mutations => {
Expand All @@ -156,7 +156,7 @@ async function generateBillboard(element) {
}
});
});

bodyObserver.observe(document.body, { childList: true, subtree: true });

// *** End of guarding against disallowed attributes
Expand Down
5 changes: 4 additions & 1 deletion app/models/article.rb
Original file line number Diff line number Diff line change
Expand Up @@ -914,7 +914,10 @@ def update_score
# Content moderation label adjustments
automod_label_adjustment = AUTOMOD_SCORE_ADJUSTMENTS[automod_label.to_sym] || 0

self.score = reactions.sum(:points) + spam_adjustment + negative_reaction_adjustment + base_subscriber_adjustment + user_featured_count_adjustment + user_negative_count_adjustment + context_note_adjustment + automod_label_adjustment
badge_bonus_weight_sum = user.badges.sum(:bonus_weight)
badge_reputation_bonus = Math.sqrt(badge_bonus_weight_sum).to_i

self.score = reactions.sum(:points) + spam_adjustment + negative_reaction_adjustment + base_subscriber_adjustment + user_featured_count_adjustment + user_negative_count_adjustment + context_note_adjustment + automod_label_adjustment + badge_reputation_bonus
accepted_max = [max_score, user&.max_score.to_i].min
accepted_max = [max_score, user&.max_score.to_i].max if accepted_max.zero?
self.score = accepted_max if accepted_max.positive? && accepted_max < score
Expand Down
1 change: 1 addition & 0 deletions app/models/badge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Badge < ApplicationRecord
validates :slug, presence: true
validates :title, presence: true, uniqueness: true
validates :allow_multiple_awards, inclusion: { in: [true, false] }
validates :bonus_weight, numericality: { only_integer: true, greater_than_or_equal_to: 0 }

before_validation :generate_slug

Expand Down
1 change: 1 addition & 0 deletions app/models/settings/general.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class General < Base

# Emails
setting :contact_email, type: :string, default: ApplicationConfig["DEFAULT_EMAIL"]
setting :custom_email_footer, type: :string, validates: { email_safe_html: true }
setting :periodic_email_digest, type: :integer, default: 2

# Analytics and tracking
Expand Down
2 changes: 1 addition & 1 deletion app/services/articles/feeds/tag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def self.call(tag = nil, number_of_articles: Article::DEFAULT_FEED_PAGINATION_WI

articles
.published
.minimal_feed_column_select
.limited_column_select
.includes(:distinct_reaction_categories, :context_notes)
.page(page)
.per(number_of_articles)
Expand Down
7 changes: 6 additions & 1 deletion app/services/badges/award_yearly_club.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ class AwardYearlyClub
5 => "five",
6 => "six",
7 => "seven",
8 => "eight"
8 => "eight",
9 => "nine",
10 => "ten",
11 => "eleven",
12 => "twelve",
13 => "thirteen",
}.freeze

def self.call
Expand Down
60 changes: 60 additions & 0 deletions app/validators/email_safe_html_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
class EmailSafeHtmlValidator < ActiveModel::EachValidator
# Email-safe HTML tags that are widely supported across email clients
ALLOWED_TAGS = %w[
p br a strong b em i span div
table tr td th tbody thead tfoot
h1 h2 h3 h4 h5 h6
ul ol li
img
].freeze

# Only allow inline styles and basic link attributes
ALLOWED_ATTRIBUTES = %w[
style href target title alt src width height
align valign bgcolor border cellpadding cellspacing
].freeze

def validate_each(record, attribute, value)
return if value.blank?

# Check for script tags or event handlers
if value.match?(/(<script|javascript:|on\w+\s*=)/i)
record.errors.add(
attribute,
"contains JavaScript or event handlers which are not allowed in emails"
)
return
end

# Check for external stylesheets or resources
if value.match?(/(<link|<style|@import)/i)
record.errors.add(
attribute,
"contains external stylesheets. Please use inline styles only"
)
return
end

# Sanitize and check if content changed significantly
sanitized = sanitize_html(value)

# If sanitization removed too much, warn the user
if sanitized.length < (value.length * 0.5) && value.length > 100
record.errors.add(
attribute,
"contains many unsupported HTML elements. Please use simple, email-safe HTML"
)
end
end

private

def sanitize_html(html)
# Use Rails sanitizer to clean HTML
Rails::Html::SafeListSanitizer.new.sanitize(
html,
tags: ALLOWED_TAGS,
attributes: ALLOWED_ATTRIBUTES
) || ""
end
end
6 changes: 4 additions & 2 deletions app/views/admin/badges/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@
<%= form.file_field :badge_image %>
</div>


<div class="crayons-field">
<%= form.label :credits_awarded, "Credits awarded:", class: "crayons-field__label" %>
<%= form.text_field :credits_awarded, class: "crayons-textfield" %>
<%= form.label :bonus_weight, "Bonus weight:", class: "crayons-field__label" %>
<p class="crayons-field__description">Integer point value for user reputational bonus. Default is 0. 10 is for major hand-picked badges, 1 for small badges, 0 for gamable or badges not indicative of quality/expertise.</p>
<%= form.number_field :bonus_weight, class: "crayons-textfield" %>
</div>

<div class="crayons-field crayons-field--checkbox ">
Expand Down
6 changes: 4 additions & 2 deletions app/views/admin/badges/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
<%= form.file_field :badge_image, class: "" %>
</div>


<div class="crayons-field">
<%= form.label :credits_awarded, "Credits awarded", class: "crayons-field__label" %>
<%= form.text_field :credits_awarded, class: "crayons-textfield" %>
<%= form.label :bonus_weight, "Bonus weight", class: "crayons-field__label" %>
<p class="crayons-field__description">Integer point value for user reputational bonus. Default is 0. 10 is for major hand-picked badges, 1 for small badges, 0 for gamable or badges not indicative of quality/expertise.</p>
<%= form.number_field :bonus_weight, class: "crayons-textfield" %>
</div>

<div class="crayons-field crayons-field--checkbox ">
Expand Down
21 changes: 21 additions & 0 deletions app/views/admin/settings/forms/_emails.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@
placeholder: Constants::Settings::General.details[:contact_email][:placeholder] %>
</div>

<div class="crayons-field">
<%= admin_config_label :custom_email_footer %>
<p class="crayons-field__description">
<strong>Custom HTML footer for all emails.</strong> This will appear above the sign-in reminder in user emails.
<br><br>
<strong>Email-safe HTML guidelines:</strong>
<ul style="margin-top: 8px;">
<li>✅ Use <strong>inline styles only</strong> (e.g., <code>style="color: #333;"</code>)</li>
<li>✅ Allowed tags: p, br, a, strong, em, span, div, table, tr, td, h1-h6, ul, ol, li, img</li>
<li>❌ No external CSS (<code>&lt;link&gt;</code> or <code>&lt;style&gt;</code> tags)</li>
<li>❌ No JavaScript or event handlers</li>
<li>💡 Keep it simple for best email client compatibility</li>
</ul>
</p>
<%= f.text_area :custom_email_footer,
class: "crayons-textfield",
value: Settings::General.custom_email_footer,
rows: 6,
placeholder: '<p style="text-align: center; color: #666;">Custom footer text here</p>' %>
</div>

<div class="crayons-field">
<%= admin_config_label :periodic_email_digest %>
<%= admin_config_description Constants::Settings::General.details[:periodic_email_digest][:description] %>
Expand Down
7 changes: 7 additions & 0 deletions app/views/layouts/mailer.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@
</td>
</tr>
<% end %>
<% if @user && Settings::General.custom_email_footer.present? %>
<tr>
<td style="padding:3% 12% 2% 4%;font-size:18px;line-height:22px;color:#202121">
<%= Settings::General.custom_email_footer.html_safe %>
</td>
</tr>
<% end %>
<tr>
<td style="padding:3% 12% 2% 4%;font-size:18px;line-height:22px;color:#202121">
<%= signed_up_with(@user).html_safe %>
Expand Down
5 changes: 5 additions & 0 deletions db/migrate/20260127193756_add_bonus_weight_to_badges.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddBonusWeightToBadges < ActiveRecord::Migration[7.0]
def change
add_column :badges, :bonus_weight, :integer, default: 0
end
end
3 changes: 2 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.0].define(version: 2026_01_27_141307) do
ActiveRecord::Schema[7.0].define(version: 2026_01_27_193756) do
# These are extensions that must be enabled in order to support this database
enable_extension "citext"
enable_extension "ltree"
Expand Down Expand Up @@ -234,6 +234,7 @@
create_table "badges", force: :cascade do |t|
t.boolean "allow_multiple_awards", default: false, null: false
t.string "badge_image"
t.integer "bonus_weight", default: 0
t.datetime "created_at", precision: nil, null: false
t.integer "credits_awarded", default: 0, null: false
t.string "description", null: false
Expand Down
41 changes: 41 additions & 0 deletions spec/models/badge_reputation_bonus_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
require "rails_helper"

RSpec.describe "Article Badge Reputation Bonus", type: :model do
let(:user) { create(:user) }
let(:article) { create(:article, user: user) }
# Use different titles/slugs to avoid uniqueness validation issues if factories aren't enough
let(:badge1) { create(:badge, title: "Badge 1", bonus_weight: 10) }
let(:badge2) { create(:badge, title: "Badge 2", bonus_weight: 6) }

it "calculates the reputation bonus as the square root of the sum of badge weights" do
# Sum of weights = 10 + 6 = 16. Sqrt(16) = 4.
create(:badge_achievement, user: user, badge: badge1)
create(:badge_achievement, user: user, badge: badge2)

# Initial update
article.update_score
score_with_badges = article.score

# Remove achievements
user.badge_achievements.destroy_all
# Reload user to clear associations cache
user.reload
article.update_score
score_without_badges = article.score

expect(score_with_badges - score_without_badges).to eq(4)
end

it "handles zero bonus weight correctly" do
create(:badge_achievement, user: user, badge: create(:badge, title: "Zero Badge", bonus_weight: 0))
article.update_score
score_with_zero_badge = article.score

user.badge_achievements.destroy_all
user.reload
article.update_score
score_without_badges = article.score

expect(score_with_zero_badge).to eq(score_without_badges)
end
end
Loading
Loading