diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea2ea3a79..f2b8385d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,23 @@ All notable changes to this project will be documented in this file. +## [4.6.8] - 2026-09-15 + +### Security + +- Temporarily disable HEIF support + +### Fixes + +- Fix relative privacy policy links in subscription emails (#40486 by @crafkaz) +- Fix canonical email blocks interfering with freezing or approving users (#40463 by @ClearlyClaire) +- Fix 500 error when submitting a status twice (#40439 by @ClearlyClaire) +- Fix account deletion not deleting generated annual reports (#40394 by @ClearlyClaire) +- Fix notifications not being cleaned up when notification requests are deleted in bulk (#40393 by @ClearlyClaire) + ## [4.6.7] - 2026-09-01 -## Security +### Security - Update dependencies - Fix password authentication bypass in 2FA auth for LDAP/PAM/SSO accounts ([GHSA-vx32-x96w-qq65](https://github.com/mastodon/mastodon/security/advisories/GHSA-vx32-x96w-qq65)) diff --git a/app/controllers/api/v1/notifications/requests_controller.rb b/app/controllers/api/v1/notifications/requests_controller.rb index 3c90f13ce2..6642725393 100644 --- a/app/controllers/api/v1/notifications/requests_controller.rb +++ b/app/controllers/api/v1/notifications/requests_controller.rb @@ -45,6 +45,7 @@ class Api::V1::Notifications::RequestsController < Api::BaseController end def dismiss_bulk + FilteredNotificationCleanupWorker.perform_async(current_account.id, @requests.map(&:from_account_id)) @requests.each(&:destroy!) render_empty end diff --git a/app/models/concerns/account/associations.rb b/app/models/concerns/account/associations.rb index 8c26a4da64..1a3d5f6845 100644 --- a/app/models/concerns/account/associations.rb +++ b/app/models/concerns/account/associations.rb @@ -40,6 +40,7 @@ module Account::Associations has_many :statuses has_many :keypairs has_many :email_subscriptions + has_many :generated_annual_reports has_one :deletion_request, class_name: 'AccountDeletionRequest' has_one :follow_recommendation_suppression diff --git a/app/models/user.rb b/app/models/user.rb index 602f4230e5..e1f1abcb68 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -90,7 +90,7 @@ class User < ApplicationRecord validates :email, presence: true, email_address: true, length: { maximum: 320 } validates :email, email_mx: { attempt_ip: :sign_up_ip }, if: :validate_email_dns? - validates_with UserEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? } + validates_with UserEmailValidator, if: -> { (ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed?) && will_save_change_to_email? } validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create # Honeypot/anti-spam fields diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb index e9ae117ceb..4f94a3ef71 100644 --- a/app/services/delete_account_service.rb +++ b/app/services/delete_account_service.rb @@ -28,6 +28,7 @@ class DeleteAccountService < BaseService scheduled_statuses status_pins tag_follows + generated_annual_reports ).freeze # The following associations have no important side-effects @@ -53,6 +54,7 @@ class DeleteAccountService < BaseService scheduled_statuses status_pins tag_follows + generated_annual_reports ).freeze ASSOCIATIONS_ON_DESTROY = %w( diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index bfcc04deb2..3b943bc28a 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -20,6 +20,15 @@ class PostStatusService < BaseService end end + class IdempotencyError < StandardError + attr_reader :status + + def initialize(status) + super() + @status = status + end + end + # Post a text status update, fetch and notify remote users mentioned # @param [Account] account Account from which to post # @param [Hash] options @@ -46,9 +55,10 @@ class PostStatusService < BaseService @in_reply_to = @options[:thread] @quoted_status = @options[:quoted_status] + preprocess_attributes! + with_idempotency do validate_media! - preprocess_attributes! if scheduled? schedule_status! @@ -63,7 +73,7 @@ class PostStatusService < BaseService end @status - rescue Antispam::SilentlyDrop => e + rescue Antispam::SilentlyDrop, IdempotencyError => e e.status end @@ -253,7 +263,7 @@ class PostStatusService < BaseService return yield unless idempotency_given? with_redis_lock("idempotency:lock:status:#{@account.id}:#{@options[:idempotency]}") do - return idempotency_duplicate if idempotency_duplicate? + raise IdempotencyError, idempotency_duplicate if idempotency_duplicate? yield diff --git a/app/views/email_subscription_mailer/confirmation.html.haml b/app/views/email_subscription_mailer/confirmation.html.haml index 51f414349e..c0c5ceb50d 100644 --- a/app/views/email_subscription_mailer/confirmation.html.haml +++ b/app/views/email_subscription_mailer/confirmation.html.haml @@ -17,6 +17,6 @@ - content_for :footer do %p.email-footer-p= t('email_subscription_mailer.notification.footer.reason_for_email_html', name: display_name(@subscription.account), unsubscribe_path: @unsubscribe_url) - %p.email-footer-p= t('email_subscription_mailer.notification.footer.privacy_html', domain: @instance, privacy_policy_path: privacy_policy_path) + %p.email-footer-p= t('email_subscription_mailer.notification.footer.privacy_html', domain: @instance, privacy_policy_path: privacy_policy_url) - if Setting.email_footer_text.present? %p.email-footer-p= Setting.email_footer_text diff --git a/app/views/email_subscription_mailer/notification.html.haml b/app/views/email_subscription_mailer/notification.html.haml index 0ee2aa2e0b..207e61191f 100644 --- a/app/views/email_subscription_mailer/notification.html.haml +++ b/app/views/email_subscription_mailer/notification.html.haml @@ -33,6 +33,6 @@ - content_for :footer do %p.email-footer-p= t('.footer.reason_for_email_html', name: display_name(@subscription.account), unsubscribe_path: @unsubscribe_url) - %p.email-footer-p= t('.footer.privacy_html', domain: @instance, privacy_policy_path: privacy_policy_path) + %p.email-footer-p= t('.footer.privacy_html', domain: @instance, privacy_policy_path: privacy_policy_url) - if Setting.email_footer_text.present? %p.email-footer-p= Setting.email_footer_text diff --git a/app/workers/filtered_notification_cleanup_worker.rb b/app/workers/filtered_notification_cleanup_worker.rb index 87ff6a9eb5..d4107bbfda 100644 --- a/app/workers/filtered_notification_cleanup_worker.rb +++ b/app/workers/filtered_notification_cleanup_worker.rb @@ -3,7 +3,7 @@ class FilteredNotificationCleanupWorker include Sidekiq::Worker - def perform(account_id, from_account_id) - Notification.where(account_id: account_id, from_account_id: from_account_id, filtered: true).in_batches(order: :desc).delete_all + def perform(account_id, from_account_ids) + Notification.where(account_id: account_id, from_account_id: from_account_ids, filtered: true).in_batches(order: :desc).delete_all end end diff --git a/config/initializers/vips.rb b/config/initializers/vips.rb index cd8d2410b9..4d68e9023a 100644 --- a/config/initializers/vips.rb +++ b/config/initializers/vips.rb @@ -17,7 +17,6 @@ Vips.block('VipsForeign', true) VipsForeignLoadJpeg VipsForeignLoadPng VipsForeignLoadWebp - VipsForeignLoadHeif VipsForeignSavePng VipsForeignSaveSpng VipsForeignSaveJpeg diff --git a/docker-compose.yml b/docker-compose.yml index af11913518..bba347b478 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,7 +60,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: ghcr.io/glitch-soc/mastodon:v4.6.7 + image: ghcr.io/glitch-soc/mastodon:v4.6.8 restart: always env_file: .env.production command: bundle exec puma -C config/puma.rb @@ -84,7 +84,7 @@ services: # build: # dockerfile: ./streaming/Dockerfile # context: . - image: ghcr.io/glitch-soc/mastodon-streaming:v4.6.7 + image: ghcr.io/glitch-soc/mastodon-streaming:v4.6.8 restart: always env_file: .env.production command: node ./streaming/index.js @@ -103,7 +103,7 @@ services: sidekiq: # 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: ghcr.io/glitch-soc/mastodon:v4.6.7 + image: ghcr.io/glitch-soc/mastodon:v4.6.8 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 4ae2e8bd04..563fc2dd2d 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 7 + 8 end def default_prerelease diff --git a/spec/mailers/email_subscription_mailer_spec.rb b/spec/mailers/email_subscription_mailer_spec.rb index 4782291145..b4f4e7e375 100644 --- a/spec/mailers/email_subscription_mailer_spec.rb +++ b/spec/mailers/email_subscription_mailer_spec.rb @@ -3,6 +3,13 @@ require 'rails_helper' RSpec.describe EmailSubscriptionMailer do + shared_examples 'an absolute privacy policy link' do + it 'links to the privacy policy with an absolute URL' do + expect(mail.html_part.body.decoded) + .to include(%(href="#{privacy_policy_url}")) + end + end + describe '.confirmation' do let(:email_subscription) { Fabricate(:email_subscription) } let(:mail) { described_class.with(subscription: email_subscription).confirmation } @@ -15,6 +22,8 @@ RSpec.describe EmailSubscriptionMailer do subject: I18n.t('email_subscription_mailer.confirmation.subject') ) end + + it_behaves_like 'an absolute privacy policy link' end describe '.notification' do @@ -33,6 +42,8 @@ RSpec.describe EmailSubscriptionMailer do subject: I18n.t('email_subscription_mailer.notification.subject.singular', name: email_subscription.account.display_name, excerpt: statuses.first.text.truncate(17)) ) end + + it_behaves_like 'an absolute privacy policy link' end context 'with multiple statuses' do diff --git a/spec/requests/api/v1/notifications/requests_spec.rb b/spec/requests/api/v1/notifications/requests_spec.rb index bee9d3a3da..596f910fe9 100644 --- a/spec/requests/api/v1/notifications/requests_spec.rb +++ b/spec/requests/api/v1/notifications/requests_spec.rb @@ -122,7 +122,9 @@ RSpec.describe 'Requests' do it_behaves_like 'forbidden for wrong scope', 'read read:notifications' it 'returns http success and destroys the notification request', :aggregate_failures do - expect { subject }.to change(NotificationRequest, :count).by(-1) + expect { subject } + .to change(NotificationRequest, :count).by(-1) + .and enqueue_sidekiq_job(FilteredNotificationCleanupWorker).with(user.account_id, be_a(Array)) expect(response).to have_http_status(200) expect(response.content_type) diff --git a/spec/services/delete_account_service_spec.rb b/spec/services/delete_account_service_spec.rb index f3eb0ca70f..291ae8035c 100644 --- a/spec/services/delete_account_service_spec.rb +++ b/spec/services/delete_account_service_spec.rb @@ -26,6 +26,7 @@ RSpec.describe DeleteAccountService do let!(:follow_notification) { Fabricate(:notification, account: local_follower, activity: active_relationship, type: :follow) } let!(:account_note) { Fabricate(:account_note, account: account) } + let!(:generated_annual_report) { Fabricate(:generated_annual_report, account: account) } it 'deletes associated owned and target records and target notifications' do subject @@ -48,6 +49,7 @@ RSpec.describe DeleteAccountService do expect { poll_vote.reload }.to raise_error(ActiveRecord::RecordNotFound) expect { account_note.reload }.to raise_error(ActiveRecord::RecordNotFound) expect { collection.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { generated_annual_report.reload }.to raise_error(ActiveRecord::RecordNotFound) end def expect_deletion_of_associated_target_records diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index 66ef93f807..052dc456a5 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -57,8 +57,8 @@ RSpec.describe PostStatusService do it 'returns existing status when used twice with idempotency key' do account = Fabricate(:account) - status1 = subject.call(account, text: 'test', idempotency: 'meepmeep', scheduled_at: future) - status2 = subject.call(account, text: 'test', idempotency: 'meepmeep', scheduled_at: future) + status1 = described_class.new.call(account, text: 'test', idempotency: 'meepmeep', scheduled_at: future) + status2 = described_class.new.call(account, text: 'test', idempotency: 'meepmeep', scheduled_at: future) expect(status2.id).to eq status1.id end @@ -341,8 +341,8 @@ RSpec.describe PostStatusService do it 'returns existing status when used twice with idempotency key' do account = Fabricate(:account) - status1 = subject.call(account, text: 'test', idempotency: 'meepmeep') - status2 = subject.call(account, text: 'test', idempotency: 'meepmeep') + status1 = described_class.new.call(account, text: 'test', idempotency: 'meepmeep') + status2 = described_class.new.call(account, text: 'test', idempotency: 'meepmeep') expect(status2.id).to eq status1.id end diff --git a/spec/workers/filtered_notification_cleanup_worker_spec.rb b/spec/workers/filtered_notification_cleanup_worker_spec.rb index 5ecd4291af..4e839c2682 100644 --- a/spec/workers/filtered_notification_cleanup_worker_spec.rb +++ b/spec/workers/filtered_notification_cleanup_worker_spec.rb @@ -20,5 +20,20 @@ RSpec.describe FilteredNotificationCleanupWorker do .to change { recipient.notifications.where(from_account: sender).count }.from(2).to(0) .and(not_change { recipient.notifications.where(from_account: bystander).count }) end + + context 'when given an array of IDs as parameter' do + let(:other_sender) { Fabricate(:account) } + + before do + Fabricate(:notification, account: recipient, activity: Fabricate(:favourite, account: other_sender), filtered: true) + end + + it 'deletes all filtered notifications to the account' do + expect { described_class.new.perform(recipient.id, [sender.id, other_sender.id]) } + .to change { recipient.notifications.where(from_account: sender).count }.from(2).to(0) + .and change { recipient.notifications.where(from_account: other_sender).count }.from(1).to(0) + .and(not_change { recipient.notifications.where(from_account: bystander).count }) + end + end end end