diff --git a/CHANGELOG.md b/CHANGELOG.md index ef6a87ebb9..4dd4783597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,88 @@ All notable changes to this project will be documented in this file. +## [4.3.7] - 2025-04-02 + +### Add + +- Add delay to profile updates to debounce them (#34137 by @ClearlyClaire) +- Add support for paginating partial collections in `SynchronizeFollowersService` (#34272 and #34277 by @ClearlyClaire) + +### Changed + +- Change account suspensions to be federated to recently-followed accounts as well (#34294 by @ClearlyClaire) +- Change `AccountReachFinder` to consider statuses based on suspension date (#32805 and #34291 by @ClearlyClaire and @mjankowski) +- Change user archive signed URL TTL from 10 seconds to 1 hour (#34254 by @ClearlyClaire) + +### Fixed + +- Fix static version of animated PNG emojis not being properly extracted (#34337 by @ClearlyClaire) +- Fix filters not applying in detailed view, favourites and bookmarks (#34259 and #34260 by @ClearlyClaire) +- Fix handling of malformed/unusual HTML (#34201 by @ClearlyClaire) +- Fix `CacheBuster` being queued for missing media attachments (#34253 by @ClearlyClaire) +- Fix incorrect URL being used when cache busting (#34189 by @ClearlyClaire) +- Fix streaming server refusing unix socket path in `DATABASE_URL` (#34091 by @ClearlyClaire) +- Fix β€œx” hotkey not working on boosted filtered posts (#33758 by @ClearlyClaire) + +## [4.3.6] - 2025-03-13 + +### Security + +- Update dependency `omniauth-saml` +- Update dependency `rack` + +### Fixed + +- Fix Stoplight errors when using `REDIS_NAMESPACE` (#34126 by @ClearlyClaire) + +## [4.3.5] - 2025-03-10 + +### Changed + +- Change hashtag suggestion to prefer personal history capitalization (#34070 by @ClearlyClaire) + +### Fixed + +- Fix processing errors for some HEIF images from iOS 18 (#34086 by @renchap) +- Fix streaming server not filtering unknown-language posts from public timelines (#33774 by @ClearlyClaire) +- Fix preview cards under Content Warnings not being shown in detailed statuses (#34068 by @ClearlyClaire) +- Fix username and display name being hidden on narrow screens in moderation interface (#33064 by @ClearlyClaire) + +## [4.3.4] - 2025-02-27 + +### Security + +- Update dependencies +- Change HTML sanitization to remove unusable and unused `embed` tag (#34021 by @ClearlyClaire, [GHSA-mq2m-hr29-8gqf](https://github.com/mastodon/mastodon/security/advisories/GHSA-mq2m-hr29-8gqf)) +- Fix rate-limit on sign-up email verification ([GHSA-v39f-c9jj-8w7h](https://github.com/mastodon/mastodon/security/advisories/GHSA-v39f-c9jj-8w7h)) +- Fix improper disclosure of domain blocks to unverified users ([GHSA-94h4-fj37-c825](https://github.com/mastodon/mastodon/security/advisories/GHSA-94h4-fj37-c825)) + +### Changed + +- Change preview cards to be shown when Content Warnings are expanded (#33827 by @ClearlyClaire) +- Change warnings against changing encryption secrets to be even more noticeable (#33631 by @ClearlyClaire) +- Change `mastodon:setup` to prevent overwriting already-configured servers (#33603, #33616, and #33684 by @ClearlyClaire and @mjankowski) +- Change notifications from moderators to not be filtered (#32974 and #33654 by @ClearlyClaire and @mjankowski) + +### Fixed + +- Fix `GET /api/v2/notifications/:id` and `POST /api/v2/notifications/:id/dismiss` for ungrouped notifications (#33990 by @ClearlyClaire) +- Fix issue with some versions of libvips on some systems (#33853 by @kleisauke) +- Fix handling of duplicate mentions in incoming status `Update` (#33911 by @ClearlyClaire) +- Fix inefficiencies in timeline generation (#33839 and #33842 by @ClearlyClaire) +- Fix emoji rewrite adding unnecessary curft to the DOM for most emoji (#33818 by @ClearlyClaire) +- Fix `tootctl feeds build` not building list timelines (#33783 by @ClearlyClaire) +- Fix flaky test in `/api/v2/notifications` tests (#33773 by @ClearlyClaire) +- Fix incorrect signature after HTTP redirect (#33757 and #33769 by @ClearlyClaire) +- Fix polls not being validated on edition (#33755 by @ClearlyClaire) +- Fix media preview height in compose form when 3 or more images are attached (#33571 by @ClearlyClaire) +- Fix preview card sizing in β€œAuthor attribution” in profile settings (#33482 by @ClearlyClaire) +- Fix processing of incoming notifications for unfilterable types (#33429 by @ClearlyClaire) +- Fix featured tags for remote accounts not being kept up to date (#33372, #33406, and #33425 by @ClearlyClaire and @mjankowski) +- Fix notification polling showing a loading bar in web UI (#32960 by @Gargron) +- Fix accounts table long display name (#29316 by @WebCoder49) +- Fix exclusive lists interfering with notifications (#28162 by @ShadowJonathan) + ## [4.3.3] - 2025-01-16 ### Security diff --git a/Dockerfile b/Dockerfile index 4e1bb24ff8..588b100107 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,6 +96,9 @@ RUN \ # Set /opt/mastodon as working directory WORKDIR /opt/mastodon +# Add backport repository for some specific packages where we need the latest version +RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list + # hadolint ignore=DL3008,DL3005 RUN \ # Mount Apt cache and lib directories from Docker buildx caches @@ -165,7 +168,7 @@ RUN \ libexif-dev \ libexpat1-dev \ libgirepository1.0-dev \ - libheif-dev \ + libheif-dev/bookworm-backports \ libimagequant-dev \ libjpeg62-turbo-dev \ liblcms2-dev \ @@ -348,7 +351,7 @@ RUN \ # libvips components libcgif0 \ libexif12 \ - libheif1 \ + libheif1/bookworm-backports \ libimagequant0 \ libjpeg62-turbo \ liblcms2-2 \ diff --git a/Gemfile.lock b/Gemfile.lock index 8547e4fba1..c11a970bc2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -416,7 +416,7 @@ GEM mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.5.5) + net-imap (0.5.6) date net-protocol net-ldap (0.19.0) @@ -424,10 +424,10 @@ GEM net-protocol net-protocol (0.2.2) timeout - net-smtp (0.5.0) + net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.2) + nokogiri (1.18.3) mini_portile2 (~> 2.8.2) racc (~> 1.4) oj (3.16.9) @@ -444,9 +444,9 @@ GEM omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) - omniauth-saml (2.2.1) + omniauth-saml (2.2.3) omniauth (~> 2.1) - ruby-saml (~> 1.17) + ruby-saml (~> 1.18) omniauth_openid_connect (0.6.1) omniauth (>= 1.9, < 3) openid_connect (~> 1.1) @@ -597,7 +597,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.10) + rack (2.2.13) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (2.0.2) @@ -745,10 +745,10 @@ GEM rubocop-rspec (~> 3, >= 3.0.1) ruby-prof (1.7.1) ruby-progressbar (1.13.0) - ruby-saml (1.17.0) + ruby-saml (1.18.0) nokogiri (>= 1.13.10) rexml - ruby-vips (2.2.2) + ruby-vips (2.2.3) ffi (~> 1.12) logger rubyzip (2.4.1) @@ -845,7 +845,7 @@ GEM unf_ext unf_ext (0.0.9.1) unicode-display_width (2.6.0) - uri (1.0.2) + uri (1.0.3) useragent (0.16.11) validate_email (0.1.6) activemodel (>= 3.0) diff --git a/app/controllers/admin/ng_words/keywords_controller.rb b/app/controllers/admin/ng_words/keywords_controller.rb index 9af38fab7b..10969204e8 100644 --- a/app/controllers/admin/ng_words/keywords_controller.rb +++ b/app/controllers/admin/ng_words/keywords_controller.rb @@ -21,6 +21,10 @@ module Admin false end + def avoid_save? + true + end + private def after_update_redirect_path diff --git a/app/controllers/admin/ng_words_controller.rb b/app/controllers/admin/ng_words_controller.rb index f052843475..9e437f8c8b 100644 --- a/app/controllers/admin/ng_words_controller.rb +++ b/app/controllers/admin/ng_words_controller.rb @@ -13,6 +13,12 @@ module Admin return unless validate + if avoid_save? + flash[:notice] = I18n.t('generic.changes_saved_msg') + redirect_to after_update_redirect_path + return + end + @admin_settings = Form::AdminSettings.new(settings_params) if @admin_settings.save @@ -33,6 +39,10 @@ module Admin admin_ng_words_path end + def avoid_save? + false + end + private def settings_params @@ -40,7 +50,7 @@ module Admin end def settings_params_test - params.require(:form_admin_settings)[:ng_words_test] + params.expect(form_admin_settings: [ng_words_test: [keywords: [], regexps: [], strangers: [], temporary_ids: []]])['ng_words_test'] end end end diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index 032e42e9d2..bdd7732b87 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -14,7 +14,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController @account = current_account UpdateAccountService.new.call(@account, account_params, raise_error: true) current_user.update(user_params) if user_params - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) render json: @account, serializer: REST::CredentialAccountSerializer rescue ActiveRecord::RecordInvalid => e render json: ValidationErrorFormatter.new(e).as_json, status: 422 diff --git a/app/controllers/api/v1/instances/domain_blocks_controller.rb b/app/controllers/api/v1/instances/domain_blocks_controller.rb index 7ec94312f4..bf96fbaaa8 100644 --- a/app/controllers/api/v1/instances/domain_blocks_controller.rb +++ b/app/controllers/api/v1/instances/domain_blocks_controller.rb @@ -31,7 +31,7 @@ class Api::V1::Instances::DomainBlocksController < Api::V1::Instances::BaseContr end def show_domain_blocks_to_user? - Setting.show_domain_blocks == 'users' && user_signed_in? + Setting.show_domain_blocks == 'users' && user_signed_in? && current_user.functional_or_moved? end def set_domain_blocks @@ -47,6 +47,6 @@ class Api::V1::Instances::DomainBlocksController < Api::V1::Instances::BaseContr end def show_rationale_for_user? - Setting.show_domain_blocks_rationale == 'users' && user_signed_in? + Setting.show_domain_blocks_rationale == 'users' && user_signed_in? && current_user.functional_or_moved? end end diff --git a/app/controllers/api/v1/profile/avatars_controller.rb b/app/controllers/api/v1/profile/avatars_controller.rb index bc4d01a597..e6c954ed63 100644 --- a/app/controllers/api/v1/profile/avatars_controller.rb +++ b/app/controllers/api/v1/profile/avatars_controller.rb @@ -7,7 +7,7 @@ class Api::V1::Profile::AvatarsController < Api::BaseController def destroy @account = current_account UpdateAccountService.new.call(@account, { avatar: nil }, raise_error: true) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) render json: @account, serializer: REST::CredentialAccountSerializer end end diff --git a/app/controllers/api/v1/profile/headers_controller.rb b/app/controllers/api/v1/profile/headers_controller.rb index 9f4daa2f77..4472a01b05 100644 --- a/app/controllers/api/v1/profile/headers_controller.rb +++ b/app/controllers/api/v1/profile/headers_controller.rb @@ -7,7 +7,7 @@ class Api::V1::Profile::HeadersController < Api::BaseController def destroy @account = current_account UpdateAccountService.new.call(@account, { header: nil }, raise_error: true) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) render json: @account, serializer: REST::CredentialAccountSerializer end end diff --git a/app/controllers/api/v2/notifications_controller.rb b/app/controllers/api/v2/notifications_controller.rb index cc38b95114..848c361cfc 100644 --- a/app/controllers/api/v2/notifications_controller.rb +++ b/app/controllers/api/v2/notifications_controller.rb @@ -46,7 +46,7 @@ class Api::V2::NotificationsController < Api::BaseController end def show - @notification = current_account.notifications.without_suspended.find_by!(group_key: params[:group_key]) + @notification = current_account.notifications.without_suspended.by_group_key(params[:group_key]).take! presenter = GroupedNotificationsPresenter.new(NotificationGroup.from_notifications([@notification])) render json: presenter, serializer: REST::DedupNotificationGroupSerializer end @@ -57,7 +57,7 @@ class Api::V2::NotificationsController < Api::BaseController end def dismiss - current_account.notifications.where(group_key: params[:group_key]).destroy_all + current_account.notifications.by_group_key(params[:group_key]).destroy_all render_empty end diff --git a/app/controllers/backups_controller.rb b/app/controllers/backups_controller.rb index 5df1af5f2f..076d19874b 100644 --- a/app/controllers/backups_controller.rb +++ b/app/controllers/backups_controller.rb @@ -9,13 +9,15 @@ class BackupsController < ApplicationController before_action :authenticate_user! before_action :set_backup + BACKUP_LINK_TIMEOUT = 1.hour.freeze + def download case Paperclip::Attachment.default_options[:storage] when :s3, :azure - redirect_to @backup.dump.expiring_url(10), allow_other_host: true + redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.to_i), allow_other_host: true when :fog if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present? - redirect_to @backup.dump.expiring_url(Time.now.utc + 10), allow_other_host: true + redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.from_now), allow_other_host: true else redirect_to full_asset_url(@backup.dump.url), allow_other_host: true end diff --git a/app/controllers/settings/pictures_controller.rb b/app/controllers/settings/pictures_controller.rb index 58a4325307..7e61e6d580 100644 --- a/app/controllers/settings/pictures_controller.rb +++ b/app/controllers/settings/pictures_controller.rb @@ -8,7 +8,7 @@ module Settings def destroy if valid_picture? if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' }) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg'), status: 303 else redirect_to settings_profile_path diff --git a/app/controllers/settings/privacy_controller.rb b/app/controllers/settings/privacy_controller.rb index a5bb3b884f..96efa03ccf 100644 --- a/app/controllers/settings/privacy_controller.rb +++ b/app/controllers/settings/privacy_controller.rb @@ -8,7 +8,7 @@ class Settings::PrivacyController < Settings::BaseController def update if UpdateAccountService.new.call(@account, account_params.except(:settings)) current_user.update!(settings_attributes: account_params[:settings]) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) redirect_to settings_privacy_path, notice: I18n.t('generic.changes_saved_msg') else render :show diff --git a/app/controllers/settings/privacy_extra_controller.rb b/app/controllers/settings/privacy_extra_controller.rb index f1292e644c..cb99c390dd 100644 --- a/app/controllers/settings/privacy_extra_controller.rb +++ b/app/controllers/settings/privacy_extra_controller.rb @@ -8,7 +8,7 @@ class Settings::PrivacyExtraController < Settings::BaseController def update if UpdateAccountService.new.call(@account, account_params.except(:settings)) current_user.update!(settings_attributes: account_params[:settings]) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) redirect_to settings_privacy_extra_path, notice: I18n.t('generic.changes_saved_msg') else render :show diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 99a647336a..04a10fbfb9 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -9,7 +9,7 @@ class Settings::ProfilesController < Settings::BaseController def update if UpdateAccountService.new.call(@account, account_params) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg') else @account.build_fields diff --git a/app/controllers/settings/verifications_controller.rb b/app/controllers/settings/verifications_controller.rb index bed29dbeec..4b949ca72d 100644 --- a/app/controllers/settings/verifications_controller.rb +++ b/app/controllers/settings/verifications_controller.rb @@ -8,7 +8,7 @@ class Settings::VerificationsController < Settings::BaseController def update if UpdateAccountService.new.call(@account, account_params) - ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id) redirect_to settings_verification_path, notice: I18n.t('generic.changes_saved_msg') else render :show diff --git a/app/controllers/system_css_controller.rb b/app/controllers/system_css_controller.rb index a19728bbfd..dd90491894 100644 --- a/app/controllers/system_css_controller.rb +++ b/app/controllers/system_css_controller.rb @@ -1,16 +1,8 @@ # frozen_string_literal: true class SystemCssController < ActionController::Base # rubocop:disable Rails/ApplicationController - before_action :set_user_roles - def show expires_in 3.minutes, public: true render content_type: 'text/css' end - - private - - def set_user_roles - @user_roles = UserRole.providing_styles - end end diff --git a/app/helpers/admin/trends/statuses_helper.rb b/app/helpers/admin/trends/statuses_helper.rb index c7a59660cf..33da1f7216 100644 --- a/app/helpers/admin/trends/statuses_helper.rb +++ b/app/helpers/admin/trends/statuses_helper.rb @@ -2,11 +2,18 @@ module Admin::Trends::StatusesHelper def one_line_preview(status) - text = if status.local? - status.text.split("\n").first - else - Nokogiri::HTML5(status.text).css('html > body > *').first&.text - end + text = begin + if status.local? + status.text.split("\n").first + else + Nokogiri::HTML5(status.text).css('html > body > *').first&.text + end + rescue ArgumentError + # This can happen if one of the Nokogumbo limits is encountered + # Unfortunately, it does not use a more precise error class + # nor allows more graceful handling + '' + end return '' if text.blank? diff --git a/app/javascript/mastodon/features/bookmarked_statuses/index.jsx b/app/javascript/mastodon/features/bookmarked_statuses/index.jsx index 93be1c6b2e..284342d4ee 100644 --- a/app/javascript/mastodon/features/bookmarked_statuses/index.jsx +++ b/app/javascript/mastodon/features/bookmarked_statuses/index.jsx @@ -101,6 +101,7 @@ class Bookmarks extends ImmutablePureComponent { onLoadMore={this.handleLoadMore} emptyMessage={emptyMessage} bindToDocument={!multiColumn} + timelineId='bookmarks' /> diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js index 022c9baaf7..35804de82a 100644 --- a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js +++ b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js @@ -22,23 +22,23 @@ describe('emoji', () => { it('does unicode', () => { expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual( - 'πŸ‘©β€πŸ‘©β€πŸ‘¦β€πŸ‘¦'); + 'πŸ‘©β€πŸ‘©β€πŸ‘¦β€πŸ‘¦'); expect(emojify('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘§')).toEqual( - 'πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘§'); - expect(emojify('πŸ‘©β€πŸ‘©β€πŸ‘¦')).toEqual('πŸ‘©β€πŸ‘©β€πŸ‘¦'); + 'πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘§'); + expect(emojify('πŸ‘©β€πŸ‘©β€πŸ‘¦')).toEqual('πŸ‘©β€πŸ‘©β€πŸ‘¦'); expect(emojify('\u2757')).toEqual( - '❗'); + '❗'); }); it('does multiple unicode', () => { expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual( - '❗ #️⃣'); + '❗ #️⃣'); expect(emojify('\u2757#\uFE0F\u20E3')).toEqual( - '❗#️⃣'); + '❗#️⃣'); expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual( - '❗ #️⃣ ❗'); + '❗ #️⃣ ❗'); expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual( - 'foo ❗ #️⃣ bar'); + 'foo ❗ #️⃣ bar'); }); it('ignores unicode inside of tags', () => { @@ -46,16 +46,16 @@ describe('emoji', () => { }); it('does multiple emoji properly (issue 5188)', () => { - expect(emojify('πŸ‘ŒπŸŒˆπŸ’•')).toEqual('πŸ‘ŒπŸŒˆπŸ’•'); - expect(emojify('πŸ‘Œ 🌈 πŸ’•')).toEqual('πŸ‘Œ 🌈 πŸ’•'); + expect(emojify('πŸ‘ŒπŸŒˆπŸ’•')).toEqual('πŸ‘ŒπŸŒˆπŸ’•'); + expect(emojify('πŸ‘Œ 🌈 πŸ’•')).toEqual('πŸ‘Œ 🌈 πŸ’•'); }); it('does an emoji that has no shortcode', () => { - expect(emojify('πŸ‘β€πŸ—¨')).toEqual('πŸ‘β€πŸ—¨'); + expect(emojify('πŸ‘β€πŸ—¨')).toEqual('πŸ‘β€πŸ—¨'); }); it('does an emoji whose filename is irregular', () => { - expect(emojify('↙️')).toEqual('↙️'); + expect(emojify('↙️')).toEqual('↙️'); }); it('avoid emojifying on invisible text', () => { @@ -67,11 +67,11 @@ describe('emoji', () => { it('avoid emojifying on invisible text with nested tags', () => { expect(emojify('πŸ˜‡')) - .toEqual('πŸ˜‡'); + .toEqual('πŸ˜‡'); expect(emojify('πŸ˜‡')) - .toEqual('πŸ˜‡'); + .toEqual('πŸ˜‡'); expect(emojify('πŸ˜‡')) - .toEqual('πŸ˜‡'); + .toEqual('πŸ˜‡'); }); it('does not emojify emojis with textual presentation VS15 character', () => { @@ -81,17 +81,17 @@ describe('emoji', () => { it('does a simple emoji properly', () => { expect(emojify('♀♂')) - .toEqual('♀♂'); + .toEqual('♀♂'); }); it('does an emoji containing ZWJ properly', () => { expect(emojify('πŸ’‚β€β™€οΈπŸ’‚β€β™‚οΈ')) - .toEqual('πŸ’‚\u200Dβ™€οΈπŸ’‚\u200D♂️'); + .toEqual('πŸ’‚\u200Dβ™€οΈπŸ’‚\u200D♂️'); }); it('keeps ordering as expected (issue fixed by PR 20677)', () => { expect(emojify('

πŸ’• #foo test: foo.

')) - .toEqual('

πŸ’• #foo test: foo.

'); + .toEqual('

πŸ’• #foo test: foo.

'); }); }); }); diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js index 1f469aced7..66dcd89488 100644 --- a/app/javascript/mastodon/features/emoji/emoji.js +++ b/app/javascript/mastodon/features/emoji/emoji.js @@ -97,30 +97,30 @@ const emojifyTextNode = (node, customEmojis) => { const { filename, shortCode } = unicodeMapping[unicode_emoji]; const title = shortCode ? `:${shortCode}:` : ''; - replacement = document.createElement('picture'); - const isSystemTheme = !!document.body?.classList.contains('theme-system'); - if(isSystemTheme) { - let source = document.createElement('source'); - source.setAttribute('media', '(prefers-color-scheme: dark)'); - source.setAttribute('srcset', `${assetHost}/emoji/${emojiFilename(filename, "dark")}.svg`); - replacement.appendChild(source); - } + const theme = (isSystemTheme || document.body?.classList.contains('theme-mastodon-light')) ? 'light' : 'dark'; - let img = document.createElement('img'); + const imageFilename = emojiFilename(filename, theme); + + const img = document.createElement('img'); img.setAttribute('draggable', 'false'); img.setAttribute('class', 'emojione'); img.setAttribute('alt', unicode_emoji); img.setAttribute('title', title); + img.setAttribute('src', `${assetHost}/emoji/${imageFilename}.svg`); - let theme = "light"; + if (isSystemTheme && imageFilename !== emojiFilename(filename, 'dark')) { + replacement = document.createElement('picture'); - if(!isSystemTheme && !document.body?.classList.contains('theme-mastodon-light')) - theme = "dark"; - - img.setAttribute('src', `${assetHost}/emoji/${emojiFilename(filename, theme)}.svg`); - replacement.appendChild(img); + const source = document.createElement('source'); + source.setAttribute('media', '(prefers-color-scheme: dark)'); + source.setAttribute('srcset', `${assetHost}/emoji/${emojiFilename(filename, 'dark')}.svg`); + replacement.appendChild(source); + replacement.appendChild(img); + } else { + replacement = img; + } } // Add the processed-up-to-now string and the emoji replacement @@ -135,7 +135,7 @@ const emojifyTextNode = (node, customEmojis) => { }; const emojifyNode = (node, customEmojis) => { - for (const child of node.childNodes) { + for (const child of Array.from(node.childNodes)) { switch(child.nodeType) { case Node.TEXT_NODE: emojifyTextNode(child, customEmojis); diff --git a/app/javascript/mastodon/features/favourited_statuses/index.jsx b/app/javascript/mastodon/features/favourited_statuses/index.jsx index f7d6d14178..9049a20f05 100644 --- a/app/javascript/mastodon/features/favourited_statuses/index.jsx +++ b/app/javascript/mastodon/features/favourited_statuses/index.jsx @@ -101,6 +101,7 @@ class Favourites extends ImmutablePureComponent { onLoadMore={this.handleLoadMore} emptyMessage={emptyMessage} bindToDocument={!multiColumn} + timelineId='favourites' /> diff --git a/app/javascript/mastodon/features/status/components/detailed_status.tsx b/app/javascript/mastodon/features/status/components/detailed_status.tsx index 1540f50065..47d1692804 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.tsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.tsx @@ -15,6 +15,7 @@ import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?re import { AnimatedNumber } from 'mastodon/components/animated_number'; import { ContentWarning } from 'mastodon/components/content_warning'; import EditedTimestamp from 'mastodon/components/edited_timestamp'; +import { FilterWarning } from 'mastodon/components/filter_warning'; import type { StatusLike } from 'mastodon/components/hashtag_bar'; import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar'; import { Icon } from 'mastodon/components/icon'; @@ -80,6 +81,7 @@ export const DetailedStatus: React.FC<{ }) => { const properStatus = status?.get('reblog') ?? status; const [height, setHeight] = useState(0); + const [showDespiteFilter, setShowDespiteFilter] = useState(false); const nodeRef = useRef(); const handleOpenVideo = useCallback( @@ -92,6 +94,10 @@ export const DetailedStatus: React.FC<{ [onOpenVideo, status], ); + const handleFilterToggle = useCallback(() => { + setShowDespiteFilter(!showDespiteFilter); + }, [showDespiteFilter, setShowDespiteFilter]); + const handleExpandedToggle = useCallback(() => { if (onToggleHidden) onToggleHidden(status); }, [onToggleHidden, status]); @@ -237,7 +243,7 @@ export const DetailedStatus: React.FC<{ ); } @@ -369,8 +375,12 @@ export const DetailedStatus: React.FC<{ const { statusContentProps, hashtagBar } = getHashtagBarForStatus( status as StatusLike, ); + + const matchedFilters = status.get('matched_filters'); + const expanded = - !status.get('hidden') || status.get('spoiler_text').length === 0; + (!matchedFilters || showDespiteFilter) && + (!status.get('hidden') || status.get('spoiler_text').length === 0); const quote = !muted && status.get('quote_id') && ( <> @@ -418,17 +428,26 @@ export const DetailedStatus: React.FC<{ )} - {status.get('spoiler_text').length > 0 && ( - )} + {status.get('spoiler_text').length > 0 && + (!matchedFilters || showDespiteFilter) && ( + + )} + {expanded && ( <> { }); const mapStateToProps = (state, props) => { - const status = getStatus(state, { id: props.params.statusId }); + const status = getStatus(state, { id: props.params.statusId, contextType: 'detailed' }); let ancestorsIds = ImmutableList(); let descendantsIds = ImmutableList(); diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 1f0011448f..9b69663c45 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -342,12 +342,26 @@ const expiresInFromExpiresAt = expires_at => { const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => { prefix = prefix.toLowerCase(); + if (suggestions.length < 4) { const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase())); - return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag }))); - } else { - return suggestions; + suggestions = suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag }))); } + + // Prefer capitalization from personal history, unless personal history is all lower-case + const fixSuggestionCapitalization = (suggestion) => { + if (suggestion.type !== 'hashtag') + return suggestion; + + const tagFromHistory = tagHistory.find((tag) => tag.localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) === 0); + + if (!tagFromHistory || tagFromHistory.toLowerCase() === tagFromHistory) + return suggestion; + + return { ...suggestion, name: tagFromHistory }; + }; + + return suggestions.map(fixSuggestionCapitalization); }; const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => { diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index 85773152f2..c96309bb6a 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -17,9 +17,10 @@ export const makeGetStatus = () => { (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), getFilters, + (_, { contextType }) => ['detailed', 'bookmarks', 'favourites'].includes(contextType), ], - (statusBase, statusReblog, statusQuote, statusReblogQuote, accountBase, accountReblog, filters) => { + (statusBase, statusReblog, statusQuote, statusReblogQuote, accountBase, accountReblog, filters, warnInsteadOfHide) => { if (!statusBase || statusBase.get('isLoading')) { return null; } @@ -46,7 +47,7 @@ export const makeGetStatus = () => { } } - if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) { + if (!warnInsteadOfHide && filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) { return null; } filterResults = filterResults.filter(result => filters.has(result.get('filter'))); diff --git a/app/javascript/mastodon/utils/filters.ts b/app/javascript/mastodon/utils/filters.ts index 5d334fe509..479e1f44ab 100644 --- a/app/javascript/mastodon/utils/filters.ts +++ b/app/javascript/mastodon/utils/filters.ts @@ -7,6 +7,11 @@ export const toServerSideType = (columnType: string) => { case 'account': case 'explore': return columnType; + case 'detailed': + return 'thread'; + case 'bookmarks': + case 'favourites': + return 'home'; default: if (columnType.includes('list:') || columnType.includes('antenna:')) { return 'home'; diff --git a/app/lib/account_reach_finder.rb b/app/lib/account_reach_finder.rb index 19464024a6..4bf5c229a5 100644 --- a/app/lib/account_reach_finder.rb +++ b/app/lib/account_reach_finder.rb @@ -10,7 +10,7 @@ class AccountReachFinder end def inboxes - (followers_inboxes + reporters_inboxes + recently_mentioned_inboxes + relay_inboxes).uniq + (followers_inboxes + reporters_inboxes + recently_mentioned_inboxes + recently_followed_inboxes + recently_requested_inboxes + relay_inboxes).uniq end private @@ -31,13 +31,32 @@ class AccountReachFinder .take(RECENT_LIMIT) end + def recently_followed_inboxes + @account + .following + .where(follows: { created_at: recent_date_cutoff... }) + .inboxes + .take(RECENT_LIMIT) + end + + def recently_requested_inboxes + Account + .where(id: @account.follow_requests.where({ created_at: recent_date_cutoff... }).select(:target_account_id)) + .inboxes + .take(RECENT_LIMIT) + end + def relay_inboxes Relay.enabled.pluck(:inbox_url) end def oldest_status_id Mastodon::Snowflake - .id_at(STATUS_SINCE.ago, with_random: false) + .id_at(recent_date_cutoff, with_random: false) + end + + def recent_date_cutoff + @account.suspended? && @account.suspension_origin_local? ? @account.suspended_at - STATUS_SINCE : STATUS_SINCE.ago end def recent_statuses diff --git a/app/lib/emoji_formatter.rb b/app/lib/emoji_formatter.rb index c0302767ef..1574d4588d 100644 --- a/app/lib/emoji_formatter.rb +++ b/app/lib/emoji_formatter.rb @@ -24,7 +24,15 @@ class EmojiFormatter def to_s return html if custom_emojis.empty? || html.blank? - tree = Nokogiri::HTML5.fragment(html) + begin + tree = Nokogiri::HTML5.fragment(html) + rescue ArgumentError + # This can happen if one of the Nokogumbo limits is encountered + # Unfortunately, it does not use a more precise error class + # nor allows more graceful handling + return '' + end + tree.xpath('./text()|.//text()[not(ancestor[@class="invisible"])]').to_a.each do |node| i = -1 inside_shortname = false diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index df4fc17908..1a558cacbe 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -42,7 +42,7 @@ class FeedManager when :home filter_from_home(status, receiver.id, build_crutches(receiver.id, [status]), :home) when :list - (filter_from_list?(status, receiver) ? :filter : nil) || filter_from_home(status, receiver.account_id, build_crutches(receiver.account_id, [status]), :list, stl_home: stl_home) + (filter_from_list?(status, receiver) ? :filter : nil) || filter_from_home(status, receiver.account_id, build_crutches(receiver.account_id, [status], list: receiver), :list, stl_home: stl_home) when :mentions filter_from_mentions?(status, receiver.id) ? :filter : nil when :tags @@ -136,7 +136,7 @@ class FeedManager 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) + query = from_account.statuses.list_eligible_visibility.includes(reblog: :account).limit(FeedManager::MAX_ITEMS / 4) if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4 oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true).first.last.to_i @@ -164,7 +164,7 @@ class FeedManager 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) + query = from_account.statuses.list_eligible_visibility.includes(reblog: :account).limit(FeedManager::MAX_ITEMS / 4) if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4 oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true).first.last.to_i @@ -172,10 +172,10 @@ class FeedManager end statuses = query.to_a - crutches = build_crutches(list.account_id, statuses) + crutches = build_crutches(list.account_id, statuses, list: list) statuses.each do |status| - next if filter_from_home(status, list.account_id, crutches) || filter_from_list?(status, list) + next if filter_from_home(status, list.account_id, crutches, :list) add_to_feed(:list, list.id, status, aggregate_reblogs: aggregate) end @@ -309,23 +309,32 @@ class FeedManager limit = FeedManager::MAX_ITEMS / 2 aggregate = account.user&.aggregates_reblogs? timeline_key = key(:home, account.id) + over_limit = false account.statuses.limit(limit).each do |status| add_to_feed(:home, account.id, status, aggregate_reblogs: aggregate) end account.following.includes(:account_stat).reorder(nil).find_each do |target_account| - if redis.zcard(timeline_key) >= limit + query = target_account.statuses.list_eligible_visibility.includes(reblog: :account).limit(limit) + + over_limit ||= redis.zcard(timeline_key) >= limit + if over_limit oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true).first.last.to_i - last_status_score = Mastodon::Snowflake.id_at(target_account.last_status_at) + last_status_score = Mastodon::Snowflake.id_at(target_account.last_status_at, with_random: false) # If the feed is full and this account has not posted more recently # than the last item on the feed, then we can skip the whole account # because none of its statuses would stay on the feed anyway next if last_status_score < oldest_home_score + + # No need to get older statuses + query = query.where(id: oldest_home_score...) end - statuses = target_account.statuses.list_eligible_visibility.includes(:preloadable_poll, :media_attachments, :account, reblog: :account).limit(limit) + statuses = query.to_a + next if statuses.empty? + crutches = build_crutches(account.id, statuses) statuses.each do |status| @@ -345,23 +354,32 @@ class FeedManager limit = FeedManager::MAX_ITEMS / 2 aggregate = list.account.user&.aggregates_reblogs? timeline_key = key(:list, list.id) + over_limit = false list.active_accounts.includes(:account_stat).reorder(nil).find_each do |target_account| - if redis.zcard(timeline_key) >= limit + query = target_account.statuses.list_eligible_visibility.includes(reblog: :account).limit(limit) + + over_limit ||= redis.zcard(timeline_key) >= limit + if over_limit oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true).first.last.to_i - last_status_score = Mastodon::Snowflake.id_at(target_account.last_status_at) + last_status_score = Mastodon::Snowflake.id_at(target_account.last_status_at, with_random: false) # If the feed is full and this account has not posted more recently # than the last item on the feed, then we can skip the whole account # because none of its statuses would stay on the feed anyway next if last_status_score < oldest_home_score + + # No need to get older statuses + query = query.where(id: oldest_home_score...) end - statuses = target_account.statuses.list_eligible_visibility.includes(:preloadable_poll, :media_attachments, :account, reblog: :account).limit(limit) - crutches = build_crutches(list.account_id, statuses) + statuses = query.to_a + next if statuses.empty? + + crutches = build_crutches(list.account_id, statuses, list: list) statuses.each do |status| - next if filter_from_home(status, list.account_id, crutches) || filter_from_list?(status, list) + next if filter_from_home(status, list.account_id, crutches, :list) add_to_feed(:list, list.id, status, aggregate_reblogs: aggregate) end @@ -632,8 +650,9 @@ class FeedManager # are going to be checked by the filtering methods # @param [Integer] receiver_id # @param [Array] statuses + # @param [List] list # @return [Hash] - def build_crutches(receiver_id, statuses) # rubocop:disable Metrics/AbcSize + def build_crutches(receiver_id, statuses, list: nil) crutches = {} crutches[:active_mentions] = crutches_active_mentions(statuses) @@ -650,25 +669,43 @@ class FeedManager arr end - lists = List.where(account_id: receiver_id, exclusive: true) - antennas = Antenna.where(list: lists, insert_feeds: true) - - replied_accounts = statuses.filter_map(&:in_reply_to_account_id) - replied_accounts += statuses.filter { |status| status.limited_visibility? && status.thread.present? }.map { |status| status.thread.account_id } - - crutches[:following] = Follow.where(account_id: receiver_id, target_account_id: replied_accounts).pluck(:target_account_id).index_with(true) + crutches[:following] = crutches_following(receiver_id, statuses, list) crutches[:languages] = Follow.where(account_id: receiver_id, target_account_id: statuses.map(&:account_id)).pluck(:target_account_id, :languages).to_h crutches[:hiding_reblogs] = Follow.where(account_id: receiver_id, target_account_id: statuses.filter_map { |s| s.account_id if s.reblog? }, show_reblogs: false).pluck(:target_account_id).index_with(true) crutches[:blocking] = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).index_with(true) crutches[:muting] = Mute.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).index_with(true) crutches[:domain_blocking] = AccountDomainBlock.where(account_id: receiver_id, domain: statuses.flat_map { |s| [s.account.domain, s.reblog&.account&.domain] }.compact).pluck(:domain).index_with(true) crutches[:blocked_by] = Block.where(target_account_id: receiver_id, account_id: statuses.map { |s| [s.account_id, s.reblog&.account_id] }.flatten.compact).pluck(:account_id).index_with(true) - crutches[:exclusive_list_users] = ListAccount.where(list: lists, account_id: statuses.map(&:account_id)).pluck(:account_id).index_with(true) - crutches[:exclusive_antenna_users] = AntennaAccount.where(antenna: antennas, account_id: statuses.map(&:account_id)).pluck(:account_id).index_with(true) + crutches[:exclusive_list_users] = crutches_exclusive_list_users(receiver_id, statuses) if list.blank? + crutches[:exclusive_antenna_users] = crutches_exclusive_antenna_users(receiver_id, statuses) crutches end + def crutches_exclusive_list_users(recipient_id, statuses) + lists = List.where(account_id: recipient_id, exclusive: true) + ListAccount.where(list: lists, account_id: statuses.map(&:account_id)).pluck(:account_id).index_with(true) + end + + def crutches_exclusive_antenna_users(recipient_id, statuses) + lists = List.where(account_id: recipient_id, exclusive: true) + antennas = Antenna.where(list: lists, insert_feeds: true) + AntennaAccount.where(antenna: antennas, account_id: statuses.map(&:account_id)).pluck(:account_id).index_with(true) + end + + def crutches_following(recipient_id, statuses, list) + if list.blank? || list.show_followed? + replied_accounts = statuses.filter_map(&:in_reply_to_account_id) + replied_accounts += statuses.filter { |status| status.limited_visibility? && status.thread.present? }.map { |status| status.thread.account_id } + + Follow.where(account_id: recipient_id, target_account_id: replied_accounts).pluck(:target_account_id).index_with(true) + elsif list.show_list? + ListAccount.where(list_id: list.id, account_id: statuses.filter_map(&:in_reply_to_account_id)).pluck(:account_id).index_with(true) + else + {} + end + end + def crutches_active_mentions(statuses) Mention .active diff --git a/app/lib/plain_text_formatter.rb b/app/lib/plain_text_formatter.rb index f960ba7acc..e8ff79806f 100644 --- a/app/lib/plain_text_formatter.rb +++ b/app/lib/plain_text_formatter.rb @@ -16,7 +16,15 @@ class PlainTextFormatter if local? text else - node = Nokogiri::HTML5.fragment(insert_newlines) + begin + node = Nokogiri::HTML5.fragment(insert_newlines) + rescue ArgumentError + # This can happen if one of the Nokogumbo limits is encountered + # Unfortunately, it does not use a more precise error class + # nor allows more graceful handling + return '' + end + # Elements that are entirely removed with our Sanitize config node.xpath('.//iframe|.//math|.//noembed|.//noframes|.//noscript|.//plaintext|.//script|.//style|.//svg|.//xmp').remove node.text.chomp diff --git a/app/models/account.rb b/app/models/account.rb index 0612e63fd5..f3f591d006 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -160,7 +160,7 @@ class Account < ApplicationRecord scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) } scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) } scope :dormant, -> { joins(:account_stat).merge(AccountStat.without_recent_activity) } - scope :with_username, ->(value) { where arel_table[:username].lower.eq(value.to_s.downcase) } + scope :with_username, ->(value) { value.is_a?(Array) ? where(arel_table[:username].lower.in(value.map { |x| x.to_s.downcase })) : where(arel_table[:username].lower.eq(value.to_s.downcase)) } scope :with_domain, ->(value) { where arel_table[:domain].lower.eq(value&.to_s&.downcase) } scope :without_memorial, -> { where(memorial: false) } scope :duplicate_uris, -> { select(:uri, Arel.star.count).group(:uri).having(Arel.star.count.gt(1)) } diff --git a/app/models/account/field.rb b/app/models/account/field.rb index bcd89015de..4b3ccea9c4 100644 --- a/app/models/account/field.rb +++ b/app/models/account/field.rb @@ -73,7 +73,14 @@ class Account::Field < ActiveModelSerializers::Model end def extract_url_from_html - doc = Nokogiri::HTML5.fragment(value) + begin + doc = Nokogiri::HTML5.fragment(value) + rescue ArgumentError + # This can happen if one of the Nokogumbo limits is encountered + # Unfortunately, it does not use a more precise error class + # nor allows more graceful handling + return + end return if doc.nil? return if doc.children.size != 1 diff --git a/app/models/concerns/notification/groups.rb b/app/models/concerns/notification/groups.rb index e064df8502..c678ff9f50 100644 --- a/app/models/concerns/notification/groups.rb +++ b/app/models/concerns/notification/groups.rb @@ -7,6 +7,10 @@ module Notification::Groups GROUPABLE_NOTIFICATION_TYPES = %i(favourite reblog follow emoji_reaction).freeze MAXIMUM_GROUP_SPAN_HOURS = 12 + included do + scope :by_group_key, ->(group_key) { group_key&.start_with?('ungrouped-') ? where(id: group_key.delete_prefix('ungrouped-')) : where(group_key: group_key) } + end + def set_group_key! return if filtered? || GROUPABLE_NOTIFICATION_TYPES.exclude?(type) diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 49ff740884..3eaa978c27 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -425,8 +425,10 @@ class MediaAttachment < ApplicationRecord @paths_to_cache_bust = MediaAttachment.attachment_definitions.keys.flat_map do |attachment_name| attachment = public_send(attachment_name) + next if attachment.blank? + styles = DEFAULT_STYLES | attachment.styles.keys - styles.map { |style| attachment.path(style) } + styles.map { |style| attachment.url(style) } end.compact rescue => e # We really don't want any error here preventing media deletion diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 1be39aaedf..10224f4d7e 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -296,7 +296,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService nil end - @status.mentions.upsert_all(currently_mentioned_account_ids.map { |id| { account_id: id, silent: false } }, unique_by: %w(status_id account_id)) + @status.mentions.upsert_all(currently_mentioned_account_ids.uniq.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 diff --git a/app/services/activitypub/synchronize_followers_service.rb b/app/services/activitypub/synchronize_followers_service.rb index f51d671a00..82d84a2f21 100644 --- a/app/services/activitypub/synchronize_followers_service.rb +++ b/app/services/activitypub/synchronize_followers_service.rb @@ -4,32 +4,46 @@ class ActivityPub::SynchronizeFollowersService < BaseService include JsonLdHelper include Payloadable + MAX_COLLECTION_PAGES = 10 + def call(account, partial_collection_url) @account = account + @expected_followers_ids = [] - items = collection_items(partial_collection_url) - return if items.nil? - - # There could be unresolved accounts (hence the call to .compact) but this - # should never happen in practice, since in almost all cases we keep an - # Account record, and should we not do that, we should have sent a Delete. - # In any case there is not much we can do if that occurs. - @expected_followers = items.filter_map { |uri| ActivityPub::TagManager.instance.uri_to_resource(uri, Account) } + return unless process_collection!(partial_collection_url) remove_unexpected_local_followers! - handle_unexpected_outgoing_follows! end private + def process_page!(items) + page_expected_followers = extract_local_followers(items) + @expected_followers_ids.concat(page_expected_followers.pluck(:id)) + + handle_unexpected_outgoing_follows!(page_expected_followers) + end + + def extract_local_followers(items) + # There could be unresolved accounts (hence the call to .filter_map) but this + # should never happen in practice, since in almost all cases we keep an + # Account record, and should we not do that, we should have sent a Delete. + # In any case there is not much we can do if that occurs. + + # TODO: this will need changes when switching to numeric IDs + + usernames = items.filter_map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username)&.downcase } + Account.local.with_username(usernames) + end + def remove_unexpected_local_followers! - @account.followers.local.where.not(id: @expected_followers.map(&:id)).reorder(nil).find_each do |unexpected_follower| + @account.followers.local.where.not(id: @expected_followers_ids).reorder(nil).find_each do |unexpected_follower| UnfollowService.new.call(unexpected_follower, @account) end end - def handle_unexpected_outgoing_follows! - @expected_followers.each do |expected_follower| + def handle_unexpected_outgoing_follows!(expected_followers) + expected_followers.each do |expected_follower| next if expected_follower.following?(@account) if expected_follower.requested?(@account) @@ -50,18 +64,33 @@ class ActivityPub::SynchronizeFollowersService < BaseService Oj.dump(serialize_payload(follow, ActivityPub::UndoFollowSerializer)) end - def collection_items(collection_or_uri) - collection = fetch_collection(collection_or_uri) - return unless collection.is_a?(Hash) + # Only returns true if the whole collection has been processed + def process_collection!(collection_uri, max_pages: MAX_COLLECTION_PAGES) + collection = fetch_collection(collection_uri) + return false unless collection.is_a?(Hash) collection = fetch_collection(collection['first']) if collection['first'].present? - return unless collection.is_a?(Hash) + while collection.is_a?(Hash) + process_page!(as_array(collection_page_items(collection))) + + max_pages -= 1 + + return true if collection['next'].blank? # We reached the end of the collection + return false if max_pages <= 0 # We reached our pages limit + + collection = fetch_collection(collection['next']) + end + + false + end + + def collection_page_items(collection) case collection['type'] when 'Collection', 'CollectionPage' - as_array(collection['items']) + collection['items'] when 'OrderedCollection', 'OrderedCollectionPage' - as_array(collection['orderedItems']) + collection['orderedItems'] end end diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index 44210799f9..3934a738f7 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -95,7 +95,7 @@ class SuspendAccountService < BaseService end end - CacheBusterWorker.perform_async(attachment.path(style)) if Rails.configuration.x.cache_buster_enabled + CacheBusterWorker.perform_async(attachment.url(style)) if Rails.configuration.x.cache_buster_enabled end end end diff --git a/app/services/unsuspend_account_service.rb b/app/services/unsuspend_account_service.rb index 652dd6a845..7d3bb806a6 100644 --- a/app/services/unsuspend_account_service.rb +++ b/app/services/unsuspend_account_service.rb @@ -91,7 +91,7 @@ class UnsuspendAccountService < BaseService end end - CacheBusterWorker.perform_async(attachment.path(style)) if Rails.configuration.x.cache_buster_enabled + CacheBusterWorker.perform_async(attachment.url(style)) if Rails.configuration.x.cache_buster_enabled end end end diff --git a/app/views/custom_css/show_system.css.erb b/app/views/custom_css/show_system.css.erb index 72f0281aa1..e69de29bb2 100644 --- a/app/views/custom_css/show_system.css.erb +++ b/app/views/custom_css/show_system.css.erb @@ -1,6 +0,0 @@ -<%- @user_roles.each do |role| %> -.user-role-<%= role.id %> { - --user-role-accent: <%= role.color %>; -} - -<%- end %> diff --git a/app/views/system_css/show.css.erb b/app/views/system_css/show.css.erb index 72f0281aa1..e69de29bb2 100644 --- a/app/views/system_css/show.css.erb +++ b/app/views/system_css/show.css.erb @@ -1,6 +0,0 @@ -<%- @user_roles.each do |role| %> -.user-role-<%= role.id %> { - --user-role-accent: <%= role.color %>; -} - -<%- end %> diff --git a/app/workers/activitypub/update_distribution_worker.rb b/app/workers/activitypub/update_distribution_worker.rb index a04ac621f3..9a418f0f3d 100644 --- a/app/workers/activitypub/update_distribution_worker.rb +++ b/app/workers/activitypub/update_distribution_worker.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ActivityPub::UpdateDistributionWorker < ActivityPub::RawDistributionWorker + DEBOUNCE_DELAY = 5.seconds + sidekiq_options queue: 'push', lock: :until_executed, lock_ttl: 1.day.to_i # Distribute an profile update to servers that might have a copy diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index b4eaab1daa..f558ee5fe0 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -122,7 +122,7 @@ class Rack::Attack end throttle('throttle_email_confirmations/ip', limit: 25, period: 5.minutes) do |req| - req.throttleable_remote_ip if req.post? && (req.path_matches?('/auth/confirmation') || req.path == '/api/v1/emails/confirmations') + req.throttleable_remote_ip if (req.post? && (req.path_matches?('/auth/confirmation') || req.path == '/api/v1/emails/confirmations')) || ((req.put? || req.patch?) && req.path_matches?('/auth/setup')) end throttle('throttle_email_confirmations/email', limit: 5, period: 30.minutes) do |req| @@ -133,6 +133,14 @@ class Rack::Attack end end + throttle('throttle_auth_setup/email', limit: 5, period: 10.minutes) do |req| + req.params.dig('user', 'email').presence if (req.put? || req.patch?) && req.path_matches?('/auth/setup') + end + + throttle('throttle_auth_setup/account', limit: 5, period: 10.minutes) do |req| + req.warden_user_id if (req.put? || req.patch?) && req.path_matches?('/auth/setup') + end + throttle('throttle_login_attempts/ip', limit: 25, period: 5.minutes) do |req| req.throttleable_remote_ip if req.post? && req.path_matches?('/auth/sign_in') end diff --git a/docker-compose.yml b/docker-compose.yml index 61deb51ab6..143929d902 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:17.0-dev + image: kmyblue:17.4 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:17.0-dev + image: kmyblue-streaming:17.4 restart: always env_file: .env.production command: node ./streaming/index.js @@ -101,7 +101,7 @@ services: sidekiq: build: . - image: kmyblue:17.0-dev + image: kmyblue:17.4 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index f09f32ab30..d0366d5d14 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,13 +13,13 @@ module Mastodon end def kmyblue_minor - 0 + 4 end def kmyblue_flag # 'LTS' - 'dev' - # nil + # 'dev' + nil end def major @@ -35,7 +35,7 @@ module Mastodon end def default_prerelease - 'alpha.2' + 'alpha.4' end def prerelease diff --git a/lib/paperclip/vips_lazy_thumbnail.rb b/lib/paperclip/vips_lazy_thumbnail.rb index fea4b86064..528d5604dc 100644 --- a/lib/paperclip/vips_lazy_thumbnail.rb +++ b/lib/paperclip/vips_lazy_thumbnail.rb @@ -123,7 +123,14 @@ module Paperclip end def needs_convert? - needs_different_geometry? || needs_different_format? || needs_metadata_stripping? + strip_animations? || needs_different_geometry? || needs_different_format? || needs_metadata_stripping? + end + + def strip_animations? + # Detecting whether the source image is animated across all our supported + # input file formats is not trivial, and converting unconditionally is just + # as simple for now + options[:style] == :static end def needs_different_geometry? diff --git a/lib/redis/namespace_extensions.rb b/lib/redis/namespace_extensions.rb index 9af59c296e..2be738b04d 100644 --- a/lib/redis/namespace_extensions.rb +++ b/lib/redis/namespace_extensions.rb @@ -5,6 +5,10 @@ class Redis def exists?(...) call_with_namespace('exists?', ...) end + + def with + yield self + end end end diff --git a/lib/sanitize_ext/sanitize_config.rb b/lib/sanitize_ext/sanitize_config.rb index 7815d9ed52..410a58b07e 100644 --- a/lib/sanitize_ext/sanitize_config.rb +++ b/lib/sanitize_ext/sanitize_config.rb @@ -155,18 +155,16 @@ class Sanitize ) MASTODON_OEMBED = freeze_config( - elements: %w(audio embed iframe source video), + elements: %w(audio iframe source video), attributes: { 'audio' => %w(controls), - 'embed' => %w(height src type width), 'iframe' => %w(allowfullscreen frameborder height scrolling src width), 'source' => %w(src type), 'video' => %w(controls height loop width), }, protocols: { - 'embed' => { 'src' => HTTP_PROTOCOLS }, 'iframe' => { 'src' => HTTP_PROTOCOLS }, 'source' => { 'src' => HTTP_PROTOCOLS }, }, diff --git a/spec/controllers/settings/privacy_controller_spec.rb b/spec/controllers/settings/privacy_controller_spec.rb index 59fd342199..b09f700081 100644 --- a/spec/controllers/settings/privacy_controller_spec.rb +++ b/spec/controllers/settings/privacy_controller_spec.rb @@ -31,7 +31,7 @@ RSpec.describe Settings::PrivacyController do describe 'PUT #update' do context 'when update succeeds' do before do - allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in) end it 'updates the user profile' do @@ -44,14 +44,14 @@ RSpec.describe Settings::PrivacyController do .to redirect_to(settings_privacy_path) expect(ActivityPub::UpdateDistributionWorker) - .to have_received(:perform_async).with(account.id) + .to have_received(:perform_in).with(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, account.id) end end context 'when update fails' do before do allow(UpdateAccountService).to receive(:new).and_return(failing_update_service) - allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in) end it 'updates the user profile' do @@ -61,7 +61,7 @@ RSpec.describe Settings::PrivacyController do .to render_template(:show) expect(ActivityPub::UpdateDistributionWorker) - .to_not have_received(:perform_async) + .to_not have_received(:perform_in) end private diff --git a/spec/lib/account_reach_finder_spec.rb b/spec/lib/account_reach_finder_spec.rb index 0c1d92b2da..ed16c07c22 100644 --- a/spec/lib/account_reach_finder_spec.rb +++ b/spec/lib/account_reach_finder_spec.rb @@ -13,13 +13,28 @@ RSpec.describe AccountReachFinder do let(:ap_mentioned_example_com) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-3', domain: 'example.com') } let(:ap_mentioned_example_org) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.org/inbox-4', domain: 'example.org') } + let(:ap_followed_example_com) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-5', domain: 'example.com') } + let(:ap_followed_example_org) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-6', domain: 'example.org') } + + let(:ap_requested_example_com) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-7', domain: 'example.com') } + let(:ap_requested_example_org) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/inbox-8', domain: 'example.org') } + let(:unrelated_account) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/unrelated-inbox', domain: 'example.com') } + let(:old_followed_account) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://example.com/old-followed-inbox', domain: 'example.com') } before do + travel_to(2.months.ago) { account.follow!(old_followed_account) } + ap_follower_example_com.follow!(account) ap_follower_example_org.follow!(account) ap_follower_with_shared.follow!(account) + account.follow!(ap_followed_example_com) + account.follow!(ap_followed_example_org) + + account.request_follow!(ap_requested_example_com) + account.request_follow!(ap_requested_example_org) + Fabricate(:status, account: account).tap do |status| status.mentions << Mention.new(account: ap_follower_example_com) status.mentions << Mention.new(account: ap_mentioned_with_shared) @@ -44,7 +59,10 @@ RSpec.describe AccountReachFinder do expect(subject) .to include(*follower_inbox_urls) .and include(*mentioned_account_inbox_urls) + .and include(*recently_followed_inbox_urls) + .and include(*recently_requested_inbox_urls) .and not_include(unrelated_account.preferred_inbox_url) + .and not_include(old_followed_account.preferred_inbox_url) end def follower_inbox_urls @@ -56,5 +74,15 @@ RSpec.describe AccountReachFinder do [ap_mentioned_with_shared, ap_mentioned_example_com, ap_mentioned_example_org] .map(&:preferred_inbox_url) end + + def recently_followed_inbox_urls + [ap_followed_example_com, ap_followed_example_org] + .map(&:preferred_inbox_url) + end + + def recently_requested_inbox_urls + [ap_requested_example_com, ap_requested_example_org] + .map(&:preferred_inbox_url) + end end end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index f5528f13a7..108111c06b 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -48,10 +48,16 @@ RSpec.describe ActivityPub::Activity::Create do content: '@bob lorem ipsum', published: 1.hour.ago.utc.iso8601, updated: 1.hour.ago.utc.iso8601, - tag: { - type: 'Mention', - href: ActivityPub::TagManager.instance.uri_for(follower), - }, + tag: [ + { + type: 'Mention', + href: ActivityPub::TagManager.instance.uri_for(follower), + }, + { + type: 'Mention', + href: ActivityPub::TagManager.instance.uri_for(follower), + }, + ], } end diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index ad7913c758..48defa1f83 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -233,6 +233,28 @@ RSpec.describe FeedManager do end end + context 'with list feed' do + let(:list) { Fabricate(:list, account: bob) } + + before do + bob.follow!(alice) + list.list_accounts.create!(account: alice) + end + + it "returns false for followee's status" do + status = Fabricate(:status, text: 'Hello world', account: alice) + + expect(subject.filter?(:list, status, list)).to be false + end + + it 'returns false for reblog by followee' do + status = Fabricate(:status, text: 'Hello world', account: jeff) + reblog = Fabricate(:status, reblog: status, account: alice) + + expect(subject.filter?(:list, reblog, list)).to be false + end + end + context 'with mentions feed' do it 'returns true for status that mentions blocked account' do bob.block!(jeff) diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb index 5f91ae0967..43e9ed087b 100644 --- a/spec/models/media_attachment_spec.rb +++ b/spec/models/media_attachment_spec.rb @@ -295,12 +295,21 @@ RSpec.describe MediaAttachment, :attachment_processing do end it 'queues CacheBusterWorker jobs' do - original_path = media.file.path(:original) - small_path = media.file.path(:small) + original_url = media.file.url(:original) + small_url = media.file.url(:small) expect { media.destroy } - .to enqueue_sidekiq_job(CacheBusterWorker).with(original_path) - .and enqueue_sidekiq_job(CacheBusterWorker).with(small_path) + .to enqueue_sidekiq_job(CacheBusterWorker).with(original_url) + .and enqueue_sidekiq_job(CacheBusterWorker).with(small_url) + end + + context 'with a missing remote attachment' do + let(:media) { Fabricate(:media_attachment, remote_url: 'https://example.com/foo.png', file: nil) } + + it 'does not queue CacheBusterWorker jobs' do + expect { media.destroy } + .to_not enqueue_sidekiq_job(CacheBusterWorker) + end end end diff --git a/spec/requests/api/v1/accounts/credentials_spec.rb b/spec/requests/api/v1/accounts/credentials_spec.rb index c92f4c7973..68ea259481 100644 --- a/spec/requests/api/v1/accounts/credentials_spec.rb +++ b/spec/requests/api/v1/accounts/credentials_spec.rb @@ -53,8 +53,6 @@ RSpec.describe 'credentials API' do patch '/api/v1/accounts/update_credentials', headers: headers, params: params end - before { allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) } - let(:params) do { avatar: fixture_file_upload('avatar.gif', 'image/gif'), @@ -113,7 +111,7 @@ RSpec.describe 'credentials API' do }) expect(ActivityPub::UpdateDistributionWorker) - .to have_received(:perform_async).with(user.account_id) + .to have_enqueued_sidekiq_job(user.account_id) end def expect_account_updates diff --git a/spec/requests/api/v1/instances/domain_blocks_spec.rb b/spec/requests/api/v1/instances/domain_blocks_spec.rb index d0707d784c..40b79c9691 100644 --- a/spec/requests/api/v1/instances/domain_blocks_spec.rb +++ b/spec/requests/api/v1/instances/domain_blocks_spec.rb @@ -4,14 +4,15 @@ require 'rails_helper' RSpec.describe 'Domain Blocks' do let(:user) { Fabricate(:user) } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes).token } let(:scopes) { 'read' } - let(:headers) { { Authorization: "Bearer #{token.token}" } } + let(:headers) { { Authorization: "Bearer #{token}" } } describe 'GET /api/v1/instance/domain_blocks' do - before do - Fabricate(:domain_block) - end + let(:user) { Fabricate(:user) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id).token } + + before { Fabricate(:domain_block) } context 'with domain blocks set to all' do before { Setting.show_domain_blocks = 'all' } @@ -45,11 +46,95 @@ RSpec.describe 'Domain Blocks' do context 'with domain blocks set to users' do before { Setting.show_domain_blocks = 'users' } - it 'returns http not found' do - get api_v1_instance_domain_blocks_path + context 'without authentication token' do + it 'returns http not found' do + get api_v1_instance_domain_blocks_path - expect(response) - .to have_http_status(404) + expect(response) + .to have_http_status(404) + end + end + + context 'with authentication token' do + context 'with unapproved user' do + before { user.update(approved: false) } + + it 'returns http not found' do + get api_v1_instance_domain_blocks_path, headers: { 'Authorization' => "Bearer #{token}" } + + expect(response) + .to have_http_status(404) + end + end + + context 'with unconfirmed user' do + before { user.update(confirmed_at: nil) } + + it 'returns http not found' do + get api_v1_instance_domain_blocks_path, headers: { 'Authorization' => "Bearer #{token}" } + + expect(response) + .to have_http_status(404) + end + end + + context 'with disabled user' do + before { user.update(disabled: true) } + + it 'returns http not found' do + get api_v1_instance_domain_blocks_path, headers: { 'Authorization' => "Bearer #{token}" } + + expect(response) + .to have_http_status(404) + end + end + + context 'with suspended user' do + before { user.account.update(suspended_at: Time.zone.now) } + + it 'returns http not found' do + get api_v1_instance_domain_blocks_path, headers: { 'Authorization' => "Bearer #{token}" } + + expect(response) + .to have_http_status(403) + end + end + + context 'with moved user' do + before { user.account.update(moved_to_account_id: Fabricate(:account).id) } + + it 'returns http success' do + get api_v1_instance_domain_blocks_path, headers: { 'Authorization' => "Bearer #{token}" } + + expect(response) + .to have_http_status(200) + + expect(response.content_type) + .to start_with('application/json') + + expect(response.parsed_body) + .to be_present + .and(be_an(Array)) + .and(have_attributes(size: 1)) + end + end + + context 'with normal user' do + it 'returns http success' do + get api_v1_instance_domain_blocks_path, headers: { 'Authorization' => "Bearer #{token}" } + + expect(response) + .to have_http_status(200) + + expect(response.content_type) + .to start_with('application/json') + + expect(response.parsed_body) + .to be_present + .and(be_an(Array)) + .and(have_attributes(size: 1)) + end + end end end diff --git a/spec/requests/api/v1/profiles_spec.rb b/spec/requests/api/v1/profiles_spec.rb index fd3ab4bf58..de7a20b133 100644 --- a/spec/requests/api/v1/profiles_spec.rb +++ b/spec/requests/api/v1/profiles_spec.rb @@ -15,10 +15,6 @@ RSpec.describe 'Deleting profile images' do let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } describe 'DELETE /api/v1/profile' do - before do - allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) - end - context 'when deleting an avatar' do context 'with wrong scope' do before do @@ -38,7 +34,8 @@ RSpec.describe 'Deleting profile images' do account.reload expect(account.avatar).to_not exist expect(account.header).to exist - expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) + expect(ActivityPub::UpdateDistributionWorker) + .to have_enqueued_sidekiq_job(account.id) end end @@ -61,7 +58,8 @@ RSpec.describe 'Deleting profile images' do account.reload expect(account.avatar).to exist expect(account.header).to_not exist - expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) + expect(ActivityPub::UpdateDistributionWorker) + .to have_enqueued_sidekiq_job(account.id) end end end diff --git a/spec/requests/api/v2/notifications_spec.rb b/spec/requests/api/v2/notifications_spec.rb index b2f6d71b51..72df063d49 100644 --- a/spec/requests/api/v2/notifications_spec.rb +++ b/spec/requests/api/v2/notifications_spec.rb @@ -170,7 +170,7 @@ RSpec.describe 'Notifications' do end context 'with min_id param' do - let(:params) { { min_id: user.account.notifications.reload.first.id - 1 } } + let(:params) { { min_id: user.account.notifications.order(id: :asc).first.id - 1 } } it 'returns a notification group covering all notifications' do subject @@ -365,6 +365,18 @@ RSpec.describe 'Notifications' do .to start_with('application/json') end + context 'with an ungrouped notification' do + let(:notification) { Fabricate(:notification, account: user.account, type: :favourite) } + + it 'returns http success' do + get "/api/v2/notifications/ungrouped-#{notification.id}", headers: headers + + expect(response).to have_http_status(200) + expect(response.content_type) + .to start_with('application/json') + end + end + context 'when notification belongs to someone else' do let(:notification) { Fabricate(:notification, group_key: 'foobar') } @@ -396,6 +408,19 @@ RSpec.describe 'Notifications' do expect { notification.reload }.to raise_error(ActiveRecord::RecordNotFound) end + context 'with an ungrouped notification' do + let(:notification) { Fabricate(:notification, account: user.account, type: :favourite) } + + it 'destroys the notification' do + post "/api/v2/notifications/ungrouped-#{notification.id}/dismiss", headers: headers + + expect(response).to have_http_status(200) + expect(response.content_type) + .to start_with('application/json') + expect { notification.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + context 'when notification belongs to someone else' do let(:notification) { Fabricate(:notification, group_key: 'foobar') } diff --git a/spec/requests/auth/setup_spec.rb b/spec/requests/auth/setup_spec.rb index 72413e1740..fa3c196805 100644 --- a/spec/requests/auth/setup_spec.rb +++ b/spec/requests/auth/setup_spec.rb @@ -24,15 +24,4 @@ RSpec.describe 'Auth Setup' do end end end - - describe 'PUT /auth/setup' do - before { sign_in Fabricate(:user, confirmed_at: nil) } - - it 'gracefully handles invalid nested params' do - put '/auth/setup?user=invalid' - - expect(response) - .to have_http_status(400) - 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 49d496e295..6d008840be 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -12,6 +12,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do [ { type: 'Hashtag', name: 'hoge' }, { type: 'Mention', href: ActivityPub::TagManager.instance.uri_for(alice) }, + { type: 'Mention', href: ActivityPub::TagManager.instance.uri_for(alice) }, { type: 'Mention', href: bogus_mention }, ] end diff --git a/spec/services/activitypub/synchronize_followers_service_spec.rb b/spec/services/activitypub/synchronize_followers_service_spec.rb index 974368b7d7..70f27627e1 100644 --- a/spec/services/activitypub/synchronize_followers_service_spec.rb +++ b/spec/services/activitypub/synchronize_followers_service_spec.rb @@ -10,7 +10,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do let(:bob) { Fabricate(:account, username: 'bob') } let(:eve) { Fabricate(:account, username: 'eve') } let(:mallory) { Fabricate(:account, username: 'mallory') } - let(:collection_uri) { 'http://example.com/partial-followers' } + let(:collection_uri) { 'https://example.com/partial-followers' } let(:items) do [alice, eve, mallory].map do |account| @@ -27,14 +27,14 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do }.with_indifferent_access end + before do + alice.follow!(actor) + bob.follow!(actor) + mallory.request_follow!(actor) + end + shared_examples 'synchronizes followers' do before do - alice.follow!(actor) - bob.follow!(actor) - mallory.request_follow!(actor) - - allow(ActivityPub::DeliveryWorker).to receive(:perform_async) - subject.call(actor, collection_uri) end @@ -46,7 +46,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do expect(mallory) .to be_following(actor) # Convert follow request to follow when accepted expect(ActivityPub::DeliveryWorker) - .to have_received(:perform_async).with(anything, eve.id, actor.inbox_url) # Send Undo Follow to actor + .to have_enqueued_sidekiq_job(anything, eve.id, actor.inbox_url) # Send Undo Follow to actor end end @@ -76,7 +76,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do it_behaves_like 'synchronizes followers' end - context 'when the endpoint is a paginated Collection of actor URIs' do + context 'when the endpoint is a single-page paginated Collection of actor URIs' do let(:payload) do { '@context': 'https://www.w3.org/ns/activitystreams', @@ -96,5 +96,106 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do it_behaves_like 'synchronizes followers' end + + context 'when the endpoint is a paginated Collection of actor URIs split across multiple pages' do + before do + stub_request(:get, 'https://example.com/partial-followers') + .to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Collection', + id: 'https://example.com/partial-followers', + first: 'https://example.com/partial-followers/1', + })) + + stub_request(:get, 'https://example.com/partial-followers/1') + .to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'CollectionPage', + id: 'https://example.com/partial-followers/1', + partOf: 'https://example.com/partial-followers', + next: 'https://example.com/partial-followers/2', + items: [alice, eve].map { |account| ActivityPub::TagManager.instance.uri_for(account) }, + })) + + stub_request(:get, 'https://example.com/partial-followers/2') + .to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'CollectionPage', + id: 'https://example.com/partial-followers/2', + partOf: 'https://example.com/partial-followers', + items: ActivityPub::TagManager.instance.uri_for(mallory), + })) + end + + it_behaves_like 'synchronizes followers' + end + + context 'when the endpoint is a paginated Collection of actor URIs split across, but one page errors out' do + before do + stub_request(:get, 'https://example.com/partial-followers') + .to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Collection', + id: 'https://example.com/partial-followers', + first: 'https://example.com/partial-followers/1', + })) + + stub_request(:get, 'https://example.com/partial-followers/1') + .to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'CollectionPage', + id: 'https://example.com/partial-followers/1', + partOf: 'https://example.com/partial-followers', + next: 'https://example.com/partial-followers/2', + items: [mallory].map { |account| ActivityPub::TagManager.instance.uri_for(account) }, + })) + + stub_request(:get, 'https://example.com/partial-followers/2') + .to_return(status: 404) + end + + it 'confirms pending follow request but does not remove extra followers' do + previous_follower_ids = actor.followers.pluck(:id) + + subject.call(actor, collection_uri) + + expect(previous_follower_ids - actor.followers.reload.pluck(:id)) + .to be_empty + expect(mallory) + .to be_following(actor) + end + end + + context 'when the endpoint is a paginated Collection of actor URIs with more pages than we allow' do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Collection', + id: collection_uri, + first: { + type: 'CollectionPage', + partOf: collection_uri, + items: items, + next: "#{collection_uri}/page2", + }, + }.with_indifferent_access + end + + before do + stub_const('ActivityPub::SynchronizeFollowersService::MAX_COLLECTION_PAGES', 1) + stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' }) + end + + it 'confirms pending follow request but does not remove extra followers' do + previous_follower_ids = actor.followers.pluck(:id) + + subject.call(actor, collection_uri) + + expect(previous_follower_ids - actor.followers.reload.pluck(:id)) + .to be_empty + expect(mallory) + .to be_following(actor) + end + end end end diff --git a/spec/services/suspend_account_service_spec.rb b/spec/services/suspend_account_service_spec.rb index 4a2f494e0c..c15c23ca30 100644 --- a/spec/services/suspend_account_service_spec.rb +++ b/spec/services/suspend_account_service_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe SuspendAccountService, :inline_jobs do +RSpec.describe SuspendAccountService do shared_examples 'common behavior' do subject { described_class.new.call(account) } @@ -11,6 +11,7 @@ RSpec.describe SuspendAccountService, :inline_jobs do before do allow(FeedManager.instance).to receive_messages(unmerge_from_home: nil, unmerge_from_list: nil) + allow(Rails.configuration.x).to receive(:cache_buster_enabled).and_return(true) local_follower.follow!(account) list.accounts << account @@ -23,6 +24,7 @@ RSpec.describe SuspendAccountService, :inline_jobs do it 'unmerges from feeds of local followers and changes file mode and preserves suspended flag' do expect { subject } .to change_file_mode + .and enqueue_sidekiq_job(CacheBusterWorker).with(account.media_attachments.first.file.url(:original)) .and not_change_suspended_flag expect(FeedManager.instance).to have_received(:unmerge_from_home).with(account, local_follower) expect(FeedManager.instance).to have_received(:unmerge_from_list).with(account, list) @@ -38,17 +40,12 @@ RSpec.describe SuspendAccountService, :inline_jobs do end describe 'suspending a local account' do - def match_update_actor_request(req, account) - json = JSON.parse(req.body) + def match_update_actor_request(json, account) + json = JSON.parse(json) actor_id = ActivityPub::TagManager.instance.uri_for(account) json['type'] == 'Update' && json['actor'] == actor_id && json['object']['id'] == actor_id && json['object']['suspended'] end - before do - stub_request(:post, 'https://alice.com/inbox').to_return(status: 201) - stub_request(:post, 'https://bob.com/inbox').to_return(status: 201) - end - include_examples 'common behavior' do let!(:account) { Fabricate(:account) } let!(:remote_follower) { Fabricate(:account, uri: 'https://alice.com', inbox_url: 'https://alice.com/inbox', protocol: :activitypub, domain: 'alice.com') } @@ -61,22 +58,20 @@ RSpec.describe SuspendAccountService, :inline_jobs do it 'sends an Update actor activity to followers and reporters' do subject - expect(a_request(:post, remote_follower.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once - expect(a_request(:post, remote_reporter.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once + + expect(ActivityPub::DeliveryWorker) + .to have_enqueued_sidekiq_job(satisfying { |json| match_update_actor_request(json, account) }, account.id, remote_follower.inbox_url).once + .and have_enqueued_sidekiq_job(satisfying { |json| match_update_actor_request(json, account) }, account.id, remote_reporter.inbox_url).once end end end describe 'suspending a remote account' do - def match_reject_follow_request(req, account, followee) - json = JSON.parse(req.body) + def match_reject_follow_request(json, account, followee) + json = JSON.parse(json) json['type'] == 'Reject' && json['actor'] == ActivityPub::TagManager.instance.uri_for(followee) && json['object']['actor'] == account.uri end - before do - stub_request(:post, 'https://bob.com/inbox').to_return(status: 201) - end - include_examples 'common behavior' do let!(:account) { Fabricate(:account, domain: 'bob.com', uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub) } let!(:local_followee) { Fabricate(:account) } @@ -88,7 +83,8 @@ RSpec.describe SuspendAccountService, :inline_jobs do it 'sends a Reject Follow activity', :aggregate_failures do subject - expect(a_request(:post, account.inbox_url).with { |req| match_reject_follow_request(req, account, local_followee) }).to have_been_made.once + expect(ActivityPub::DeliveryWorker) + .to have_enqueued_sidekiq_job(satisfying { |json| match_reject_follow_request(json, account, local_followee) }, local_followee.id, account.inbox_url).once end end end diff --git a/spec/system/settings/profiles_spec.rb b/spec/system/settings/profiles_spec.rb index 73a5751141..f0fdbdb203 100644 --- a/spec/system/settings/profiles_spec.rb +++ b/spec/system/settings/profiles_spec.rb @@ -7,7 +7,6 @@ RSpec.describe 'Settings profile page' do let(:account) { user.account } before do - allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) sign_in user end @@ -24,7 +23,7 @@ RSpec.describe 'Settings profile page' do .to change { account.reload.display_name }.to('New name') .and(change { account.reload.avatar.instance.avatar_file_name }.from(nil).to(be_present)) expect(ActivityPub::UpdateDistributionWorker) - .to have_received(:perform_async).with(account.id) + .to have_enqueued_sidekiq_job(account.id) end def display_name_field diff --git a/streaming/database.js b/streaming/database.js index 60a3b34ef0..553c9149cc 100644 --- a/streaming/database.js +++ b/streaming/database.js @@ -49,7 +49,7 @@ export function configFromEnv(env, environment) { if (typeof parsedUrl.password === 'string') baseConfig.password = parsedUrl.password; if (typeof parsedUrl.host === 'string') baseConfig.host = parsedUrl.host; if (typeof parsedUrl.user === 'string') baseConfig.user = parsedUrl.user; - if (typeof parsedUrl.port === 'string') { + if (typeof parsedUrl.port === 'string' && parsedUrl.port) { const parsedPort = parseInt(parsedUrl.port, 10); if (isNaN(parsedPort)) { throw new Error('Invalid port specified in DATABASE_URL environment variable'); diff --git a/streaming/index.js b/streaming/index.js index 4b6817d8fd..ff3ef1c7ba 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -689,7 +689,7 @@ const startServer = async () => { // filtering of statuses: // Filter based on language: - if (Array.isArray(req.chosenLanguages) && payload.language !== null && req.chosenLanguages.indexOf(payload.language) === -1) { + if (Array.isArray(req.chosenLanguages) && req.chosenLanguages.indexOf(payload.language) === -1) { log.debug(`Message ${payload.id} filtered by language (${payload.language})`); return; }