diff --git a/Gemfile b/Gemfile
index 32b05207f3..ea48d19ca3 100644
--- a/Gemfile
+++ b/Gemfile
@@ -207,3 +207,5 @@ gem 'net-http', '~> 0.4.0'
gem 'rubyzip', '~> 2.3'
gem 'hcaptcha', '~> 7.1'
+
+gem 'mail', '~> 2.8'
diff --git a/Gemfile.lock b/Gemfile.lock
index acc4394940..3508ad8d54 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -356,7 +356,7 @@ GEM
rdoc
reline (>= 0.4.2)
jmespath (1.6.2)
- json (2.7.1)
+ json (2.7.2)
json-canonicalization (1.0.0)
json-jwt (1.15.3.1)
activesupport (>= 4.2)
@@ -607,7 +607,7 @@ GEM
redlock (1.3.2)
redis (>= 3.0.0, < 6.0)
regexp_parser (2.9.0)
- reline (0.4.3)
+ reline (0.5.0)
io-console (~> 0.5)
request_store (1.5.1)
rack (>= 1.4)
@@ -671,7 +671,7 @@ GEM
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
- rubocop-rspec (2.28.0)
+ rubocop-rspec (2.29.1)
rubocop (~> 1.40)
rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22)
@@ -692,7 +692,7 @@ GEM
sanitize (6.1.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
- scenic (1.7.0)
+ scenic (1.8.0)
activerecord (>= 4.0.0)
railties (>= 4.0.0)
selenium-webdriver (4.19.0)
@@ -880,6 +880,7 @@ DEPENDENCIES
letter_opener_web (~> 2.0)
link_header (~> 0.0)
lograge (~> 0.12)
+ mail (~> 2.8)
mario-redis-lock (~> 1.2)
md-paperclip-azure (~> 2.2)
memory_profiler
diff --git a/Vagrantfile b/Vagrantfile
index 12bd1ba67a..8a95e91f36 100644
--- a/Vagrantfile
+++ b/Vagrantfile
@@ -173,6 +173,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
# Otherwise, you can access the site at http://localhost:3000 and http://localhost:4000 , http://localhost:8080
config.vm.network :forwarded_port, guest: 3000, host: 3000
+ config.vm.network :forwarded_port, guest: 3035, host: 3035
config.vm.network :forwarded_port, guest: 4000, host: 4000
config.vm.network :forwarded_port, guest: 8080, host: 8080
config.vm.network :forwarded_port, guest: 9200, host: 9200
diff --git a/app/javascript/mastodon/utils/__tests__/html-test.s b/app/javascript/mastodon/utils/__tests__/html-test.ts
similarity index 67%
rename from app/javascript/mastodon/utils/__tests__/html-test.s
rename to app/javascript/mastodon/utils/__tests__/html-test.ts
index d948cf4c5d..99bfdcb801 100644
--- a/app/javascript/mastodon/utils/__tests__/html-test.s
+++ b/app/javascript/mastodon/utils/__tests__/html-test.ts
@@ -3,7 +3,9 @@ import * as html from '../html';
describe('html', () => {
describe('unescapeHTML', () => {
it('returns unescaped HTML', () => {
- const output = html.unescapeHTML('
lorem
ipsum
<br>');
+ const output = html.unescapeHTML(
+ 'lorem
ipsum
<br>',
+ );
expect(output).toEqual('lorem\n\nipsum\n
');
});
});
diff --git a/app/javascript/mastodon/utils/__tests__/numbers.ts b/app/javascript/mastodon/utils/__tests__/numbers.ts
new file mode 100644
index 0000000000..d1d1444e8a
--- /dev/null
+++ b/app/javascript/mastodon/utils/__tests__/numbers.ts
@@ -0,0 +1,24 @@
+import { DECIMAL_UNITS, toShortNumber } from '../numbers';
+
+interface TableRow {
+ input: number;
+ base: number;
+ unit: number;
+ digits: number;
+}
+
+describe.each`
+ input | base | unit | digits
+ ${10_000_000} | ${10} | ${DECIMAL_UNITS.MILLION} | ${0}
+ ${2_789_123} | ${2.789123} | ${DECIMAL_UNITS.MILLION} | ${1}
+ ${12_345_789} | ${12.345789} | ${DECIMAL_UNITS.MILLION} | ${0}
+ ${10_000_000_000} | ${10} | ${DECIMAL_UNITS.BILLION} | ${0}
+ ${12} | ${12} | ${DECIMAL_UNITS.ONE} | ${0}
+ ${123} | ${123} | ${DECIMAL_UNITS.ONE} | ${0}
+ ${1234} | ${1.234} | ${DECIMAL_UNITS.THOUSAND} | ${1}
+ ${6666} | ${6.666} | ${DECIMAL_UNITS.THOUSAND} | ${1}
+`('toShortNumber', ({ input, base, unit, digits }: TableRow) => {
+ test(`correctly formats ${input}`, () => {
+ expect(toShortNumber(input)).toEqual([base, unit, digits]);
+ });
+});
diff --git a/app/javascript/styles/mastodon/emoji_picker.scss b/app/javascript/styles/mastodon/emoji_picker.scss
index 9473858371..b9fdaa5847 100644
--- a/app/javascript/styles/mastodon/emoji_picker.scss
+++ b/app/javascript/styles/mastodon/emoji_picker.scss
@@ -112,9 +112,11 @@
border: 0;
}
- &:focus,
- &:active {
+ &:active,
+ &:focus {
outline: none !important;
+ border-width: 1px !important;
+ border-color: $ui-button-background-color;
}
&::-webkit-search-cancel-button {
diff --git a/app/lib/admin/metrics/measure/tag_servers_measure.rb b/app/lib/admin/metrics/measure/tag_servers_measure.rb
index e0f1bf3440..f273d739d0 100644
--- a/app/lib/admin/metrics/measure/tag_servers_measure.rb
+++ b/app/lib/admin/metrics/measure/tag_servers_measure.rb
@@ -46,11 +46,11 @@ class Admin::Metrics::Measure::TagServersMeasure < Admin::Metrics::Measure::Base
end
def earliest_status_id
- Mastodon::Snowflake.id_at(@start_at, with_random: false)
+ Mastodon::Snowflake.id_at(@start_at.beginning_of_day, with_random: false)
end
def latest_status_id
- Mastodon::Snowflake.id_at(@end_at, with_random: false)
+ Mastodon::Snowflake.id_at(@end_at.end_of_day, with_random: false)
end
def tag
diff --git a/app/models/concerns/custom_filter_cache.rb b/app/models/concerns/custom_filter_cache.rb
new file mode 100644
index 0000000000..79b22f11f1
--- /dev/null
+++ b/app/models/concerns/custom_filter_cache.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module CustomFilterCache
+ extend ActiveSupport::Concern
+
+ included do
+ after_commit :invalidate_cache!
+ before_destroy :prepare_cache_invalidation!
+ before_save :prepare_cache_invalidation!
+
+ delegate(
+ :invalidate_cache!,
+ :prepare_cache_invalidation!,
+ to: :custom_filter
+ )
+ end
+end
diff --git a/app/models/custom_filter_keyword.rb b/app/models/custom_filter_keyword.rb
index 979d0b822e..112798b10a 100644
--- a/app/models/custom_filter_keyword.rb
+++ b/app/models/custom_filter_keyword.rb
@@ -13,16 +13,14 @@
#
class CustomFilterKeyword < ApplicationRecord
+ include CustomFilterCache
+
belongs_to :custom_filter
validates :keyword, presence: true
alias_attribute :phrase, :keyword
- before_save :prepare_cache_invalidation!
- before_destroy :prepare_cache_invalidation!
- after_commit :invalidate_cache!
-
def to_regex
if whole_word?
/(?mix:#{to_regex_sb}#{Regexp.escape(keyword)}#{to_regex_eb})/
@@ -40,12 +38,4 @@ class CustomFilterKeyword < ApplicationRecord
def to_regex_eb
/[[:word:]]\z/.match?(keyword) ? '\b' : ''
end
-
- def prepare_cache_invalidation!
- custom_filter.prepare_cache_invalidation!
- end
-
- def invalidate_cache!
- custom_filter.invalidate_cache!
- end
end
diff --git a/app/models/custom_filter_status.rb b/app/models/custom_filter_status.rb
index 0a5650204a..58b61cd79d 100644
--- a/app/models/custom_filter_status.rb
+++ b/app/models/custom_filter_status.rb
@@ -12,27 +12,17 @@
#
class CustomFilterStatus < ApplicationRecord
+ include CustomFilterCache
+
belongs_to :custom_filter
belongs_to :status
validates :status, uniqueness: { scope: :custom_filter }
validate :validate_status_access
- before_save :prepare_cache_invalidation!
- before_destroy :prepare_cache_invalidation!
- after_commit :invalidate_cache!
-
private
def validate_status_access
errors.add(:status_id, :invalid) unless StatusPolicy.new(custom_filter.account, status).show?
end
-
- def prepare_cache_invalidation!
- custom_filter.prepare_cache_invalidation!
- end
-
- def invalidate_cache!
- custom_filter.invalidate_cache!
- end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index a082c178e2..0109eec781 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -95,6 +95,8 @@ class User < ApplicationRecord
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? && !Setting.require_invite_text }
validates :invite_request, presence: true, on: :create, if: :invite_text_required?
+ validates :email, presence: true, email_address: true
+
validates_with BlacklistedEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? }
validates_with EmailMxValidator, if: :validate_email_dns?
validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
diff --git a/app/validators/email_address_validator.rb b/app/validators/email_address_validator.rb
new file mode 100644
index 0000000000..ed0bb11652
--- /dev/null
+++ b/app/validators/email_address_validator.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+# NOTE: I initially wrote this as `EmailValidator` but it ended up clashing
+# with an indirect dependency of ours, `validate_email`, which, turns out,
+# has the same approach as we do, but with an extra check disallowing
+# single-label domains. Decided to not switch to `validate_email` because
+# we do want to allow at least `localhost`.
+
+class EmailAddressValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ value = value.strip
+
+ address = Mail::Address.new(value)
+ record.errors.add(attribute, :invalid) if address.address != value
+ rescue Mail::Field::FieldError
+ record.errors.add(attribute, :invalid)
+ end
+end
diff --git a/app/views/application/mailer/_feature.html.haml b/app/views/application/mailer/_feature.html.haml
index 5facdd0866..94dd4b9cff 100644
--- a/app/views/application/mailer/_feature.html.haml
+++ b/app/views/application/mailer/_feature.html.haml
@@ -4,7 +4,7 @@
%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
%tr
%td.email-feature-td
- .email-desktop-flex{ class: ('email-dir-rtl' if defined?(text_first_on_desktop) && !text_first_on_desktop) }
+ .email-desktop-flex{ class: ('email-dir-rtl' if feature_iteration.index.odd?) }
/[if mso]
.email-desktop-column
@@ -24,7 +24,7 @@
%tr
%td.email-column-td
- if defined?(feature)
- %p{ class: ('email-desktop-text-right' if defined?(text_first_on_desktop) && text_first_on_desktop) }
+ %p{ class: ('email-desktop-text-right' if feature_iteration.index.even?) }
= image_tag frontend_asset_url("images/mailer-new/welcome/feature_#{feature}.png"), alt: '', width: 240, height: 230
/[if mso]
|
diff --git a/app/views/user_mailer/welcome.html.haml b/app/views/user_mailer/welcome.html.haml
index 97fb0a2c97..0f9cbf36ff 100644
--- a/app/views/user_mailer/welcome.html.haml
+++ b/app/views/user_mailer/welcome.html.haml
@@ -68,7 +68,4 @@
%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' }
%tr
%td.email-extra-td
- = render 'application/mailer/feature', feature: 'control', text_first_on_desktop: true
- = render 'application/mailer/feature', feature: 'audience', text_first_on_desktop: false
- = render 'application/mailer/feature', feature: 'moderation', text_first_on_desktop: true
- = render 'application/mailer/feature', feature: 'creativity', text_first_on_desktop: false
+ = render partial: 'application/mailer/feature', collection: %w(control audience moderation creativity)
diff --git a/spec/lib/admin/metrics/dimension/languages_dimension_spec.rb b/spec/lib/admin/metrics/dimension/languages_dimension_spec.rb
index 1722c4c616..9d80970693 100644
--- a/spec/lib/admin/metrics/dimension/languages_dimension_spec.rb
+++ b/spec/lib/admin/metrics/dimension/languages_dimension_spec.rb
@@ -3,7 +3,7 @@
require 'rails_helper'
describe Admin::Metrics::Dimension::LanguagesDimension do
- subject(:dimension) { described_class.new(start_at, end_at, limit, params) }
+ subject { described_class.new(start_at, end_at, limit, params) }
let(:start_at) { 2.days.ago }
let(:end_at) { Time.now.utc }
@@ -11,8 +11,21 @@ describe Admin::Metrics::Dimension::LanguagesDimension do
let(:params) { ActionController::Parameters.new }
describe '#data' do
- it 'runs data query without error' do
- expect { dimension.data }.to_not raise_error
+ let(:alice) { Fabricate(:user, locale: 'en', current_sign_in_at: 1.day.ago) }
+ let(:bob) { Fabricate(:user, locale: 'en', current_sign_in_at: 30.days.ago) }
+
+ before do
+ alice.update(current_sign_in_at: 1.day.ago)
+ bob.update(current_sign_in_at: 30.days.ago)
+ end
+
+ it 'returns locales with sign in counts' do
+ expect(subject.data.size)
+ .to eq(1)
+ expect(subject.data.map(&:symbolize_keys))
+ .to contain_exactly(
+ include(key: 'en', value: '1')
+ )
end
end
end
diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb
index 404b834702..5a8c293740 100644
--- a/spec/mailers/user_mailer_spec.rb
+++ b/spec/mailers/user_mailer_spec.rb
@@ -247,6 +247,12 @@ describe UserMailer do
describe '#welcome' do
let(:mail) { described_class.welcome(receiver) }
+ before do
+ # This is a bit hacky and low-level but this allows stubbing trending tags
+ tag_ids = Fabricate.times(5, :tag).pluck(:id)
+ allow(Trends.tags).to receive(:query).and_return(instance_double(Trends::Query, allowed: Tag.where(id: tag_ids)))
+ end
+
it 'renders welcome mail' do
expect(mail)
.to be_present
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 39986f476c..2a07263069 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -38,6 +38,12 @@ RSpec.describe User do
user.save(validate: false)
expect(user.valid?).to be true
end
+
+ it 'is valid with a localhost e-mail address' do
+ user = Fabricate.build(:user, email: 'admin@localhost')
+ user.valid?
+ expect(user.valid?).to be true
+ end
end
describe 'Normalizations' do
diff --git a/spec/requests/api/web/embeds_spec.rb b/spec/requests/api/web/embeds_spec.rb
index 6314f43aaf..0e6195204b 100644
--- a/spec/requests/api/web/embeds_spec.rb
+++ b/spec/requests/api/web/embeds_spec.rb
@@ -137,6 +137,18 @@ RSpec.describe '/api/web/embed' do
end
end
+ context 'when sanitizing the fragment fails' do
+ let(:call_result) { { html: 'ok' } }
+
+ before { allow(Sanitize).to receive(:fragment).and_raise(ArgumentError) }
+
+ it 'returns http not found' do
+ subject
+
+ expect(response).to have_http_status(404)
+ end
+ end
+
context 'when failing to fetch OEmbed' do
let(:call_result) { nil }