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 }