diff --git a/CHANGELOG.md b/CHANGELOG.md index 0696f0b31c..0b6d52c376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,66 @@ 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 + +- Add `tootctl feeds vacuum` (#33065 by @ClearlyClaire) +- Add error message when user tries to follow their own account (#31910 by @lenikadali) +- Add client_secret_expires_at to OAuth Applications (#30317 by @ThisIsMissEm) + +### Changed + +- Change design of Content Warnings and filters (#32543 by @ClearlyClaire) + +### Fixed + +- Fix processing incoming post edits with mentions to unresolvable accounts (#33129 by @ClearlyClaire) +- Fix error when including multiple instances of `embed.js` (#33107 by @YKWeyer) +- Fix inactive users' timelines being backfilled on follow and unsuspend (#33094 by @ClearlyClaire) +- Fix direct inbox delivery pushing posts into inactive followers' timelines (#33067 by @ClearlyClaire) +- Fix `TagFollow` records not being correctly handled in account operations (#33063 by @ClearlyClaire) +- Fix pushing hashtag-followed posts to feeds of inactive users (#33018 by @Gargron) +- Fix duplicate notifications in notification groups when using slow mode (#33014 by @ClearlyClaire) +- Fix posts made in the future being allowed to trend (#32996 by @ClearlyClaire) +- Fix uploading higher-than-wide GIF profile picture with libvips enabled (#32911 by @ClearlyClaire) +- Fix domain attribution field having autocorrect and autocapitalize enabled (#32903 by @ClearlyClaire) +- Fix titles being escaped twice (#32889 by @ClearlyClaire) +- Fix list creation limit check (#32869 by @ClearlyClaire) +- Fix error in `tootctl email_domain_blocks` when supplying `--with-dns-records` (#32863 by @mjankowski) +- Fix `min_id` and `max_id` causing error in search API (#32857 by @Gargron) +- Fix inefficiencies when processing removal of posts that use featured tags (#32787 by @ClearlyClaire) +- Fix alt-text pop-in not using the translated description (#32766 by @ClearlyClaire) +- Fix preview cards with long titles erroneously causing layout changes (#32678 by @ClearlyClaire) +- Fix embed modal layout on mobile (#32641 by @DismalShadowX) +- Fix and improve batch attachment deletion handling when using OpenStack Swift (#32637 by @hugogameiro) +- Fix blocks not being applied on link timeline (#32625 by @tribela) +- Fix follow counters being incorrectly changed (#32622 by @oneiros) +- Fix 'unknown' media attachment type rendering (#32613 and #32713 by @ThisIsMissEm and @renatolond) +- Fix tl language native name (#32606 by @seav) + +### Security + +- Update dependencies + ## [4.3.1] - 2024-10-21 ### Added @@ -93,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 4d6287912e..df6563a9a6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -153,6 +153,7 @@ RUN \ libpq-dev \ libssl-dev \ libtool \ + libyaml-dev \ meson \ nasm \ pkg-config \ diff --git a/app/controllers/api/v2/notifications_controller.rb b/app/controllers/api/v2/notifications_controller.rb index c070c0e5e7..cc38b95114 100644 --- a/app/controllers/api/v2/notifications_controller.rb +++ b/app/controllers/api/v2/notifications_controller.rb @@ -80,10 +80,31 @@ class Api::V2::NotificationsController < Api::BaseController 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/javascript/mastodon/actions/bookmark_categories.js b/app/javascript/mastodon/actions/bookmark_categories.js index eab632ab7a..313d5de8f2 100644 --- a/app/javascript/mastodon/actions/bookmark_categories.js +++ b/app/javascript/mastodon/actions/bookmark_categories.js @@ -129,9 +129,9 @@ export const fetchBookmarkCategoryStatusesFail = (id, error) => ({ export function expandBookmarkCategoryStatuses(bookmarkCategoryId) { return (dispatch, getState) => { - const url = getState().getIn(['bookmark_categories', bookmarkCategoryId, 'next'], null); + const url = getState().getIn(['status_lists', 'bookmark_category_statuses', bookmarkCategoryId, 'next'], null); - if (url === null || getState().getIn(['bookmark_categories', bookmarkCategoryId, 'isLoading'])) { + if (url === null || getState().getIn(['status_lists', 'bookmark_category_statuses', bookmarkCategoryId, 'isLoading'])) { return; } diff --git a/app/javascript/mastodon/actions/circles.js b/app/javascript/mastodon/actions/circles.js index c9cbb2cd13..221c0c683a 100644 --- a/app/javascript/mastodon/actions/circles.js +++ b/app/javascript/mastodon/actions/circles.js @@ -152,9 +152,9 @@ export function fetchCircleStatusesFail(id, error) { export function expandCircleStatuses(circleId) { return (dispatch, getState) => { - const url = getState().getIn(['circles', circleId, 'next'], null); + const url = getState().getIn(['status_lists', 'circle_statuses', circleId, 'next'], null); - if (url === null || getState().getIn(['circles', circleId, 'isLoading'])) { + if (url === null || getState().getIn(['status_lists', 'circle_statuses', circleId, 'isLoading'])) { return; } diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb index 75c88d964e..256d72acb9 100644 --- a/app/lib/activitypub/activity/follow.rb +++ b/app/lib/activitypub/activity/follow.rb @@ -60,19 +60,13 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity already_accepted = friend.accepted? friend.update!(passive_state: :pending, active_state: :idle, passive_follow_activity_id: @json['id']) else - @friend = FriendDomain.new(domain: @account.domain, passive_state: :pending, passive_follow_activity_id: @json['id']) - @friend.inbox_url = @json['inboxUrl'].presence || @friend.default_inbox_url - @friend.save! + @friend = FriendDomain.create!(domain: @account.domain, passive_state: :pending, passive_follow_activity_id: @json['id'], inbox_url: @account.preferred_inbox_url) end - if already_accepted || Setting.unlocked_friend - friend.accept! + friend.accept! if already_accepted || Setting.unlocked_friend - # Notify for admin even if unlocked - notify_staff_about_pending_friend_server! unless already_accepted - else - notify_staff_about_pending_friend_server! - end + # Notify for admin + notify_staff_about_pending_friend_server! unless already_accepted end def friend diff --git a/app/lib/delivery_failure_tracker.rb b/app/lib/delivery_failure_tracker.rb index e17b45d667..96292923f4 100644 --- a/app/lib/delivery_failure_tracker.rb +++ b/app/lib/delivery_failure_tracker.rb @@ -46,6 +46,8 @@ class DeliveryFailureTracker 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/lib/feed_manager.rb b/app/lib/feed_manager.rb index 279b31e98e..10598395ed 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -125,6 +125,8 @@ class FeedManager # @param [Account] into_account # @return [void] def merge_into_home(from_account, into_account) + return unless into_account.user&.signed_in_recently? + timeline_key = key(:home, into_account.id) aggregate = into_account.user&.aggregates_reblogs? query = from_account.statuses.list_eligible_visibility.includes(:preloadable_poll, :media_attachments, reblog: :account).limit(FeedManager::MAX_ITEMS / 4) @@ -151,6 +153,8 @@ class FeedManager # @param [List] list # @return [void] def merge_into_list(from_account, list) + return unless list.account.user&.signed_in_recently? + timeline_key = key(:list, list.id) aggregate = list.account.user&.aggregates_reblogs? query = from_account.statuses.list_eligible_visibility.includes(:preloadable_poll, :media_attachments, reblog: :account).limit(FeedManager::MAX_ITEMS / 4) diff --git a/app/models/featured_tag.rb b/app/models/featured_tag.rb index 529056f9c6..dfc700649c 100644 --- a/app/models/featured_tag.rb +++ b/app/models/featured_tag.rb @@ -47,7 +47,7 @@ class FeaturedTag < ApplicationRecord 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/friend_domain.rb b/app/models/friend_domain.rb index 88ebd9338b..2ca501e21d 100644 --- a/app/models/friend_domain.rb +++ b/app/models/friend_domain.rb @@ -116,6 +116,7 @@ class FriendDomain < ApplicationRecord object: ActivityPub::TagManager::COLLECTIONS[:public], # Cannot use inbox_url method because this model also has inbox_url column + # This is deprecated property. Newer version's kmyblue will ignore it. inboxUrl: "https://#{Rails.configuration.x.web_domain}/inbox", } end diff --git a/app/models/instance_info.rb b/app/models/instance_info.rb index 32b1969f94..3729a5755d 100644 --- a/app/models/instance_info.rb +++ b/app/models/instance_info.rb @@ -25,11 +25,13 @@ class InstanceInfo < ApplicationRecord firefish hollo iceshrimp + Iceshrimp.NET meisskey misskey pleroma sharkey tanukey + yojo-art ).freeze QUOTE_AVAILABLE_SOFTWARES = EMOJI_REACTION_AVAILABLE_SOFTWARES + %w(bridgy-fed).freeze diff --git a/app/models/notification_group.rb b/app/models/notification_group.rb index 7d17774f20..2bfe0894f3 100644 --- a/app/models/notification_group.rb +++ b/app/models/notification_group.rb @@ -89,22 +89,32 @@ class NotificationGroup < ActiveModelSerializers::Model 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 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 id FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key #{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 #{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 #{upper_bound_cond}) AS notifications_count, + array(SELECT activity_id FROM notifications WHERE notifications.account_id = $1 AND notifications.group_key = groups.group_key #{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 >= $4 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 #{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 82b25d4649..bd248a7b7a 100644 --- a/app/models/preview_card.rb +++ b/app/models/preview_card.rb @@ -134,7 +134,7 @@ class PreviewCard < ApplicationRecord end def authors - @authors ||= [PreviewCard::Author.new(self)] + @authors ||= Array(serialized_authors) end class Author < ActiveModelSerializers::Model @@ -169,6 +169,13 @@ class PreviewCard < ApplicationRecord 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 b6941725d1..5c55defbda 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 @@ class ActivityPub::ProcessAccountService < BaseService 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 @@ class ActivityPub::ProcessAccountService < BaseService 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 5a8fa2c53f..7b6649d1ec 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -259,33 +259,35 @@ class ActivityPub::ProcessStatusUpdateService < BaseService 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 + account&.id + rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS + # 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 + @status.mentions.where.not(account_id: currently_mentioned_account_ids).update_all(silent: true) - Mention.where(id: removed_mentions.map(&:id)).update_all(silent: true) unless removed_mentions.empty? + # Queue unresolved mentions for later + unresolved_mentions.uniq.each do |uri| + MentionResolveWorker.perform_in(rand(30...600).seconds, @status.id, uri, { 'request_id' => @request_id }) + end end def update_emojis! diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 31b0e56022..a8d95a53d7 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -15,7 +15,7 @@ class ProcessMentionsService < BaseService 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 @@ class ProcessMentionsService < BaseService 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 @@ class ProcessMentionsService < BaseService 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 @@ class ProcessMentionsService < BaseService # 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 72dcd9633f..8c5938aeaf 100644 --- a/app/workers/mention_resolve_worker.rb +++ b/app/workers/mention_resolve_worker.rb @@ -16,7 +16,7 @@ class MentionResolveWorker 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 f755128332..03544e2e98 100644 --- a/app/workers/scheduler/user_cleanup_scheduler.rb +++ b/app/workers/scheduler/user_cleanup_scheduler.rb @@ -19,6 +19,7 @@ class Scheduler::UserCleanupScheduler 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 2e0684826a..ed1642d6b8 100644 --- a/db/migrate/20240808124338_migrate_notifications_policy_v2.rb +++ b/db/migrate/20240808124338_migrate_notifications_policy_v2.rb @@ -9,7 +9,7 @@ class MigrateNotificationsPolicyV2 < ActiveRecord::Migration[7.1] 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 @@ class MigrateNotificationsPolicyV2 < ActiveRecord::Migration[7.1] 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 eb0c909729..5daf646643 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 PostDeploymentMigrateNotificationsPolicyV2 < ActiveRecord::Migration[7.1] 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 @@ class PostDeploymentMigrateNotificationsPolicyV2 < ActiveRecord::Migration[7.1] 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 d7958cedfd..c88e186c2a 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:16.0-dev + image: kmyblue:16.2 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:16.0-dev + image: kmyblue-streaming:16.2 restart: always env_file: .env.production command: node ./streaming/index.js @@ -101,7 +101,7 @@ services: sidekiq: build: . - image: kmyblue:16.0-dev + image: kmyblue:16.2 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/cli/feeds.rb b/lib/mastodon/cli/feeds.rb index 9d25cd7859..c0635bbcde 100644 --- a/lib/mastodon/cli/feeds.rb +++ b/lib/mastodon/cli/feeds.rb @@ -5,6 +5,7 @@ require_relative 'base' module Mastodon::CLI class Feeds < Base include Redisable + include DatabaseHelper option :all, type: :boolean, default: false option :concurrency, type: :numeric, default: 5, aliases: [:c] @@ -59,6 +60,38 @@ module Mastodon::CLI say('OK', :green) end + desc 'vacuum', 'Remove home feeds of inactive users from Redis' + long_desc <<-LONG_DESC + Running this task should not be needed in most cases, as Mastodon will + automatically clean up feeds from inactive accounts every day. + + However, this task is more aggressive in order to clean up feeds that + may have been missed because of bugs or database mishaps. + LONG_DESC + def vacuum + with_read_replica do + say('Deleting orphaned home feeds…') + redis.scan_each(match: 'feed:home:*').each_slice(1000) do |keys| + ids = keys.map { |key| key.split(':')[2] }.compact_blank + + known_ids = User.confirmed.signed_in_recently.where(account_id: ids).pluck(:account_id) + + keys_to_delete = keys.filter { |key| known_ids.exclude?(key.split(':')[2]&.to_i) } + redis.del(keys_to_delete) + end + + say('Deleting orphaned list feeds…') + redis.scan_each(match: 'feed:list:*').each_slice(1000) do |keys| + ids = keys.map { |key| key.split(':')[2] }.compact_blank + + known_ids = List.where(account_id: User.confirmed.signed_in_recently.select(:account_id)).where(id: ids).pluck(:id) + + keys_to_delete = keys.filter { |key| known_ids.exclude?(key.split(':')[2]&.to_i) } + redis.del(keys_to_delete) + end + end + end + private def active_user_accounts diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index b57856e472..d90ef59b5b 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,13 +13,13 @@ module Mastodon end def kmyblue_minor - 0 + 2 end def kmyblue_flag # 'LTS' - 'dev' - # nil + # 'dev' + nil end def major diff --git a/public/embed.js b/public/embed.js index 53372a3890..bc1fac3864 100644 --- a/public/embed.js +++ b/public/embed.js @@ -1,8 +1,5 @@ // @ts-check - -const allowedPrefixes = (document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT' && document.currentScript.dataset.allowedPrefixes) ? document.currentScript.dataset.allowedPrefixes.split(' ') : []; - -(function () { +(function (allowedPrefixes) { 'use strict'; /** @@ -127,4 +124,4 @@ const allowedPrefixes = (document.currentScript && document.currentScript.tagNam container.appendChild(iframe); }); }); -})(); +})((document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT' && document.currentScript.dataset.allowedPrefixes) ? document.currentScript.dataset.allowedPrefixes.split(' ') : []); diff --git a/spec/lib/activitypub/activity/follow_spec.rb b/spec/lib/activitypub/activity/follow_spec.rb index ec0e5e4eef..12c30feb28 100644 --- a/spec/lib/activitypub/activity/follow_spec.rb +++ b/spec/lib/activitypub/activity/follow_spec.rb @@ -380,11 +380,10 @@ RSpec.describe ActivityPub::Activity::Follow do context 'when given a friend server' do subject { described_class.new(json, sender) } - let(:sender) { Fabricate(:account, domain: 'abc.com', url: 'https://abc.com/#actor') } + let(:sender) { Fabricate(:account, domain: 'abc.com', url: 'https://abc.com/#actor', shared_inbox_url: 'https://abc.com/shared_inbox') } let!(:friend) { Fabricate(:friend_domain, domain: 'abc.com', inbox_url: 'https://example.com/inbox', passive_state: :idle) } let!(:owner_user) { Fabricate(:user, role: UserRole.find_by(name: 'Owner')) } let!(:patch_user) { Fabricate(:user, role: Fabricate(:user_role, name: 'OhagiOps', permissions: UserRole::FLAGS[:manage_federation])) } - let(:inbox_url) { nil } let(:json) do { @@ -393,7 +392,6 @@ RSpec.describe ActivityPub::Activity::Follow do type: 'Follow', actor: ActivityPub::TagManager.instance.uri_for(sender), object: 'https://www.w3.org/ns/activitystreams#Public', - inboxUrl: inbox_url, }.with_indifferent_access end @@ -415,25 +413,34 @@ RSpec.describe ActivityPub::Activity::Follow do expect(friend).to_not be_nil expect(friend.they_are_pending?).to be true expect(friend.passive_follow_activity_id).to eq 'foo' - expect(friend.inbox_url).to eq 'https://abc.com/inbox' + expect(friend.inbox_url).to eq 'https://abc.com/shared_inbox' end end - context 'when no record and inbox_url is specified' do - let(:inbox_url) { 'https://ohagi.com/inbox' } + context 'when old spec which no record and inbox_url is specified' do + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: 'https://www.w3.org/ns/activitystreams#Public', + inboxUrl: 'https://evil.org/bad_inbox', + }.with_indifferent_access + end before do friend.destroy! end - it 'marks the friend as pending' do + it 'marks the friend as pending but inboxUrl is not working' do subject.perform friend = FriendDomain.find_by(domain: 'abc.com') expect(friend).to_not be_nil expect(friend.they_are_pending?).to be true expect(friend.passive_follow_activity_id).to eq 'foo' - expect(friend.inbox_url).to eq 'https://ohagi.com/inbox' + expect(friend.inbox_url).to eq 'https://abc.com/shared_inbox' end end diff --git a/spec/lib/delivery_failure_tracker_spec.rb b/spec/lib/delivery_failure_tracker_spec.rb index 40c8adc4c8..34912c8133 100644 --- a/spec/lib/delivery_failure_tracker_spec.rb +++ b/spec/lib/delivery_failure_tracker_spec.rb @@ -42,8 +42,8 @@ RSpec.describe DeliveryFailureTracker do 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/models/friend_domain_spec.rb b/spec/models/friend_domain_spec.rb index 336f921ebd..d3992ed149 100644 --- a/spec/models/friend_domain_spec.rb +++ b/spec/models/friend_domain_spec.rb @@ -21,7 +21,6 @@ RSpec.describe FriendDomain do type: 'Follow', actor: 'https://cb6e6126.ngrok.io/actor', object: 'https://www.w3.org/ns/activitystreams#Public', - inboxUrl: 'https://cb6e6126.ngrok.io/inbox', }))).to have_been_made.once end end diff --git a/spec/requests/api/v2/notifications_spec.rb b/spec/requests/api/v2/notifications_spec.rb index ffa0a71c77..aa4a861557 100644 --- a/spec/requests/api/v2/notifications_spec.rb +++ b/spec/requests/api/v2/notifications_spec.rb @@ -143,6 +143,55 @@ RSpec.describe 'Notifications' do 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 0000000000..41ba305b7c --- /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 62dc556b8c..a2c5d9c8da 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -36,7 +36,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do 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 } end @@ -320,7 +320,19 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do 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 99eddf2f88..2a2bbd68b6 100644 --- a/spec/services/update_status_service_spec.rb +++ b/spec/services/update_status_service_spec.rb @@ -199,6 +199,14 @@ RSpec.describe UpdateStatusService do .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 b1be7c4611..604f528586 100644 --- a/spec/workers/scheduler/user_cleanup_scheduler_spec.rb +++ b/spec/workers/scheduler/user_cleanup_scheduler_spec.rb @@ -9,6 +9,7 @@ RSpec.describe Scheduler::UserCleanupScheduler do 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 @@ RSpec.describe Scheduler::UserCleanupScheduler do .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