From b3e9094e14e0ed693173c66d3826d2d52a67a725 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 10 Apr 2021 11:50:41 +0200 Subject: Bump devise-two-factor from git to 4.0.0 (#15987) --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'Gemfile') diff --git a/Gemfile b/Gemfile index a813e82f2..cb24207ca 100644 --- a/Gemfile +++ b/Gemfile @@ -34,7 +34,7 @@ gem 'iso-639' gem 'chewy', '~> 5.2' gem 'cld3', '~> 3.4.1' gem 'devise', '~> 4.7' -gem 'devise-two-factor', git: 'https://github.com/ClearlyClaire/devise-two-factor', ref: '594bb8a32e6f94df7e5ba7c9399eaf9ff25bac0d' +gem 'devise-two-factor', '~> 4.0' group :pam_authentication, optional: true do gem 'devise_pam_authenticatable2', '~> 9.2' -- cgit From 619fad6cf8078ea997554081febe850404bee73c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 11 Apr 2021 11:22:50 +0200 Subject: Remove spam check and dependency on nilsimsa gem (#16011) --- Gemfile | 1 - Gemfile.lock | 8 -- app/controllers/admin/dashboard_controller.rb | 1 - app/lib/activitypub/activity/create.rb | 5 - app/lib/spam_check.rb | 198 -------------------------- app/models/form/admin_settings.rb | 2 - app/services/process_mentions_service.rb | 5 - app/services/remove_status_service.rb | 5 - app/views/admin/dashboard/index.html.haml | 2 - app/views/admin/settings/edit.html.haml | 3 - config/locales/en.yml | 6 - config/settings.yml | 1 - spec/lib/spam_check_spec.rb | 192 ------------------------- 13 files changed, 429 deletions(-) delete mode 100644 app/lib/spam_check.rb delete mode 100644 spec/lib/spam_check_spec.rb (limited to 'Gemfile') diff --git a/Gemfile b/Gemfile index cb24207ca..1190f2558 100644 --- a/Gemfile +++ b/Gemfile @@ -62,7 +62,6 @@ gem 'idn-ruby', require: 'idn' gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' gem 'mime-types', '~> 3.3.1', require: 'mime/types/columnar' -gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532' gem 'nokogiri', '~> 1.11' gem 'nsa', git: 'https://github.com/Gargron/nsa', ref: 'd1079e0cdafdfed7f9f35478d13b9bdaa65965c0' gem 'oj', '~> 3.11' diff --git a/Gemfile.lock b/Gemfile.lock index 11765a967..8bea31332 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -18,13 +18,6 @@ GIT activerecord (>= 6.1.0) activesupport (>= 6.1.0) -GIT - remote: https://github.com/witgo/nilsimsa - revision: fd184883048b922b176939f851338d0a4971a532 - ref: fd184883048b922b176939f851338d0a4971a532 - specs: - nilsimsa (1.1.2) - GEM remote: https://rubygems.org/ specs: @@ -762,7 +755,6 @@ DEPENDENCIES microformats (~> 4.2) mime-types (~> 3.3.1) net-ldap (~> 0.17) - nilsimsa! nokogiri (~> 1.11) nsa! oj (~> 3.11) diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 4422825ee..c829ed98f 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -35,7 +35,6 @@ module Admin @whitelist_enabled = whitelist_mode? @profile_directory = Setting.profile_directory @timeline_preview = Setting.timeline_preview - @spam_check_enabled = Setting.spam_check_enabled @trends_enabled = Setting.trends end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 0fa306cdd..9f6dd9ce0 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -88,7 +88,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity resolve_thread(@status) fetch_replies(@status) - check_for_spam distribute(@status) forward_for_reply end @@ -492,10 +491,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity Tombstone.exists?(uri: object_uri) end - def check_for_spam - SpamCheck.perform(@status) - end - def forward_for_reply return unless @status.distributable? && @json['signature'].present? && reply_to_local? diff --git a/app/lib/spam_check.rb b/app/lib/spam_check.rb deleted file mode 100644 index dcb2db9ca..000000000 --- a/app/lib/spam_check.rb +++ /dev/null @@ -1,198 +0,0 @@ -# frozen_string_literal: true - -class SpamCheck - include Redisable - include ActionView::Helpers::TextHelper - - # Threshold over which two Nilsimsa values are considered - # to refer to the same text - NILSIMSA_COMPARE_THRESHOLD = 95 - - # Nilsimsa doesn't work well on small inputs, so below - # this size, we check only for exact matches with MD5 - NILSIMSA_MIN_SIZE = 10 - - # How long to keep the trail of digests between updates, - # there is no reason to store it forever - EXPIRE_SET_AFTER = 1.week.seconds - - # How many digests to keep in an account's trail. If it's - # too small, spam could rotate around different message templates - MAX_TRAIL_SIZE = 10 - - # How many detected duplicates to allow through before - # considering the message as spam - THRESHOLD = 5 - - def initialize(status) - @account = status.account - @status = status - end - - def skip? - disabled? || already_flagged? || trusted? || no_unsolicited_mentions? || solicited_reply? - end - - def spam? - if insufficient_data? - false - elsif nilsimsa? - digests_over_threshold?('nilsimsa') { |_, other_digest| nilsimsa_compare_value(digest, other_digest) >= NILSIMSA_COMPARE_THRESHOLD } - else - digests_over_threshold?('md5') { |_, other_digest| other_digest == digest } - end - end - - def flag! - auto_report_status! - end - - def remember! - # The scores in sorted sets don't actually have enough bits to hold an exact - # value of our snowflake IDs, so we use it only for its ordering property. To - # get the correct status ID back, we have to save it in the string value - - redis.zadd(redis_key, @status.id, digest_with_algorithm) - redis.zremrangebyrank(redis_key, 0, -(MAX_TRAIL_SIZE + 1)) - redis.expire(redis_key, EXPIRE_SET_AFTER) - end - - def reset! - redis.del(redis_key) - end - - def hashable_text - return @hashable_text if defined?(@hashable_text) - - @hashable_text = @status.text - @hashable_text = remove_mentions(@hashable_text) - @hashable_text = strip_tags(@hashable_text) unless @status.local? - @hashable_text = normalize_unicode(@status.spoiler_text + ' ' + @hashable_text) - @hashable_text = remove_whitespace(@hashable_text) - end - - def insufficient_data? - hashable_text.blank? - end - - def digest - @digest ||= begin - if nilsimsa? - Nilsimsa.new(hashable_text).hexdigest - else - Digest::MD5.hexdigest(hashable_text) - end - end - end - - def digest_with_algorithm - if nilsimsa? - ['nilsimsa', digest, @status.id].join(':') - else - ['md5', digest, @status.id].join(':') - end - end - - class << self - def perform(status) - spam_check = new(status) - - return if spam_check.skip? - - if spam_check.spam? - spam_check.flag! - else - spam_check.remember! - end - end - end - - private - - def disabled? - !Setting.spam_check_enabled - end - - def remove_mentions(text) - return text.gsub(Account::MENTION_RE, '') if @status.local? - - Nokogiri::HTML.fragment(text).tap do |html| - mentions = @status.mentions.map { |mention| ActivityPub::TagManager.instance.url_for(mention.account) } - - html.traverse do |element| - element.unlink if element.name == 'a' && mentions.include?(element['href']) - end - end.to_s - end - - def normalize_unicode(text) - text.unicode_normalize(:nfkc).downcase - end - - def remove_whitespace(text) - text.gsub(/\s+/, ' ').strip - end - - def auto_report_status! - status_ids = Status.where(visibility: %i(public unlisted)).where(id: matching_status_ids).pluck(:id) + [@status.id] if @status.distributable? - ReportService.new.call(Account.representative, @account, status_ids: status_ids, comment: I18n.t('spam_check.spam_detected')) - end - - def already_flagged? - @account.silenced? || @account.targeted_reports.unresolved.where(account_id: -99).exists? - end - - def trusted? - @account.trust_level > Account::TRUST_LEVELS[:untrusted] || (@account.local? && @account.user_staff?) - end - - def no_unsolicited_mentions? - @status.mentions.all? { |mention| mention.silent? || (!@account.local? && !mention.account.local?) || mention.account.following?(@account) } - end - - def solicited_reply? - !@status.thread.nil? && @status.thread.mentions.where(account: @account).exists? - end - - def nilsimsa_compare_value(first, second) - first = [first].pack('H*') - second = [second].pack('H*') - bits = 0 - - 0.upto(31) do |i| - bits += Nilsimsa::POPC[255 & (first[i].ord ^ second[i].ord)].ord - end - - 128 - bits # -128 <= Nilsimsa Compare Value <= 128 - end - - def nilsimsa? - hashable_text.size > NILSIMSA_MIN_SIZE - end - - def other_digests - redis.zrange(redis_key, 0, -1) - end - - def digests_over_threshold?(filter_algorithm) - other_digests.select do |record| - algorithm, other_digest, status_id = record.split(':') - - next unless algorithm == filter_algorithm - - yield algorithm, other_digest, status_id - end.size >= THRESHOLD - end - - def matching_status_ids - if nilsimsa? - other_digests.filter_map { |record| record.split(':')[2] if record.start_with?('nilsimsa') && nilsimsa_compare_value(digest, record.split(':')[1]) >= NILSIMSA_COMPARE_THRESHOLD } - else - other_digests.filter_map { |record| record.split(':')[2] if record.start_with?('md5') && record.split(':')[1] == digest } - end - end - - def redis_key - @redis_key ||= "spam_check:#{@account.id}" - end -end diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index e9f78da21..b5c3dcdbe 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -29,7 +29,6 @@ class Form::AdminSettings thumbnail hero mascot - spam_check_enabled trends trendable_by_default show_domain_blocks @@ -48,7 +47,6 @@ class Form::AdminSettings show_known_fediverse_at_about_page preview_sensitive_media profile_directory - spam_check_enabled trends trendable_by_default noindex diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 4c02c7865..73dbb1834 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -43,7 +43,6 @@ class ProcessMentionsService < BaseService end status.save! - check_for_spam(status) mentions.each { |mention| create_notification(mention) } end @@ -72,8 +71,4 @@ class ProcessMentionsService < BaseService def resolve_account_service ResolveAccountService.new end - - def check_for_spam(status) - SpamCheck.perform(status) - end end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index d6043fb5d..d642beeaa 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -41,7 +41,6 @@ class RemoveStatusService < BaseService remove_from_hashtags remove_from_public remove_from_media if @status.media_attachments.any? - remove_from_spam_check remove_media end @@ -163,10 +162,6 @@ class RemoveStatusService < BaseService @status.media_attachments.destroy_all end - def remove_from_spam_check - redis.zremrangebyscore("spam_check:#{@status.account_id}", @status.id, @status.id) - end - def lock_options { redis: Redis.current, key: "distribute:#{@status.id}" } end diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 205538402..57a753e6b 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -77,8 +77,6 @@ = feature_hint(link_to(t('admin.dashboard.trends'), edit_admin_settings_path), @trends_enabled) %li = feature_hint(link_to(t('admin.dashboard.feature_relay'), admin_relays_path), @relay_enabled) - %li - = feature_hint(link_to(t('admin.dashboard.feature_spam_check'), edit_admin_settings_path), @spam_check_enabled) .dashboard__widgets__versions %div diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index 159bd4b0a..0e705f205 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -92,9 +92,6 @@ .fields-group = f.input :noindex, as: :boolean, wrapper: :with_label, label: t('admin.settings.default_noindex.title'), hint: t('admin.settings.default_noindex.desc_html') - .fields-group - = f.input :spam_check_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.spam_check_enabled.title'), hint: t('admin.settings.spam_check_enabled.desc_html') - %hr.spacer/ .fields-group diff --git a/config/locales/en.yml b/config/locales/en.yml index 182a8e985..3387b4df6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -363,7 +363,6 @@ en: feature_profile_directory: Profile directory feature_registrations: Registrations feature_relay: Federation relay - feature_spam_check: Anti-spam feature_timeline_preview: Timeline preview features: Features hidden_service: Federation with hidden services @@ -627,9 +626,6 @@ en: desc_html: You can write your own privacy policy, terms of service or other legalese. You can use HTML tags title: Custom terms of service site_title: Server name - spam_check_enabled: - desc_html: Mastodon can auto-report accounts that send repeated unsolicited messages. There may be false positives. - title: Anti-spam automation thumbnail: desc_html: Used for previews via OpenGraph and API. 1200x630px recommended title: Server thumbnail @@ -1209,8 +1205,6 @@ en: relationships: Follows and followers two_factor_authentication: Two-factor Auth webauthn_authentication: Security keys - spam_check: - spam_detected: This is an automated report. Spam has been detected. statuses: attached: audio: diff --git a/config/settings.yml b/config/settings.yml index 9cf68a096..b79ea620c 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -67,7 +67,6 @@ defaults: &defaults activity_api_enabled: true peers_api_enabled: true show_known_fediverse_at_about_page: true - spam_check_enabled: true show_domain_blocks: 'disabled' show_domain_blocks_rationale: 'disabled' require_invite_text: false diff --git a/spec/lib/spam_check_spec.rb b/spec/lib/spam_check_spec.rb deleted file mode 100644 index 159d83257..000000000 --- a/spec/lib/spam_check_spec.rb +++ /dev/null @@ -1,192 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe SpamCheck do - let!(:sender) { Fabricate(:account) } - let!(:alice) { Fabricate(:account, username: 'alice') } - let!(:bob) { Fabricate(:account, username: 'bob') } - - def status_with_html(text, options = {}) - status = PostStatusService.new.call(sender, { text: text }.merge(options)) - status.update_columns(text: Formatter.instance.format(status), local: false) - status - end - - describe '#hashable_text' do - it 'removes mentions from HTML for remote statuses' do - status = status_with_html('@alice Hello') - expect(described_class.new(status).hashable_text).to eq 'hello' - end - - it 'removes mentions from text for local statuses' do - status = PostStatusService.new.call(alice, text: "Hey @#{sender.username}, how are you?") - expect(described_class.new(status).hashable_text).to eq 'hey , how are you?' - end - end - - describe '#insufficient_data?' do - it 'returns true when there is no text' do - status = status_with_html('@alice') - expect(described_class.new(status).insufficient_data?).to be true - end - - it 'returns false when there is text' do - status = status_with_html('@alice h') - expect(described_class.new(status).insufficient_data?).to be false - end - end - - describe '#digest' do - it 'returns a string' do - status = status_with_html('@alice Hello world') - expect(described_class.new(status).digest).to be_a String - end - end - - describe '#spam?' do - it 'returns false for a unique status' do - status = status_with_html('@alice Hello') - expect(described_class.new(status).spam?).to be false - end - - it 'returns false for different statuses to the same recipient' do - status1 = status_with_html('@alice Hello') - described_class.new(status1).remember! - status2 = status_with_html('@alice Are you available to talk?') - expect(described_class.new(status2).spam?).to be false - end - - it 'returns false for statuses with different content warnings' do - status1 = status_with_html('@alice Are you available to talk?') - described_class.new(status1).remember! - status2 = status_with_html('@alice Are you available to talk?', spoiler_text: 'This is a completely different matter than what I was talking about previously, I swear!') - expect(described_class.new(status2).spam?).to be false - end - - it 'returns false for different statuses to different recipients' do - status1 = status_with_html('@alice How is it going?') - described_class.new(status1).remember! - status2 = status_with_html('@bob Are you okay?') - expect(described_class.new(status2).spam?).to be false - end - - it 'returns false for very short different statuses to different recipients' do - status1 = status_with_html('@alice 🙄') - described_class.new(status1).remember! - status2 = status_with_html('@bob Huh?') - expect(described_class.new(status2).spam?).to be false - end - - it 'returns false for statuses with no text' do - status1 = status_with_html('@alice') - described_class.new(status1).remember! - status2 = status_with_html('@bob') - expect(described_class.new(status2).spam?).to be false - end - - it 'returns true for duplicate statuses to the same recipient' do - described_class::THRESHOLD.times do - status1 = status_with_html('@alice Hello') - described_class.new(status1).remember! - end - - status2 = status_with_html('@alice Hello') - expect(described_class.new(status2).spam?).to be true - end - - it 'returns true for duplicate statuses to different recipients' do - described_class::THRESHOLD.times do - status1 = status_with_html('@alice Hello') - described_class.new(status1).remember! - end - - status2 = status_with_html('@bob Hello') - expect(described_class.new(status2).spam?).to be true - end - - it 'returns true for nearly identical statuses with random numbers' do - source_text = 'Sodium, atomic number 11, was first isolated by Humphry Davy in 1807. A chemical component of salt, he named it Na in honor of the saltiest region on earth, North America.' - - described_class::THRESHOLD.times do - status1 = status_with_html('@alice ' + source_text + ' 1234') - described_class.new(status1).remember! - end - - status2 = status_with_html('@bob ' + source_text + ' 9568') - expect(described_class.new(status2).spam?).to be true - end - end - - describe '#skip?' do - it 'returns true when the sender is already silenced' do - status = status_with_html('@alice Hello') - sender.silence! - expect(described_class.new(status).skip?).to be true - end - - it 'returns true when the mentioned person follows the sender' do - status = status_with_html('@alice Hello') - alice.follow!(sender) - expect(described_class.new(status).skip?).to be true - end - - it 'returns false when even one mentioned person doesn\'t follow the sender' do - status = status_with_html('@alice @bob Hello') - alice.follow!(sender) - expect(described_class.new(status).skip?).to be false - end - - it 'returns true when the sender is replying to a status that mentions the sender' do - parent = PostStatusService.new.call(alice, text: "Hey @#{sender.username}, how are you?") - status = status_with_html('@alice @bob Hello', thread: parent) - expect(described_class.new(status).skip?).to be true - end - end - - describe '#remember!' do - let(:status) { status_with_html('@alice') } - let(:spam_check) { described_class.new(status) } - let(:redis_key) { spam_check.send(:redis_key) } - - it 'remembers' do - expect(Redis.current.exists?(redis_key)).to be true - spam_check.remember! - expect(Redis.current.exists?(redis_key)).to be true - end - end - - describe '#reset!' do - let(:status) { status_with_html('@alice') } - let(:spam_check) { described_class.new(status) } - let(:redis_key) { spam_check.send(:redis_key) } - - before do - spam_check.remember! - end - - it 'resets' do - expect(Redis.current.exists?(redis_key)).to be true - spam_check.reset! - expect(Redis.current.exists?(redis_key)).to be false - end - end - - describe '#flag!' do - let!(:status1) { status_with_html('@alice General Kenobi you are a bold one') } - let!(:status2) { status_with_html('@alice @bob General Kenobi, you are a bold one') } - - before do - described_class.new(status1).remember! - described_class.new(status2).flag! - end - - it 'creates a report about the account' do - expect(sender.targeted_reports.unresolved.count).to eq 1 - end - - it 'attaches both matching statuses to the report' do - expect(sender.targeted_reports.first.status_ids).to include(status1.id, status2.id) - end - end -end -- cgit From ad61265268f13d9b2a04e2e176724d8a7376f85a Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 12 Apr 2021 03:35:58 +0200 Subject: Remove dependency on pluck_each gem (#16012) --- Gemfile | 1 - Gemfile.lock | 10 ---------- config/application.rb | 1 + lib/active_record/batches.rb | 44 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 lib/active_record/batches.rb (limited to 'Gemfile') diff --git a/Gemfile b/Gemfile index 1190f2558..d4385f014 100644 --- a/Gemfile +++ b/Gemfile @@ -157,4 +157,3 @@ gem 'concurrent-ruby', require: false gem 'connection_pool', require: false gem 'xorcist', '~> 1.1' -gem 'pluck_each', git: 'https://github.com/nsommer/pluck_each', ref: '73be0947c52fc54bf6d7085378db008358aac5eb' diff --git a/Gemfile.lock b/Gemfile.lock index 8bea31332..a3b11ab6c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,15 +9,6 @@ GIT sidekiq (>= 3.5) statsd-ruby (~> 1.4, >= 1.4.0) -GIT - remote: https://github.com/nsommer/pluck_each - revision: 73be0947c52fc54bf6d7085378db008358aac5eb - ref: 73be0947c52fc54bf6d7085378db008358aac5eb - specs: - pluck_each (0.1.3) - activerecord (>= 6.1.0) - activesupport (>= 6.1.0) - GEM remote: https://rubygems.org/ specs: @@ -771,7 +762,6 @@ DEPENDENCIES pg (~> 1.2) pghero (~> 2.8) pkg-config (~> 1.4) - pluck_each! posix-spawn premailer-rails private_address_check (~> 0.5) diff --git a/config/application.rb b/config/application.rb index c911e76dc..eb2c91677 100644 --- a/config/application.rb +++ b/config/application.rb @@ -29,6 +29,7 @@ require_relative '../lib/webpacker/helper_extensions' require_relative '../lib/action_dispatch/cookie_jar_extensions' require_relative '../lib/rails/engine_extensions' require_relative '../lib/active_record/database_tasks_extensions' +require_relative '../lib/active_record/batches' Dotenv::Railtie.load diff --git a/lib/active_record/batches.rb b/lib/active_record/batches.rb new file mode 100644 index 000000000..55d29e52e --- /dev/null +++ b/lib/active_record/batches.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module ActiveRecord + module Batches + def pluck_each(*column_names) + relation = self + + options = column_names.extract_options! + + flatten = column_names.size == 1 + batch_limit = options[:batch_limit] || 1_000 + order = options[:order] || :asc + + column_names.unshift(primary_key) + + relation = relation.reorder(batch_order(order)).limit(batch_limit) + relation.skip_query_cache! + + batch_relation = relation + + loop do + batch = batch_relation.pluck(*column_names) + + break if batch.empty? + + primary_key_offset = batch.last[0] + + batch.each do |record| + if flatten + yield record[1] + else + yield record[1..-1] + end + end + + break if batch.size < batch_limit + + batch_relation = relation.where( + predicate_builder[primary_key, primary_key_offset, order == :desc ? :lt : :gt] + ) + end + end + end +end -- cgit From 120965eb0b1b0da6906bb242da50a77367defd96 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 12 Apr 2021 14:25:34 +0200 Subject: Change Web Push API deliveries to use request pooling (#16014) --- Gemfile | 2 +- Gemfile.lock | 2 +- app/models/web/push_subscription.rb | 91 +++++++++++------------ app/workers/web/push_notification_worker.rb | 65 +++++++++++++--- spec/workers/web/push_notification_worker_spec.rb | 48 ++++++++++++ 5 files changed, 150 insertions(+), 58 deletions(-) create mode 100644 spec/workers/web/push_notification_worker_spec.rb (limited to 'Gemfile') diff --git a/Gemfile b/Gemfile index d4385f014..cc77ee618 100644 --- a/Gemfile +++ b/Gemfile @@ -94,7 +94,7 @@ gem 'tty-prompt', '~> 0.23', require: false gem 'twitter-text', '~> 3.1.0' gem 'tzinfo-data', '~> 1.2021' gem 'webpacker', '~> 5.2' -gem 'webpush' +gem 'webpush', '~> 0.3' gem 'webauthn', '~> 3.0.0.alpha1' gem 'json-ld' diff --git a/Gemfile.lock b/Gemfile.lock index b1fb350d4..1d3c24595 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -808,5 +808,5 @@ DEPENDENCIES webauthn (~> 3.0.0.alpha1) webmock (~> 3.12) webpacker (~> 5.2) - webpush + webpush (~> 0.3) xorcist (~> 1.1) diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb index c407a7789..7609b1bfc 100644 --- a/app/models/web/push_subscription.rb +++ b/app/models/web/push_subscription.rb @@ -24,81 +24,80 @@ class Web::PushSubscription < ApplicationRecord validates :key_p256dh, presence: true validates :key_auth, presence: true - def push(notification) - I18n.with_locale(associated_user&.locale || I18n.default_locale) do - push_payload(payload_for_notification(notification), 48.hours.seconds) - end + delegate :locale, to: :associated_user + + def encrypt(payload) + Webpush::Encryption.encrypt(payload, key_p256dh, key_auth) + end + + def audience + @audience ||= Addressable::URI.parse(endpoint).normalized_site + end + + def crypto_key_header + p256ecdsa = vapid_key.public_key_for_push_header + + "p256ecdsa=#{p256ecdsa}" + end + + def authorization_header + jwt = JWT.encode({ aud: audience, exp: 24.hours.from_now.to_i, sub: "mailto:#{contact_email}" }, vapid_key.curve, 'ES256', typ: 'JWT') + + "WebPush #{jwt}" end def pushable?(notification) - data&.key?('alerts') && ActiveModel::Type::Boolean.new.cast(data['alerts'][notification.type.to_s]) + ActiveModel::Type::Boolean.new.cast(data&.dig('alerts', notification.type.to_s)) end def associated_user return @associated_user if defined?(@associated_user) - @associated_user = if user_id.nil? - session_activation.user - else - user - end + @associated_user = begin + if user_id.nil? + session_activation.user + else + user + end + end end def associated_access_token return @associated_access_token if defined?(@associated_access_token) - @associated_access_token = if access_token_id.nil? - find_or_create_access_token.token - else - access_token.token - end + @associated_access_token = begin + if access_token_id.nil? + find_or_create_access_token.token + else + access_token.token + end + end end class << self def unsubscribe_for(application_id, resource_owner) - access_token_ids = Doorkeeper::AccessToken.where(application_id: application_id, resource_owner_id: resource_owner.id, revoked_at: nil) - .pluck(:id) - + access_token_ids = Doorkeeper::AccessToken.where(application_id: application_id, resource_owner_id: resource_owner.id, revoked_at: nil).pluck(:id) where(access_token_id: access_token_ids).delete_all end end private - def push_payload(message, ttl = 5.minutes.seconds) - Webpush.payload_send( - message: Oj.dump(message), - endpoint: endpoint, - p256dh: key_p256dh, - auth: key_auth, - ttl: ttl, - ssl_timeout: 10, - open_timeout: 10, - read_timeout: 10, - vapid: { - subject: "mailto:#{::Setting.site_contact_email}", - private_key: Rails.configuration.x.vapid_private_key, - public_key: Rails.configuration.x.vapid_public_key, - } - ) - end - - def payload_for_notification(notification) - ActiveModelSerializers::SerializableResource.new( - notification, - serializer: Web::NotificationSerializer, - scope: self, - scope_name: :current_push_subscription - ).as_json - end - def find_or_create_access_token Doorkeeper::AccessToken.find_or_create_for( application: Doorkeeper::Application.find_by(superapp: true), - resource_owner: session_activation.user_id, + resource_owner: user_id || session_activation.user_id, scopes: Doorkeeper::OAuth::Scopes.from_string('read write follow push'), expires_in: Doorkeeper.configuration.access_token_expires_in, use_refresh_token: Doorkeeper.configuration.refresh_token_enabled? ) end + + def vapid_key + @vapid_key ||= Webpush::VapidKey.from_keys(Rails.configuration.x.vapid_public_key, Rails.configuration.x.vapid_private_key) + end + + def contact_email + @contact_email ||= ::Setting.site_contact_email + end end diff --git a/app/workers/web/push_notification_worker.rb b/app/workers/web/push_notification_worker.rb index 46aeaa30b..57f5b5c22 100644 --- a/app/workers/web/push_notification_worker.rb +++ b/app/workers/web/push_notification_worker.rb @@ -3,22 +3,67 @@ class Web::PushNotificationWorker include Sidekiq::Worker - sidekiq_options backtrace: true, retry: 5 + sidekiq_options queue: 'push', retry: 5 + + TTL = 48.hours.to_s + URGENCY = 'normal' def perform(subscription_id, notification_id) - subscription = ::Web::PushSubscription.find(subscription_id) - notification = Notification.find(notification_id) + @subscription = Web::PushSubscription.find(subscription_id) + @notification = Notification.find(notification_id) + + # Polymorphically associated activity could have been deleted + # in the meantime, so we have to double-check before proceeding + return unless @notification.activity.present? && @subscription.pushable?(@notification) + + payload = @subscription.encrypt(push_notification_json) - subscription.push(notification) unless notification.activity.nil? - rescue Webpush::ResponseError => e - code = e.response.code.to_i + request_pool.with(@subscription.audience) do |http_client| + request = Request.new(:post, @subscription.endpoint, body: payload.fetch(:ciphertext), http_client: http_client) - if (400..499).cover?(code) && ![408, 429].include?(code) - subscription.destroy! - else - raise e + request.add_headers( + 'Content-Type' => 'application/octet-stream', + 'Ttl' => TTL, + 'Urgency' => URGENCY, + 'Content-Encoding' => 'aesgcm', + 'Encryption' => "salt=#{Webpush.encode64(payload.fetch(:salt)).delete('=')}", + 'Crypto-Key' => "dh=#{Webpush.encode64(payload.fetch(:server_public_key)).delete('=')};#{@subscription.crypto_key_header}", + 'Authorization' => @subscription.authorization_header + ) + + request.perform do |response| + # If the server responds with an error in the 4xx range + # that isn't about rate-limiting or timeouts, we can + # assume that the subscription is invalid or expired + # and must be removed + + if (400..499).cover?(response.code) && ![408, 429].include?(response.code) + @subscription.destroy! + elsif !(200...300).cover?(response.code) + raise Mastodon::UnexpectedResponseError, response + end + end end rescue ActiveRecord::RecordNotFound true end + + private + + def push_notification_json + json = I18n.with_locale(@subscription.locale || I18n.default_locale) do + ActiveModelSerializers::SerializableResource.new( + @notification, + serializer: Web::NotificationSerializer, + scope: @subscription, + scope_name: :current_push_subscription + ).as_json + end + + Oj.dump(json) + end + + def request_pool + RequestPool.current + end end diff --git a/spec/workers/web/push_notification_worker_spec.rb b/spec/workers/web/push_notification_worker_spec.rb new file mode 100644 index 000000000..5bc24f888 --- /dev/null +++ b/spec/workers/web/push_notification_worker_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Web::PushNotificationWorker do + subject { described_class.new } + + let(:p256dh) { 'BN4GvZtEZiZuqFxSKVZfSfluwKBD7UxHNBmWkfiZfCtgDE8Bwh-_MtLXbBxTBAWH9r7IPKL0lhdcaqtL1dfxU5E=' } + let(:auth) { 'Q2BoAjC09xH3ywDLNJr-dA==' } + let(:endpoint) { 'https://updates.push.services.mozilla.com/push/v1/subscription-id' } + let(:user) { Fabricate(:user) } + let(:notification) { Fabricate(:notification) } + let(:subscription) { Fabricate(:web_push_subscription, user_id: user.id, key_p256dh: p256dh, key_auth: auth, endpoint: endpoint, data: { alerts: { notification.type => true } }) } + let(:vapid_public_key) { 'BB37UCyc8LLX4PNQSe-04vSFvpUWGrENubUaslVFM_l5TxcGVMY0C3RXPeUJAQHKYlcOM2P4vTYmkoo0VZGZTM4=' } + let(:vapid_private_key) { 'OPrw1Sum3gRoL4-DXfSCC266r-qfFSRZrnj8MgIhRHg=' } + let(:vapid_key) { Webpush::VapidKey.from_keys(vapid_public_key, vapid_private_key) } + let(:contact_email) { 'sender@example.com' } + let(:ciphertext) { "+\xB8\xDBT}\x13\xB6\xDD.\xF9\xB0\xA7\xC8\xD2\x80\xFD\x99#\xF7\xAC\x83\xA4\xDB,\x1F\xB5\xB9w\x85>\xF7\xADr" } + let(:salt) { "X\x97\x953\xE4X\xF8_w\xE7T\x95\xC51q\xFE" } + let(:server_public_key) { "\x04\b-RK9w\xDD$\x16lFz\xF9=\xB4~\xC6\x12k\xF3\xF40t\xA9\xC1\fR\xC3\x81\x80\xAC\f\x7F\xE4\xCC\x8E\xC2\x88 n\x8BB\xF1\x9C\x14\a\xFA\x8D\xC9\x80\xA1\xDDyU\\&c\x01\x88#\x118Ua" } + let(:shared_secret) { "\t\xA7&\x85\t\xC5m\b\xA8\xA7\xF8B{1\xADk\xE1y'm\xEDE\xEC\xDD\xEDj\xB3$s\xA9\xDA\xF0" } + let(:payload) { { ciphertext: ciphertext, salt: salt, server_public_key: server_public_key, shared_secret: shared_secret } } + + describe 'perform' do + before do + allow_any_instance_of(subscription.class).to receive(:contact_email).and_return(contact_email) + allow_any_instance_of(subscription.class).to receive(:vapid_key).and_return(vapid_key) + allow(Webpush::Encryption).to receive(:encrypt).and_return(payload) + allow(JWT).to receive(:encode).and_return('jwt.encoded.payload') + + stub_request(:post, endpoint).to_return(status: 201, body: '') + + subject.perform(subscription.id, notification.id) + end + + it 'calls the relevant service with the correct headers' do + expect(a_request(:post, endpoint).with(headers: { + 'Content-Encoding' => 'aesgcm', + 'Content-Type' => 'application/octet-stream', + 'Crypto-Key' => 'dh=BAgtUks5d90kFmxGevk9tH7GEmvz9DB0qcEMUsOBgKwMf-TMjsKIIG6LQvGcFAf6jcmAod15VVwmYwGIIxE4VWE;p256ecdsa=' + vapid_public_key.delete('='), + 'Encryption' => 'salt=WJeVM-RY-F9351SVxTFx_g', + 'Ttl' => '172800', + 'Urgency' => 'normal', + 'Authorization' => 'WebPush jwt.encoded.payload', + }, body: "+\xB8\xDBT}\u0013\xB6\xDD.\xF9\xB0\xA7\xC8Ҁ\xFD\x99#\xF7\xAC\x83\xA4\xDB,\u001F\xB5\xB9w\x85>\xF7\xADr")).to have_been_made + end + end +end -- cgit From 04fe071279205fb0ebb29e694105c84da92596d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Apr 2021 14:37:29 +0200 Subject: Bump parallel_tests from 3.6.0 to 3.7.0 (#16024) Bumps [parallel_tests](https://github.com/grosser/parallel_tests) from 3.6.0 to 3.7.0. - [Release notes](https://github.com/grosser/parallel_tests/releases) - [Changelog](https://github.com/grosser/parallel_tests/blob/master/CHANGELOG.md) - [Commits](https://github.com/grosser/parallel_tests/compare/v3.6.0...v3.7.0) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'Gemfile') diff --git a/Gemfile b/Gemfile index cc77ee618..6ee98a09f 100644 --- a/Gemfile +++ b/Gemfile @@ -123,7 +123,7 @@ group :test do gem 'rspec-sidekiq', '~> 3.1' gem 'simplecov', '~> 0.21', require: false gem 'webmock', '~> 3.12' - gem 'parallel_tests', '~> 3.6' + gem 'parallel_tests', '~> 3.7' gem 'rspec_junit_formatter', '~> 0.4' end diff --git a/Gemfile.lock b/Gemfile.lock index 1d3c24595..fc4e645d8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -412,7 +412,7 @@ GEM av (~> 0.9.0) paperclip (>= 2.5.2) parallel (1.20.1) - parallel_tests (3.6.0) + parallel_tests (3.7.0) parallel parser (3.0.1.0) ast (~> 2.4.1) @@ -757,7 +757,7 @@ DEPENDENCIES paperclip (~> 6.0) paperclip-av-transcoder (~> 0.6) parallel (~> 1.20) - parallel_tests (~> 3.6) + parallel_tests (~> 3.7) parslet pg (~> 1.2) pghero (~> 2.8) -- cgit From bb68a9570efcf6ccc176e226a8b2637e1fc84ae7 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 13 Apr 2021 03:45:45 +0200 Subject: Bump nsa from git to 0.2.8 (#16033) --- Gemfile | 2 +- Gemfile.lock | 18 ++++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) (limited to 'Gemfile') diff --git a/Gemfile b/Gemfile index 6ee98a09f..38698a8e3 100644 --- a/Gemfile +++ b/Gemfile @@ -63,7 +63,7 @@ gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' gem 'mime-types', '~> 3.3.1', require: 'mime/types/columnar' gem 'nokogiri', '~> 1.11' -gem 'nsa', git: 'https://github.com/Gargron/nsa', ref: 'd1079e0cdafdfed7f9f35478d13b9bdaa65965c0' +gem 'nsa', '~> 0.2' gem 'oj', '~> 3.11' gem 'ox', '~> 2.14' gem 'parslet' diff --git a/Gemfile.lock b/Gemfile.lock index fc4e645d8..af83978ed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,14 +1,3 @@ -GIT - remote: https://github.com/Gargron/nsa - revision: d1079e0cdafdfed7f9f35478d13b9bdaa65965c0 - ref: d1079e0cdafdfed7f9f35478d13b9bdaa65965c0 - specs: - nsa (0.2.8) - activesupport (>= 4.2, < 7) - concurrent-ruby (~> 1.0, >= 1.0.2) - sidekiq (>= 3.5) - statsd-ruby (~> 1.4, >= 1.4.0) - GEM remote: https://rubygems.org/ specs: @@ -384,6 +373,11 @@ GEM racc (~> 1.4) nokogumbo (2.0.4) nokogiri (~> 1.8, >= 1.8.4) + nsa (0.2.8) + activesupport (>= 4.2, < 7) + concurrent-ruby (~> 1.0, >= 1.0.2) + sidekiq (>= 3.5) + statsd-ruby (~> 1.4, >= 1.4.0) oj (3.11.3) omniauth (1.9.1) hashie (>= 3.4.6) @@ -747,7 +741,7 @@ DEPENDENCIES mime-types (~> 3.3.1) net-ldap (~> 0.17) nokogiri (~> 1.11) - nsa! + nsa (~> 0.2) oj (~> 3.11) omniauth (~> 1.9) omniauth-cas (~> 2.0) -- cgit From 43f42310ae9defe433ecd03d0e43716b1d9d103a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Apr 2021 15:33:41 +0200 Subject: Bump cld3 from 3.4.1 to 3.4.2 (#16069) Bumps [cld3](https://github.com/akihikodaki/cld3-ruby) from 3.4.1 to 3.4.2. - [Release notes](https://github.com/akihikodaki/cld3-ruby/releases) - [Commits](https://github.com/akihikodaki/cld3-ruby/compare/v3.4.1...v3.4.2) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile | 2 +- Gemfile.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) (limited to 'Gemfile') diff --git a/Gemfile b/Gemfile index 38698a8e3..34e2ab926 100644 --- a/Gemfile +++ b/Gemfile @@ -32,7 +32,7 @@ gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'iso-639' gem 'chewy', '~> 5.2' -gem 'cld3', '~> 3.4.1' +gem 'cld3', '~> 3.4.2' gem 'devise', '~> 4.7' gem 'devise-two-factor', '~> 4.0' diff --git a/Gemfile.lock b/Gemfile.lock index 9239e2156..5e92a4670 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -153,8 +153,8 @@ GEM elasticsearch (>= 2.0.0) elasticsearch-dsl chunky_png (1.3.15) - cld3 (3.4.1) - ffi (>= 1.1.0, < 1.15.0) + cld3 (3.4.2) + ffi (>= 1.1.0, < 1.16.0) climate_control (0.2.0) cocaine (0.5.8) climate_control (>= 0.0.3, < 1.0) @@ -224,7 +224,7 @@ GEM faraday-net_http (1.0.1) fast_blank (1.0.0) fastimage (2.2.3) - ffi (1.14.2) + ffi (1.15.0) ffi-compiler (1.0.1) ffi (>= 1.0.0) rake @@ -699,7 +699,7 @@ DEPENDENCIES capybara (~> 3.35) charlock_holmes (~> 0.7.7) chewy (~> 5.2) - cld3 (~> 3.4.1) + cld3 (~> 3.4.2) climate_control (~> 0.2) color_diff (~> 0.1) concurrent-ruby -- cgit