diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index dcf0ef5c7f2..9735004c803 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -9,7 +9,7 @@ class AdminMailer < ApplicationMailer before_action :process_params before_action :set_instance - after_action :set_important_headers!, only: :new_critical_software_updates + after_action :set_important_headers!, only: [:new_critical_software_updates, :end_of_support_out_of_support_warning] around_action :set_locale @@ -57,6 +57,24 @@ class AdminMailer < ApplicationMailer mail subject: default_i18n_subject(instance: @instance) end + def end_of_support_three_months_warning + @software_updates = SoftwareUpdate.by_version + + mail subject: default_i18n_subject(instance: @instance, branch: SoftwareDeprecation.current_branch) + end + + def end_of_support_two_weeks_warning + @software_updates = SoftwareUpdate.by_version + + mail subject: default_i18n_subject(instance: @instance, branch: SoftwareDeprecation.current_branch) + end + + def end_of_support_out_of_support_warning + @software_updates = SoftwareUpdate.by_version + + mail subject: default_i18n_subject(instance: @instance, branch: SoftwareDeprecation.current_branch) + end + def auto_close_registrations mail subject: default_i18n_subject(instance: @instance) end diff --git a/app/models/software_deprecation.rb b/app/models/software_deprecation.rb new file mode 100644 index 00000000000..b7f42ca20e2 --- /dev/null +++ b/app/models/software_deprecation.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: software_deprecations +# +# id :bigint(8) not null, primary key +# branch :string not null +# end_of_support :date not null +# warning_issued :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# +class SoftwareDeprecation < ApplicationRecord + enum :warning_issued, { + none: 0, + three_months_warning: 1, + two_weeks_warning: 2, + out_of_support_warning: 3, + }, validate: true, suffix: :issued + + def unsupported? + end_of_support.past? + end + + def self.clear_irrelevant_branches! + where.not(branch: current_branch).delete_all + end + + def self.current + find_by(branch: current_branch) + end + + def self.current_branch + Mastodon::Version.gem_version.segments[...2].join('.') + end +end diff --git a/app/models/user_settings.rb b/app/models/user_settings.rb index 1590ce4e89d..6e1537fdbce 100644 --- a/app/models/user_settings.rb +++ b/app/models/user_settings.rb @@ -54,6 +54,7 @@ class UserSettings setting :trends, default: true setting :appeal, default: true setting :software_updates, default: 'critical', in: %w(none critical patch all) + setting :end_of_support, default: true end namespace :interactions do diff --git a/app/services/software_update_check_service.rb b/app/services/software_update_check_service.rb index 353a9a6c203..c6069713006 100644 --- a/app/services/software_update_check_service.rb +++ b/app/services/software_update_check_service.rb @@ -3,9 +3,12 @@ class SoftwareUpdateCheckService < BaseService def call clean_outdated_updates! + clean_outdated_deprecations! return unless SoftwareUpdate.check_enabled? - process_update_notices!(fetch_update_notices) + json = fetch_update_notices + process_update_notices!(json&.fetch('updatesAvailable', nil)) + process_deprecation_notice!(json&.fetch('currentVersion', nil)) end private @@ -18,6 +21,10 @@ class SoftwareUpdateCheckService < BaseService end end + def clean_outdated_deprecations! + SoftwareDeprecation.clear_irrelevant_branches! + end + def fetch_update_notices Request.new(:get, "#{api_url}?version=#{version}").add_headers('Accept' => 'application/json', 'User-Agent' => 'Mastodon update checker').perform do |res| return JSON.parse(res.body_with_limit) if res.code == 200 @@ -35,19 +42,17 @@ class SoftwareUpdateCheckService < BaseService end def process_update_notices!(update_notices) - return if update_notices.blank? || update_notices['updatesAvailable'].nil? + return if update_notices.blank? # Clear notices that are not listed by the update server anymore - SoftwareUpdate.where.not(version: update_notices['updatesAvailable'].pluck('version')).delete_all - - return if update_notices['updatesAvailable'].blank? + SoftwareUpdate.where.not(version: update_notices.pluck('version')).delete_all # Check if any of the notices is new, and issue notifications - known_versions = SoftwareUpdate.where(version: update_notices['updatesAvailable'].pluck('version')).pluck(:version) - new_update_notices = update_notices['updatesAvailable'].filter { |notice| known_versions.exclude?(notice['version']) } + known_versions = SoftwareUpdate.where(version: update_notices.pluck('version')).pluck(:version) + new_update_notices = update_notices.filter { |notice| known_versions.exclude?(notice['version']) } return if new_update_notices.blank? - SoftwareUpdate.upsert_all(update_notices['updatesAvailable'].map do |notice| + SoftwareUpdate.upsert_all(update_notices.map do |notice| { version: notice['version'], urgent: notice['urgent'], type: notice['type'], release_notes: notice['releaseNotes'], end_of_support: notice['endOfSupport']&.to_date } end, unique_by: :version) @@ -56,6 +61,18 @@ class SoftwareUpdateCheckService < BaseService notify_devops!(new_updates) end + def process_deprecation_notice!(current_version) + return if current_version.blank? || current_version['endOfSupport'].blank? + + deprecation_notice = SoftwareDeprecation + .create_with(warning_issued: :none, end_of_support: current_version['endOfSupport'].to_date) + .find_or_create_by(branch: SoftwareDeprecation.current_branch) + + deprecation_notice.update(end_of_support: current_version['endOfSupport'].to_date) + + notify_of_end_of_support!(deprecation_notice) + end + def should_notify_user?(user, urgent_version, patch_version) case user.settings['notification_emails.software_updates'] when 'none' @@ -83,4 +100,30 @@ class SoftwareUpdateCheckService < BaseService end end end + + def should_notify_about_end_of_support?(user) + user.settings['notification_emails.end_of_support'] + end + + def send_notification_for_end_of_support_warning!(deprecation_notice, warning_type) + return if deprecation_notice.warning_issued == warning_type + + User.those_who_can(:view_devops).includes(:account).find_each do |user| + next unless should_notify_about_end_of_support?(user) + + AdminMailer.with(recipient: user.account).public_send("end_of_support_#{warning_type}").deliver_later + end + + deprecation_notice.update!(warning_issued: warning_type) + end + + def notify_of_end_of_support!(deprecation_notice) + if deprecation_notice.end_of_support.past? + send_notification_for_end_of_support_warning!(deprecation_notice, 'out_of_support_warning') + elsif deprecation_notice.end_of_support < 2.weeks.from_now + send_notification_for_end_of_support_warning!(deprecation_notice, 'two_weeks_warning') + elsif deprecation_notice.end_of_support < 3.months.from_now + send_notification_for_end_of_support_warning!(deprecation_notice, 'three_months_warning') + end + end end diff --git a/app/views/admin/software_updates/index.html.haml b/app/views/admin/software_updates/index.html.haml index 25de175a7be..3f117801a13 100644 --- a/app/views/admin/software_updates/index.html.haml +++ b/app/views/admin/software_updates/index.html.haml @@ -1,6 +1,12 @@ - content_for :page_title do = t('admin.software_updates.title') +- current_deprecation = SoftwareDeprecation.current +- if current_deprecation&.unsupported? + .flash-message.alert= t('admin.software_updates.unsupported_branch') +- elsif current_deprecation.present? + .flash-message.warning= t('admin.software_updates.nearing_eol', date: current_deprecation.end_of_support) + .simple_form %p.lead = t('admin.software_updates.description') diff --git a/app/views/admin_mailer/end_of_support_out_of_support_warning.text.erb b/app/views/admin_mailer/end_of_support_out_of_support_warning.text.erb new file mode 100644 index 00000000000..f2cc54bae0d --- /dev/null +++ b/app/views/admin_mailer/end_of_support_out_of_support_warning.text.erb @@ -0,0 +1,7 @@ +<%= raw t('admin_mailer.end_of_support_out_of_support_warning.body') %> + +<% @software_updates.each do |update| %> +- Mastodon <%= update.version %>: <%= update.release_notes %> +<% end %> + +<%= raw t('application_mailer.view')%> <%= admin_software_updates_url %> diff --git a/app/views/admin_mailer/end_of_support_three_months_warning.text.erb b/app/views/admin_mailer/end_of_support_three_months_warning.text.erb new file mode 100644 index 00000000000..bfa813e2912 --- /dev/null +++ b/app/views/admin_mailer/end_of_support_three_months_warning.text.erb @@ -0,0 +1,7 @@ +<%= raw t('admin_mailer.end_of_support_three_months_warning.body') %> + +<% @software_updates.each do |update| %> +- Mastodon <%= update.version %>: <%= update.release_notes %> +<% end %> + +<%= raw t('application_mailer.view')%> <%= admin_software_updates_url %> diff --git a/app/views/admin_mailer/end_of_support_two_weeks_warning.text.erb b/app/views/admin_mailer/end_of_support_two_weeks_warning.text.erb new file mode 100644 index 00000000000..6c95b018c75 --- /dev/null +++ b/app/views/admin_mailer/end_of_support_two_weeks_warning.text.erb @@ -0,0 +1,7 @@ +<%= raw t('admin_mailer.end_of_support_two_weeks_warning.body') %> + +<% @software_updates.each do |update| %> +- Mastodon <%= update.version %>: <%= update.release_notes %> +<% end %> + +<%= raw t('application_mailer.view')%> <%= admin_software_updates_url %> diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml index fb206b13081..ac5d3335197 100644 --- a/app/views/settings/preferences/notifications/show.html.haml +++ b/app/views/settings/preferences/notifications/show.html.haml @@ -23,8 +23,8 @@ .fields-group = ff.input :always_send_emails, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_always_send_emails'), hint: I18n.t('simple_form.hints.defaults.setting_always_send_emails') - - if current_user.can?(:manage_reports, :manage_appeals, :manage_users, :manage_taxonomies) || (SoftwareUpdate.check_enabled? && current_user.can?(:view_devops)) - %h2= t 'notifications.administration_emails' + - if current_user.can?(:manage_reports, :manage_appeals, :manage_users, :manage_taxonomies) + %h2= t 'notifications.moderation_emails' .fields-group = ff.input :'notification_emails.report', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.report') if current_user.can?(:manage_reports) @@ -32,13 +32,21 @@ = ff.input :'notification_emails.pending_account', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.pending_account') if current_user.can?(:manage_users) = ff.input :'notification_emails.trends', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.trending_tag') if current_user.can?(:manage_taxonomies) - - if SoftwareUpdate.check_enabled? && current_user.can?(:view_devops) - .fields-group - = ff.input :'notification_emails.software_updates', - collection: user_settings_collection('notification_emails.software_updates'), - hint: false, - include_blank: false, - label_method: ->(setting) { I18n.t("simple_form.labels.notification_emails.software_updates.#{setting}") }, - label: I18n.t('simple_form.labels.notification_emails.software_updates.label'), - wrapper: :with_label, - required: false + - if SoftwareUpdate.check_enabled? && current_user.can?(:view_devops) + %h2= t 'notifications.administration_emails' + + .fields-group + = ff.input :'notification_emails.end_of_support', + wrapper: :with_label, + label: I18n.t('simple_form.labels.notification_emails.end_of_support'), + recommended: true + + .fields-group + = ff.input :'notification_emails.software_updates', + collection: user_settings_collection('notification_emails.software_updates'), + hint: false, + include_blank: false, + label_method: ->(setting) { I18n.t("simple_form.labels.notification_emails.software_updates.#{setting}") }, + label: I18n.t('simple_form.labels.notification_emails.software_updates.label'), + wrapper: :with_label, + required: false diff --git a/config/locales/en.yml b/config/locales/en.yml index 410e54980a3..8b3aca6b68e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1013,6 +1013,7 @@ en: critical_update: Critical — please update quickly description: It is recommended to keep your Mastodon installation up to date to benefit from the latest fixes and features. Moreover, it is sometimes critical to update Mastodon in a timely manner to avoid security issues. For these reasons, Mastodon checks for updates every 30 minutes, and will notify you according to your email notification preferences. documentation_link: Learn more + nearing_eol: Support for the branch you are currently running will stop on %{date}, please consider updating. release_notes: Release notes supported_until: Support ends on %{date} title: Available updates @@ -1021,6 +1022,7 @@ en: major: Major release minor: Minor release patch: Patch release — bugfixes and easy to apply changes + unsupported_branch: The branch you are currently running is unsupported, please update as soon as possible. unsupported_since: Unsupported since %{date} version: Version statuses: @@ -1287,6 +1289,15 @@ en: auto_close_registrations: body: Due to a lack of recent moderator activity, registrations on %{instance} have been automatically switched to requiring manual review, to prevent %{instance} from being used as a platform for potential bad actors. You can switch it back to open registrations at any time. subject: Registrations for %{instance} have been automatically switched to requiring approval + end_of_support_out_of_support_warning: + body: The Mastodon version currently in use on your server is not supported anymore. We recommend you to update as soon as possible! + subject: Mastodon %{branch} used on %{instance} is now unsupported! + end_of_support_three_months_warning: + body: The Mastodon version currently in use on your server will stop being supported in 3 months. You may want to update to a newer branch ahead of time. + subject: Support for Mastodon %{branch} used on %{instance} to end in 3 months. + end_of_support_two_weeks_warning: + body: The Mastodon version currently in use on your server will stop being supported in 2 weeks. We recommend you update to a newer branch ahead of time. + subject: Support for Mastodon %{branch} used on %{instance} to end in 2 weeks. new_appeal: actions: delete_statuses: to delete their posts @@ -1922,6 +1933,7 @@ en: administration_emails: Admin email notifications email_events: Events for email notifications email_events_hint: 'Select events that you want to receive notifications for:' + moderation_emails: Moderation email notifications number: human: decimal_units: diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 4c9a3bce617..82c4f42bcf8 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -345,6 +345,7 @@ en: notification_emails: appeal: Someone appeals a moderator decision digest: Send digest e-mails + end_of_support: The current Mastodon version is nearing end of support favourite: Someone favourited your post follow: Someone followed you follow_request: Someone requested to follow you diff --git a/config/navigation.rb b/config/navigation.rb index dbc542bfbc6..361e06b3a4b 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -11,7 +11,7 @@ SimpleNavigation::Configuration.run do |navigation| SoftwareUpdate.urgent_pending? ? [material_symbol('report'), t('admin.critical_update_pending')] : [material_symbol('system_update_alt'), t('admin.update_available')] ), admin_software_updates_path, - html: { class: SoftwareUpdate.urgent_pending? ? 'warning' : nil }, + html: { class: SoftwareUpdate.urgent_pending? || SoftwareDeprecation.current&.unsupported? ? 'warning' : nil }, if: -> { Rails.configuration.x.mastodon.software_update_url.present? && current_user.can?(:view_devops) && SoftwareUpdate.pending? } n.item :profile, safe_join([material_symbol('person'), t('settings.profile')]), settings_profile_path, if: -> { current_user.functional? && !self_destruct }, highlights_on: %r{/settings/profile|/settings/featured_tags|/settings/verification} diff --git a/db/migrate/20260706143100_create_software_deprecations.rb b/db/migrate/20260706143100_create_software_deprecations.rb new file mode 100644 index 00000000000..d5b0780057d --- /dev/null +++ b/db/migrate/20260706143100_create_software_deprecations.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateSoftwareDeprecations < ActiveRecord::Migration[8.1] + def change + create_table :software_deprecations do |t| + t.string :branch, null: false + t.date :end_of_support, null: false + t.integer :warning_issued, null: false + + t.timestamps + end + + add_index :software_deprecations, :branch, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 1b24c81b3fe..ecf113318dc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_06_122112) do +ActiveRecord::Schema[8.1].define(version: 2026_07_06_143100) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -1157,6 +1157,15 @@ ActiveRecord::Schema[8.1].define(version: 2026_07_06_122112) do t.index ["var"], name: "index_site_uploads_on_var", unique: true end + create_table "software_deprecations", force: :cascade do |t| + t.string "branch", null: false + t.datetime "created_at", null: false + t.date "end_of_support", null: false + t.datetime "updated_at", null: false + t.integer "warning_issued", null: false + t.index ["branch"], name: "index_software_deprecations_on_branch", unique: true + end + create_table "software_updates", force: :cascade do |t| t.datetime "created_at", null: false t.date "end_of_support" diff --git a/spec/services/software_update_check_service_spec.rb b/spec/services/software_update_check_service_spec.rb index ea72c52734a..d0f205e8e05 100644 --- a/spec/services/software_update_check_service_spec.rb +++ b/spec/services/software_update_check_service_spec.rb @@ -66,10 +66,12 @@ RSpec.describe SoftwareUpdateCheckService do end context 'when the server returns new versions' do + let(:deprecation_date) { nil } + let(:server_json) do { currentVersion: { - endOfSupport: nil, + endOfSupport: deprecation_date&.iso8601, }, updatesAvailable: [ { @@ -104,6 +106,91 @@ RSpec.describe SoftwareUpdateCheckService do expect { subject.call }.to change { SoftwareUpdate.pluck(:version).sort }.from(['3.5.0', '42.13.12', 'Malformed']).to(['4.2.1', '4.3.0', '5.0.0']) end + context 'when server returns deprecation in the distant future' do + let(:deprecation_date) { 5.years.from_now.to_date } + + it 'updates the software deprecation info' do + expect { subject.call } + .to change { SoftwareDeprecation.pluck(:end_of_support, :warning_issued) }.from([]).to([[deprecation_date, 'none']]) + end + + context 'when an irrelevant deprecation was stored' do + before do + SoftwareDeprecation.create!(branch: '4.3', end_of_support: '2026-05-06'.to_date, warning_issued: :out_of_support_warning) + end + + it 'updates the software deprecation info' do + expect { subject.call } + .to change { SoftwareDeprecation.pluck(:end_of_support, :warning_issued) }.from([['2026-05-06'.to_date, 'out_of_support_warning']]).to([[deprecation_date, 'none']]) + end + end + end + + context 'when server returns deprecation in 2 months' do + let(:deprecation_date) { 2.months.from_now.to_date } + + it 'updates the software deprecation info' do + expect { subject.call } + .to change { SoftwareDeprecation.pluck(:end_of_support, :warning_issued) }.from([]).to([[deprecation_date, 'three_months_warning']]) + end + + context 'when an irrelevant deprecation was stored' do + before do + SoftwareDeprecation.create!(branch: '4.3', end_of_support: '2026-05-06'.to_date, warning_issued: :out_of_support_warning) + end + + it 'updates the software deprecation info and sends email' do + expect { subject.call } + .to change { SoftwareDeprecation.pluck(:end_of_support, :warning_issued) }.from([['2026-05-06'.to_date, 'out_of_support_warning']]).to([[deprecation_date, 'three_months_warning']]) + .and(have_enqueued_mail(AdminMailer, :end_of_support_three_months_warning).with(hash_including(params: { recipient: owner_user.account })).once) + end + end + end + + context 'when server returns deprecation in 1 week' do + let(:deprecation_date) { 1.week.from_now.to_date } + + it 'updates the software deprecation info' do + expect { subject.call } + .to change { SoftwareDeprecation.pluck(:end_of_support, :warning_issued) }.from([]).to([[deprecation_date, 'two_weeks_warning']]) + .and(have_enqueued_mail(AdminMailer, :end_of_support_two_weeks_warning).with(hash_including(params: { recipient: owner_user.account })).once) + end + + context 'when an irrelevant deprecation was stored' do + before do + SoftwareDeprecation.create!(branch: '4.3', end_of_support: '2026-05-06'.to_date, warning_issued: :out_of_support_warning) + end + + it 'updates the software deprecation info and sends email' do + expect { subject.call } + .to change { SoftwareDeprecation.pluck(:end_of_support, :warning_issued) }.from([['2026-05-06'.to_date, 'out_of_support_warning']]).to([[deprecation_date, 'two_weeks_warning']]) + .and(have_enqueued_mail(AdminMailer, :end_of_support_two_weeks_warning).with(hash_including(params: { recipient: owner_user.account })).once) + end + end + end + + context 'when server returns deprecation in the past' do + let(:deprecation_date) { 1.week.ago.to_date } + + it 'updates the software deprecation info' do + expect { subject.call } + .to change { SoftwareDeprecation.pluck(:end_of_support, :warning_issued) }.from([]).to([[deprecation_date, 'out_of_support_warning']]) + .and(have_enqueued_mail(AdminMailer, :end_of_support_out_of_support_warning).with(hash_including(params: { recipient: owner_user.account })).once) + end + + context 'when an irrelevant deprecation was stored' do + before do + SoftwareDeprecation.create!(branch: '4.3', end_of_support: '2026-05-06'.to_date, warning_issued: :out_of_support_warning) + end + + it 'updates the software deprecation info and sends email' do + expect { subject.call } + .to change { SoftwareDeprecation.pluck(:end_of_support, :warning_issued) }.from([['2026-05-06'.to_date, 'out_of_support_warning']]).to([[deprecation_date, 'out_of_support_warning']]) + .and(have_enqueued_mail(AdminMailer, :end_of_support_out_of_support_warning).with(hash_including(params: { recipient: owner_user.account })).once) + end + end + end + context 'when no update is urgent' do it 'sends e-mail notifications according to settings', :aggregate_failures do expect { subject.call }.to have_enqueued_mail(AdminMailer, :new_software_updates)