diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a12cfe5baffb..0b6d52c376712e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. +## [4.3.3] - 2025-01-16 + +### Security + +- Fix insufficient validation of account URIs ([GHSA-5wxh-3p65-r4g6](https://github.com/mastodon/mastodon/security/advisories/GHSA-5wxh-3p65-r4g6)) +- Update dependencies + +### Fixed + +- Fix `libyaml` missing from `Dockerfile` build stage (#33591 by @vmstan) +- Fix incorrect notification settings migration for non-followers (#33348 by @ClearlyClaire) +- Fix down clause for notification policy v2 migrations (#33340 by @jesseplusplus) +- Fix error decrementing status count when `FeaturedTags#last_status_at` is `nil` (#33320 by @ClearlyClaire) +- Fix last paginated notification group only including data on a single notification (#33271 by @ClearlyClaire) +- Fix processing of mentions for post edits with an existing corresponding silent mention (#33227 by @ClearlyClaire) +- Fix deletion of unconfirmed users with Webauthn set (#33186 by @ClearlyClaire) +- Fix empty authors preview card serialization (#33151, #33466 by @mjankowski and @ClearlyClaire) + ## [4.3.2] - 2024-12-03 ### Added @@ -135,7 +153,7 @@ The following changelog entries focus on changes visible to users, administrator - **Add notifications of severed relationships** (#27511, #29665, #29668, #29670, #29700, #29714, #29712, and #29731 by @ClearlyClaire and @Gargron)\ Notify local users when they lose relationships as a result of a local moderator blocking a remote account or server, allowing the affected user to retrieve the list of broken relationships.\ Note that this does not notify remote users.\ - This adds the `severed_relationships` notification type to the REST API and streaming, with a new [`relationship_severance_event` attribute](https://docs.joinmastodon.org/entities/Notification/#relationship_severance_event). + This adds the `severed_relationships` notification type to the REST API and streaming, with a new [`event` attribute](https://docs.joinmastodon.org/entities/Notification/#relationship_severance_event). - **Add hover cards in web UI** (#30754, #30864, #30850, #30879, #30928, #30949, #30948, #30931, and #31300 by @ClearlyClaire, @Gargron, and @renchap)\ Hovering over an avatar or username will now display a hover card with the first two lines of the user's description and their first two profile fields.\ This can be disabled in the “Animations and accessibility” section of the preferences. diff --git a/Dockerfile b/Dockerfile index c7c02d9b467ef9..8d50458132d03b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -150,6 +150,7 @@ RUN \ libpq-dev \ libssl-dev \ libtool \ + libyaml-dev \ meson \ nasm \ pkg-config \ diff --git a/Gemfile.lock b/Gemfile.lock index 0236bf37703fbe..cd07706f937655 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,35 +10,35 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (7.1.4.1) - actionpack (= 7.1.4.1) - activesupport (= 7.1.4.1) + actioncable (7.1.5.1) + actionpack (= 7.1.5.1) + activesupport (= 7.1.5.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.4.1) - actionpack (= 7.1.4.1) - activejob (= 7.1.4.1) - activerecord (= 7.1.4.1) - activestorage (= 7.1.4.1) - activesupport (= 7.1.4.1) + actionmailbox (7.1.5.1) + actionpack (= 7.1.5.1) + activejob (= 7.1.5.1) + activerecord (= 7.1.5.1) + activestorage (= 7.1.5.1) + activesupport (= 7.1.5.1) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.1.4.1) - actionpack (= 7.1.4.1) - actionview (= 7.1.4.1) - activejob (= 7.1.4.1) - activesupport (= 7.1.4.1) + actionmailer (7.1.5.1) + actionpack (= 7.1.5.1) + actionview (= 7.1.5.1) + activejob (= 7.1.5.1) + activesupport (= 7.1.5.1) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.2) - actionpack (7.1.4.1) - actionview (= 7.1.4.1) - activesupport (= 7.1.4.1) + actionpack (7.1.5.1) + actionview (= 7.1.5.1) + activesupport (= 7.1.5.1) nokogiri (>= 1.8.5) racc rack (>= 2.2.4) @@ -46,15 +46,15 @@ GEM rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.4.1) - actionpack (= 7.1.4.1) - activerecord (= 7.1.4.1) - activestorage (= 7.1.4.1) - activesupport (= 7.1.4.1) + actiontext (7.1.5.1) + actionpack (= 7.1.5.1) + activerecord (= 7.1.5.1) + activestorage (= 7.1.5.1) + activesupport (= 7.1.5.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.4.1) - activesupport (= 7.1.4.1) + actionview (7.1.5.1) + activesupport (= 7.1.5.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) @@ -64,30 +64,33 @@ GEM activemodel (>= 4.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - activejob (7.1.4.1) - activesupport (= 7.1.4.1) + activejob (7.1.5.1) + activesupport (= 7.1.5.1) globalid (>= 0.3.6) - activemodel (7.1.4.1) - activesupport (= 7.1.4.1) - activerecord (7.1.4.1) - activemodel (= 7.1.4.1) - activesupport (= 7.1.4.1) + activemodel (7.1.5.1) + activesupport (= 7.1.5.1) + activerecord (7.1.5.1) + activemodel (= 7.1.5.1) + activesupport (= 7.1.5.1) timeout (>= 0.4.0) - activestorage (7.1.4.1) - actionpack (= 7.1.4.1) - activejob (= 7.1.4.1) - activerecord (= 7.1.4.1) - activesupport (= 7.1.4.1) + activestorage (7.1.5.1) + actionpack (= 7.1.5.1) + activejob (= 7.1.5.1) + activerecord (= 7.1.5.1) + activesupport (= 7.1.5.1) marcel (~> 1.0) - activesupport (7.1.4.1) + activesupport (7.1.5.1) base64 + benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) mutex_m + securerandom (>= 0.3) tzinfo (~> 2.0) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) @@ -126,6 +129,7 @@ GEM base64 (0.2.0) bcp47_spec (0.2.1) bcrypt (3.1.20) + benchmark (0.4.0) better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) @@ -454,7 +458,7 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.3) - nokogiri (1.16.7) + nokogiri (1.16.8) mini_portile2 (~> 2.8.2) racc (~> 1.4) oj (3.16.6) @@ -638,20 +642,20 @@ GEM rackup (1.0.0) rack (< 3) webrick - rails (7.1.4.1) - actioncable (= 7.1.4.1) - actionmailbox (= 7.1.4.1) - actionmailer (= 7.1.4.1) - actionpack (= 7.1.4.1) - actiontext (= 7.1.4.1) - actionview (= 7.1.4.1) - activejob (= 7.1.4.1) - activemodel (= 7.1.4.1) - activerecord (= 7.1.4.1) - activestorage (= 7.1.4.1) - activesupport (= 7.1.4.1) + rails (7.1.5.1) + actioncable (= 7.1.5.1) + actionmailbox (= 7.1.5.1) + actionmailer (= 7.1.5.1) + actionpack (= 7.1.5.1) + actiontext (= 7.1.5.1) + actionview (= 7.1.5.1) + activejob (= 7.1.5.1) + activemodel (= 7.1.5.1) + activerecord (= 7.1.5.1) + activestorage (= 7.1.5.1) + activesupport (= 7.1.5.1) bundler (>= 1.15.0) - railties (= 7.1.4.1) + railties (= 7.1.5.1) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -660,15 +664,15 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) - nokogiri (~> 1.14) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rails-i18n (7.0.9) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.1.4.1) - actionpack (= 7.1.4.1) - activesupport (= 7.1.4.1) + railties (7.1.5.1) + actionpack (= 7.1.5.1) + activesupport (= 7.1.5.1) irb rackup (>= 1.0.0) rake (>= 12.2) @@ -781,6 +785,7 @@ GEM scenic (1.8.0) activerecord (>= 4.0.0) railties (>= 4.0.0) + securerandom (0.4.1) selenium-webdriver (4.25.0) base64 (~> 0.2) logger (~> 1.4) diff --git a/app/controllers/api/v2/notifications_controller.rb b/app/controllers/api/v2/notifications_controller.rb index c070c0e5e71892..cc38b95114e86f 100644 --- a/app/controllers/api/v2/notifications_controller.rb +++ b/app/controllers/api/v2/notifications_controller.rb @@ -80,10 +80,31 @@ def load_grouped_notifications return [] if @notifications.empty? MastodonOTELTracer.in_span('Api::V2::NotificationsController#load_grouped_notifications') do - NotificationGroup.from_notifications(@notifications, pagination_range: (@notifications.last.id)..(@notifications.first.id), grouped_types: params[:grouped_types]) + pagination_range = (@notifications.last.id)..@notifications.first.id + + # If the page is incomplete, we know we are on the last page + if incomplete_page? + if paginating_up? + pagination_range = @notifications.last.id...(params[:max_id]&.to_i) + else + range_start = params[:since_id]&.to_i + range_start += 1 unless range_start.nil? + pagination_range = range_start..(@notifications.first.id) + end + end + + NotificationGroup.from_notifications(@notifications, pagination_range: pagination_range, grouped_types: params[:grouped_types]) end end + def incomplete_page? + @notifications.size < limit_param(DEFAULT_NOTIFICATIONS_LIMIT) + end + + def paginating_up? + params[:min_id].present? + end + def browserable_account_notifications current_account.notifications.without_suspended.browserable( types: Array(browserable_params[:types]), diff --git a/app/lib/delivery_failure_tracker.rb b/app/lib/delivery_failure_tracker.rb index e17b45d667a354..96292923f4289f 100644 --- a/app/lib/delivery_failure_tracker.rb +++ b/app/lib/delivery_failure_tracker.rb @@ -46,6 +46,8 @@ def without_unavailable(urls) urls.reject do |url| host = Addressable::URI.parse(url).normalized_host unavailable_domains_map[host] + rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError + true end end diff --git a/app/models/featured_tag.rb b/app/models/featured_tag.rb index 529056f9c60e2c..dfc700649c403b 100644 --- a/app/models/featured_tag.rb +++ b/app/models/featured_tag.rb @@ -47,7 +47,7 @@ def increment(timestamp) def decrement(deleted_status) if statuses_count <= 1 update(statuses_count: 0, last_status_at: nil) - elsif last_status_at > deleted_status.created_at + elsif last_status_at.present? && last_status_at > deleted_status.created_at update(statuses_count: statuses_count - 1) else # Fetching the latest status creation time can be expensive, so only perform it diff --git a/app/models/notification_group.rb b/app/models/notification_group.rb index 7ebc87ddbcdfb3..f0fa42da6b2c23 100644 --- a/app/models/notification_group.rb +++ b/app/models/notification_group.rb @@ -88,22 +88,32 @@ def load_groups_data(account_id, group_keys, pagination_range: nil) binds = [ account_id, SAMPLE_ACCOUNTS_SIZE, - pagination_range.begin, - pagination_range.end, ActiveRecord::Relation::QueryAttribute.new('group_keys', group_keys, ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array.new(ActiveModel::Type::String.new)), + pagination_range.begin || 0, ] + binds << pagination_range.end unless pagination_range.end.nil? + + upper_bound_cond = begin + if pagination_range.end.nil? + '' + elsif pagination_range.exclude_end? + 'AND id < $5' + else + 'AND id <= $5' + end + end ActiveRecord::Base.connection.select_all(<<~SQL.squish, 'grouped_notifications', binds).cast_values.to_h { |k, *values| [k, values] } SELECT groups.group_key, - (SELECT id FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND id <= $4 ORDER BY id DESC LIMIT 1), - array(SELECT from_account_id FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND id <= $4 ORDER BY id DESC LIMIT $2), - (SELECT count(*) FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND id <= $4) AS notifications_count, - array(SELECT activity_id FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND id <= $4 AND activity_type = 'EmojiReaction'), + (SELECT id FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND #{upper_bound_cond} ORDER BY id DESC LIMIT 1), + array(SELECT from_account_id FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND #{upper_bound_cond} ORDER BY id DESC LIMIT $2), + (SELECT count(*) FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND #{upper_bound_cond}) AS notifications_count, + array(SELECT activity_id FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND #{upper_bound_cond} AND activity_type = 'EmojiReaction'), (SELECT id FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND id >= $3 ORDER BY id ASC LIMIT 1) AS min_id, - (SELECT created_at FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND id <= $4 ORDER BY id DESC LIMIT 1) + (SELECT created_at FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key AND #{upper_bound_cond} ORDER BY id DESC LIMIT 1) FROM - unnest($5::text[]) AS groups(group_key); + unnest($3::text[]) AS groups(group_key); SQL else binds = [ diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb index 82b25d4649433f..bd248a7b7ad127 100644 --- a/app/models/preview_card.rb +++ b/app/models/preview_card.rb @@ -134,7 +134,7 @@ def history end def authors - @authors ||= [PreviewCard::Author.new(self)] + @authors ||= Array(serialized_authors) end class Author < ActiveModelSerializers::Model @@ -169,6 +169,13 @@ def image_styles(file) private + def serialized_authors + if author_name? || author_url? || author_account_id? + PreviewCard::Author + .new(self) + end + end + def extract_dimensions file = image.queued_for_write[:original] diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index a7825594bda067..c1d54264a6aee5 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -11,6 +11,8 @@ class ActivityPub::ProcessAccountService < BaseService SCAN_SEARCHABILITY_RE = /\[searchability:(public|followers|reactors|private)\]/ SCAN_SEARCHABILITY_FEDIBIRD_RE = /searchable_by_(all_users|followers_only|reacted_users_only|nobody)/ + VALID_URI_SCHEMES = %w(http https).freeze + # Should be called with confirmed valid JSON # and WebFinger-resolved username and domain def call(username, domain, json, options = {}) # rubocop:disable Metrics/PerceivedComplexity @@ -113,16 +115,28 @@ def update_account end def set_immediate_protocol_attributes! - @account.inbox_url = @json['inbox'] || '' - @account.outbox_url = @json['outbox'] || '' - @account.shared_inbox_url = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || '' - @account.followers_url = @json['followers'] || '' + @account.inbox_url = valid_collection_uri(@json['inbox']) + @account.outbox_url = valid_collection_uri(@json['outbox']) + @account.shared_inbox_url = valid_collection_uri(@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) + @account.followers_url = valid_collection_uri(@json['followers']) @account.url = url || @uri @account.uri = @uri @account.actor_type = actor_type @account.created_at = @json['published'] if @json['published'].present? end + def valid_collection_uri(uri) + uri = uri.first if uri.is_a?(Array) + uri = uri['id'] if uri.is_a?(Hash) + return '' unless uri.is_a?(String) + + parsed_uri = Addressable::URI.parse(uri) + + VALID_URI_SCHEMES.include?(parsed_uri.scheme) && parsed_uri.host.present? ? parsed_uri : '' + rescue Addressable::URI::InvalidURIError + '' + end + def set_immediate_attributes! @account.featured_collection_url = @json['featured'] || '' @account.display_name = @json['name'] || '' @@ -398,10 +412,11 @@ def followers_private? end def collection_info(type) - return [nil, nil] if @json[type].blank? + collection_uri = valid_collection_uri(@json[type]) + return [nil, nil] if collection_uri.blank? return @collections[type] if @collections.key?(type) - collection = fetch_resource_without_id_validation(@json[type]) + collection = fetch_resource_without_id_validation(collection_uri) total_items = collection.is_a?(Hash) && collection['totalItems'].present? && collection['totalItems'].is_a?(Numeric) ? collection['totalItems'] : nil has_first_page = collection.is_a?(Hash) && collection['first'].present? diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 2cd95351030a10..20f697e21f2acf 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -257,40 +257,30 @@ def update_tags! end def update_mentions! - previous_mentions = @status.active_mentions.includes(:account).to_a - current_mentions = [] unresolved_mentions = [] - @raw_mentions.each do |href| + currently_mentioned_account_ids = @raw_mentions.filter_map do |href| next if href.blank? account = ActivityPub::TagManager.instance.uri_to_resource(href, Account) account ||= ActivityPub::FetchRemoteAccountService.new.call(href, request_id: @request_id) - next if account.nil? - - mention = previous_mentions.find { |x| x.account_id == account.id } - mention ||= account.mentions.new(status: @status) - - current_mentions << mention - rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS + account&.id + rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError # Since previous mentions are about already-known accounts, # they don't try to resolve again and won't fall into this case. # In other words, this failure case is only for new mentions and won't # affect `removed_mentions` so they can safely be retried asynchronously unresolved_mentions << href + nil end - current_mentions.each do |mention| - mention.save if mention.new_record? - end + @status.mentions.upsert_all(currently_mentioned_account_ids.map { |id| { account_id: id, silent: false } }, unique_by: %w(status_id account_id)) # If previous mentions are no longer contained in the text, convert them # to silent mentions, since withdrawing access from someone who already # received a notification might be more confusing - removed_mentions = previous_mentions - current_mentions - - Mention.where(id: removed_mentions.map(&:id)).update_all(silent: true) unless removed_mentions.empty? + @status.mentions.where.not(account_id: currently_mentioned_account_ids).update_all(silent: true) # Queue unresolved mentions for later unresolved_mentions.uniq.each do |uri| diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index ebd5d260f06146..2332d1d8a2d1c4 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -15,7 +15,7 @@ def call(status, limited_type: '', circle: nil, save_records: true) return unless @status.local? - @previous_mentions = @status.active_mentions.includes(:account).to_a + @previous_mentions = @status.mentions.includes(:account).to_a @current_mentions = [] Status.transaction do @@ -63,6 +63,8 @@ def scan_text! mention ||= @current_mentions.find { |x| x.account_id == mentioned_account.id } mention ||= @status.mentions.new(account: mentioned_account) + mention.silent = false + @current_mentions << mention "@#{mentioned_account.acct}" @@ -87,7 +89,7 @@ def assign_mentions! end @current_mentions.each do |mention| - mention.save if mention.new_record? && @save_records + mention.save if (mention.new_record? || mention.silent_changed?) && @save_records end # If previous mentions are no longer contained in the text, convert them @@ -95,7 +97,7 @@ def assign_mentions! # received a notification might be more confusing removed_mentions = @previous_mentions - @current_mentions - Mention.where(id: removed_mentions.map(&:id)).update_all(silent: true) unless removed_mentions.empty? + Mention.where(id: removed_mentions.map(&:id), silent: false).update_all(silent: true) unless removed_mentions.empty? end def mention_undeliverable?(mentioned_account) diff --git a/app/workers/mention_resolve_worker.rb b/app/workers/mention_resolve_worker.rb index 72dcd9633f32fe..8c5938aeaf1cb5 100644 --- a/app/workers/mention_resolve_worker.rb +++ b/app/workers/mention_resolve_worker.rb @@ -16,7 +16,7 @@ def perform(status_id, uri, options = {}) return if account.nil? - status.mentions.create!(account: account, silent: false) + status.mentions.upsert({ account_id: account.id, silent: false }, unique_by: %w(status_id account_id)) rescue ActiveRecord::RecordNotFound # Do nothing rescue Mastodon::UnexpectedResponseError => e diff --git a/app/workers/scheduler/user_cleanup_scheduler.rb b/app/workers/scheduler/user_cleanup_scheduler.rb index f7551283320dce..03544e2e98e53f 100644 --- a/app/workers/scheduler/user_cleanup_scheduler.rb +++ b/app/workers/scheduler/user_cleanup_scheduler.rb @@ -19,6 +19,7 @@ def clean_unconfirmed_accounts! User.unconfirmed.where(confirmation_sent_at: ..UNCONFIRMED_ACCOUNTS_MAX_AGE_DAYS.days.ago).find_in_batches do |batch| # We have to do it separately because of missing database constraints AccountModerationNote.where(target_account_id: batch.map(&:account_id)).delete_all + WebauthnCredential.where(user_id: batch.map(&:id)).delete_all Account.where(id: batch.map(&:account_id)).delete_all User.where(id: batch.map(&:id)).delete_all end diff --git a/db/migrate/20240808124338_migrate_notifications_policy_v2.rb b/db/migrate/20240808124338_migrate_notifications_policy_v2.rb index 2e0684826a1d54..ed1642d6b825ab 100644 --- a/db/migrate/20240808124338_migrate_notifications_policy_v2.rb +++ b/db/migrate/20240808124338_migrate_notifications_policy_v2.rb @@ -9,7 +9,7 @@ class NotificationPolicy < ApplicationRecord; end def up NotificationPolicy.in_batches.update_all(<<~SQL.squish) for_not_following = CASE filter_not_following WHEN true THEN 1 ELSE 0 END, - for_not_followers = CASE filter_not_following WHEN true THEN 1 ELSE 0 END, + for_not_followers = CASE filter_not_followers WHEN true THEN 1 ELSE 0 END, for_new_accounts = CASE filter_new_accounts WHEN true THEN 1 ELSE 0 END, for_private_mentions = CASE filter_private_mentions WHEN true THEN 1 ELSE 0 END SQL @@ -18,7 +18,7 @@ def up def down NotificationPolicy.in_batches.update_all(<<~SQL.squish) filter_not_following = CASE for_not_following WHEN 0 THEN false ELSE true END, - filter_not_following = CASE for_not_followers WHEN 0 THEN false ELSE true END, + filter_not_followers = CASE for_not_followers WHEN 0 THEN false ELSE true END, filter_new_accounts = CASE for_new_accounts WHEN 0 THEN false ELSE true END, filter_private_mentions = CASE for_private_mentions WHEN 0 THEN false ELSE true END SQL diff --git a/db/post_migrate/20240808124339_post_deployment_migrate_notifications_policy_v2.rb b/db/post_migrate/20240808124339_post_deployment_migrate_notifications_policy_v2.rb index eb0c9097293d0c..5daf6466430b07 100644 --- a/db/post_migrate/20240808124339_post_deployment_migrate_notifications_policy_v2.rb +++ b/db/post_migrate/20240808124339_post_deployment_migrate_notifications_policy_v2.rb @@ -9,7 +9,7 @@ class NotificationPolicy < ApplicationRecord; end def up NotificationPolicy.in_batches.update_all(<<~SQL.squish) for_not_following = CASE filter_not_following WHEN true THEN 1 ELSE 0 END, - for_not_followers = CASE filter_not_following WHEN true THEN 1 ELSE 0 END, + for_not_followers = CASE filter_not_followers WHEN true THEN 1 ELSE 0 END, for_new_accounts = CASE filter_new_accounts WHEN true THEN 1 ELSE 0 END, for_private_mentions = CASE filter_private_mentions WHEN true THEN 1 ELSE 0 END SQL @@ -18,7 +18,7 @@ def up def down NotificationPolicy.in_batches.update_all(<<~SQL.squish) filter_not_following = CASE for_not_following WHEN 0 THEN false ELSE true END, - filter_not_following = CASE for_not_followers WHEN 0 THEN false ELSE true END, + filter_not_followers = CASE for_not_followers WHEN 0 THEN false ELSE true END, filter_new_accounts = CASE for_new_accounts WHEN 0 THEN false ELSE true END, filter_private_mentions = CASE for_private_mentions WHEN 0 THEN false ELSE true END SQL diff --git a/docker-compose.yml b/docker-compose.yml index 5f2343cfbc02f1..a2f71097911910 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,7 @@ services: web: # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes build: . - image: kmyblue:15.7-lts + image: kmyblue:15.8-lts restart: always env_file: .env.production command: bundle exec puma -C config/puma.rb @@ -83,7 +83,7 @@ services: build: dockerfile: ./streaming/Dockerfile context: . - image: kmyblue-streaming:15.7-lts + image: kmyblue-streaming:15.8-lts restart: always env_file: .env.production command: node ./streaming/index.js @@ -101,7 +101,7 @@ services: sidekiq: build: . - image: kmyblue:15.7-lts + image: kmyblue:15.8-lts restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index dedfcbb3573195..a03412a31b7d3c 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -31,7 +31,7 @@ def minor end def patch - 2 + 3 end def default_prerelease diff --git a/spec/lib/delivery_failure_tracker_spec.rb b/spec/lib/delivery_failure_tracker_spec.rb index 40c8adc4c80620..34912c8133e65c 100644 --- a/spec/lib/delivery_failure_tracker_spec.rb +++ b/spec/lib/delivery_failure_tracker_spec.rb @@ -42,8 +42,8 @@ Fabricate(:unavailable_domain, domain: 'foo.bar') end - it 'removes URLs that are unavailable' do - results = described_class.without_unavailable(['http://example.com/good/inbox', 'http://foo.bar/unavailable/inbox']) + it 'removes URLs that are bogus or unavailable' do + results = described_class.without_unavailable(['http://example.com/good/inbox', 'http://foo.bar/unavailable/inbox', '{foo:']) expect(results).to include('http://example.com/good/inbox') expect(results).to_not include('http://foo.bar/unavailable/inbox') diff --git a/spec/requests/api/v2/notifications_spec.rb b/spec/requests/api/v2/notifications_spec.rb index ffa0a71c779e1b..aa4a8615576dc1 100644 --- a/spec/requests/api/v2/notifications_spec.rb +++ b/spec/requests/api/v2/notifications_spec.rb @@ -143,6 +143,55 @@ end end + context 'when there are numerous notifications for the same final group' do + before do + user.account.notifications.destroy_all + 5.times.each { FavouriteService.new.call(Fabricate(:account), user.account.statuses.first) } + end + + context 'with no options' do + it 'returns a notification group covering all notifications' do + subject + + notification_ids = user.account.notifications.reload.pluck(:id) + + expect(response).to have_http_status(200) + expect(response.content_type) + .to start_with('application/json') + expect(response.parsed_body[:notification_groups]).to contain_exactly( + a_hash_including( + type: 'favourite', + sample_account_ids: have_attributes(size: 5), + page_min_id: notification_ids.first.to_s, + page_max_id: notification_ids.last.to_s + ) + ) + end + end + + context 'with min_id param' do + let(:params) { { min_id: user.account.notifications.reload.first.id - 1 } } + + it 'returns a notification group covering all notifications' do + subject + + notification_ids = user.account.notifications.reload.pluck(:id) + + expect(response).to have_http_status(200) + expect(response.content_type) + .to start_with('application/json') + expect(response.parsed_body[:notification_groups]).to contain_exactly( + a_hash_including( + type: 'favourite', + sample_account_ids: have_attributes(size: 5), + page_min_id: notification_ids.first.to_s, + page_max_id: notification_ids.last.to_s + ) + ) + end + end + end + context 'with no options' do it 'returns expected notification types', :aggregate_failures do subject diff --git a/spec/serializers/rest/preview_card_serializer_spec.rb b/spec/serializers/rest/preview_card_serializer_spec.rb new file mode 100644 index 00000000000000..41ba305b7ce285 --- /dev/null +++ b/spec/serializers/rest/preview_card_serializer_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe REST::PreviewCardSerializer do + subject do + serialized_record_json( + preview_card, + described_class + ) + end + + context 'when preview card does not have author data' do + let(:preview_card) { Fabricate.build :preview_card } + + it 'includes empty authors array' do + expect(subject.deep_symbolize_keys) + .to include( + authors: be_an(Array).and(be_empty) + ) + end + end + + context 'when preview card has fediverse author data' do + let(:preview_card) { Fabricate.build :preview_card, author_account: Fabricate(:account) } + + it 'includes populated authors array' do + expect(subject.deep_symbolize_keys) + .to include( + authors: be_an(Array).and( + contain_exactly( + include( + account: be_present + ) + ) + ) + ) + end + end + + context 'when preview card has non-fediverse author data' do + let(:preview_card) { Fabricate.build :preview_card, author_name: 'Name', author_url: 'https://host.example/123' } + + it 'includes populated authors array' do + expect(subject.deep_symbolize_keys) + .to include( + authors: be_an(Array).and( + contain_exactly( + include( + name: 'Name', + url: 'https://host.example/123' + ) + ) + ) + ) + end + end +end diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index 62dc556b8c3b3b..3ddf166befea05 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -11,9 +11,11 @@ [ { type: 'Hashtag', name: 'hoge' }, { type: 'Mention', href: ActivityPub::TagManager.instance.uri_for(alice) }, + { type: 'Mention', href: bogus_mention }, ] end let(:content) { 'Hello universe' } + let(:bogus_mention) { 'https://example.com/users/erroringuser' } let(:payload) do { '@context': 'https://www.w3.org/ns/activitystreams', @@ -36,19 +38,21 @@ let(:media_attachments) { [] } before do - mentions.each { |a| Fabricate(:mention, status: status, account: a) } + mentions.each { |(account, silent)| Fabricate(:mention, status: status, account: account, silent: silent) } tags.each { |t| status.tags << t } media_attachments.each { |m| status.media_attachments << m } + stub_request(:get, bogus_mention).to_raise(HTTP::ConnectionError) end describe '#call' do - it 'updates text and content warning' do + it 'updates text and content warning, and schedules re-fetching broken mention' do subject.call(status, json, json) expect(status.reload) .to have_attributes( text: eq('Hello universe'), spoiler_text: eq('Show more') ) + expect(MentionResolveWorker).to have_enqueued_sidekiq_job(status.id, bogus_mention, anything) end context 'when the changes are only in sanitized-out HTML' do @@ -320,7 +324,19 @@ end context 'when originally with mentions' do - let(:mentions) { [alice, bob] } + let(:mentions) { [[alice, false], [bob, false]] } + + before do + subject.call(status, json, json) + end + + it 'updates mentions' do + expect(status.active_mentions.reload.map(&:account_id)).to eq [alice.id] + end + end + + context 'when originally with silent mentions' do + let(:mentions) { [[alice, true], [bob, true]] } before do subject.call(status, json, json) diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb index 99eddf2f88bbf3..2a2bbd68b65e5a 100644 --- a/spec/services/update_status_service_spec.rb +++ b/spec/services/update_status_service_spec.rb @@ -199,6 +199,14 @@ def match_update_request(req, type) .to eq [bob.id] expect(status.mentions.pluck(:account_id)) .to contain_exactly(alice.id, bob.id) + + # Going back when a mention was switched to silence should still be possible + subject.call(status, status.account_id, text: 'Hello @alice') + + expect(status.active_mentions.pluck(:account_id)) + .to eq [alice.id] + expect(status.mentions.pluck(:account_id)) + .to contain_exactly(alice.id, bob.id) end end diff --git a/spec/workers/scheduler/user_cleanup_scheduler_spec.rb b/spec/workers/scheduler/user_cleanup_scheduler_spec.rb index b1be7c46117203..604f528586afb2 100644 --- a/spec/workers/scheduler/user_cleanup_scheduler_spec.rb +++ b/spec/workers/scheduler/user_cleanup_scheduler_spec.rb @@ -9,6 +9,7 @@ let!(:old_unconfirmed_user) { Fabricate(:user) } let!(:confirmed_user) { Fabricate(:user) } let!(:moderation_note) { Fabricate(:account_moderation_note, account: Fabricate(:account), target_account: old_unconfirmed_user.account) } + let!(:webauthn_credential) { Fabricate(:webauthn_credential, user_id: old_unconfirmed_user.id) } describe '#perform' do before do @@ -26,6 +27,8 @@ .from(true).to(false) expect { moderation_note.reload } .to raise_error(ActiveRecord::RecordNotFound) + expect { webauthn_credential.reload } + .to raise_error(ActiveRecord::RecordNotFound) expect_preservation_of(new_unconfirmed_user) expect_preservation_of(confirmed_user) end