diff --git a/app/controllers/admin/software_updates_controller.rb b/app/controllers/admin/software_updates_controller.rb new file mode 100644 index 000000000..52d8cb41e --- /dev/null +++ b/app/controllers/admin/software_updates_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Admin + class SoftwareUpdatesController < BaseController + before_action :check_enabled! + + def index + authorize :software_update, :index? + @software_updates = SoftwareUpdate.all.sort_by(&:gem_version) + end + + private + + def check_enabled! + not_found unless SoftwareUpdate.check_enabled? + end + end +end diff --git a/app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx b/app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx new file mode 100644 index 000000000..d0dd2b6ac --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx @@ -0,0 +1,26 @@ +import { FormattedMessage } from 'react-intl'; + +export const CriticalUpdateBanner = () => ( +
+
+

+ +

+

+ {' '} + + + +

+
+
+); diff --git a/app/javascript/mastodon/features/home_timeline/index.jsx b/app/javascript/mastodon/features/home_timeline/index.jsx index 1cd6edd7a..8ff037794 100644 --- a/app/javascript/mastodon/features/home_timeline/index.jsx +++ b/app/javascript/mastodon/features/home_timeline/index.jsx @@ -14,7 +14,7 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/an import { IconWithBadge } from 'mastodon/components/icon_with_badge'; import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container'; -import { me } from 'mastodon/initial_state'; +import { me, criticalUpdatesPending } from 'mastodon/initial_state'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { expandHomeTimeline } from '../../actions/timelines'; @@ -23,6 +23,7 @@ import ColumnHeader from '../../components/column_header'; import StatusListContainer from '../ui/containers/status_list_container'; import { ColumnSettings } from './components/column_settings'; +import { CriticalUpdateBanner } from './components/critical_update_banner'; import { ExplorePrompt } from './components/explore_prompt'; const messages = defineMessages({ @@ -156,8 +157,9 @@ class HomeTimeline extends PureComponent { const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props; const pinned = !!columnId; const { signedIn } = this.context.identity; + const banners = []; - let announcementsButton, banner; + let announcementsButton; if (hasAnnouncements) { announcementsButton = ( @@ -173,8 +175,12 @@ class HomeTimeline extends PureComponent { ); } + if (criticalUpdatesPending) { + banners.push(); + } + if (tooSlow) { - banner = ; + banners.push(); } return ( @@ -196,7 +202,7 @@ class HomeTimeline extends PureComponent { {signedIn ? ( } accounts * @property {InitialStateLanguage[]} languages + * @property {boolean=} critical_updates_pending * @property {InitialStateMeta} meta */ @@ -140,6 +141,7 @@ export const useBlurhash = getMeta('use_blurhash'); export const usePendingItems = getMeta('use_pending_items'); export const version = getMeta('version'); export const languages = initialState?.languages; +export const criticalUpdatesPending = initialState?.critical_updates_pending; // @ts-expect-error export const statusPageUrl = getMeta('status_page_url'); export const sso_redirect = getMeta('sso_redirect'); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 90bb9616f..13cddba72 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -310,6 +310,9 @@ "home.explore_prompt.body": "Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. If that feels too quiet, you may want to:", "home.explore_prompt.title": "This is your home base within Mastodon.", "home.hide_announcements": "Hide announcements", + "home.pending_critical_update.body": "Please update your Mastodon server as soon as possible!", + "home.pending_critical_update.link": "See updates", + "home.pending_critical_update.title": "Critical security update available!", "home.show_announcements": "Show announcements", "interaction_modal.description.favourite": "With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.", "interaction_modal.description.follow": "With an account on Mastodon, you can follow {name} to receive their posts in your home feed.", diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index bbb6ffdff..a65f35e7b 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -143,6 +143,11 @@ $content-width: 840px; } } + .warning a { + color: $gold-star; + font-weight: 700; + } + .simple-navigation-active-leaf a { color: $primary-text-color; background-color: $ui-highlight-color; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index f61cd059f..10083a2a3 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -8860,7 +8860,8 @@ noscript { } } -.dismissable-banner { +.dismissable-banner, +.warning-banner { position: relative; margin: 10px; margin-bottom: 5px; @@ -8938,6 +8939,21 @@ noscript { } } +.warning-banner { + border: 1px solid $warning-red; + background: rgba($warning-red, 0.15); + + &__message { + h1 { + color: $warning-red; + } + + a { + color: $primary-text-color; + } + } +} + .image { position: relative; overflow: hidden; diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss index 38cfc8727..dd5b483ec 100644 --- a/app/javascript/styles/mastodon/tables.scss +++ b/app/javascript/styles/mastodon/tables.scss @@ -12,6 +12,11 @@ border-top: 1px solid $ui-base-color; text-align: start; background: darken($ui-base-color, 4%); + + &.critical { + font-weight: 700; + color: $gold-star; + } } & > thead > tr > th { diff --git a/app/lib/admin/system_check.rb b/app/lib/admin/system_check.rb index 89dfcef9f..25c88341a 100644 --- a/app/lib/admin/system_check.rb +++ b/app/lib/admin/system_check.rb @@ -2,6 +2,7 @@ class Admin::SystemCheck ACTIVE_CHECKS = [ + Admin::SystemCheck::SoftwareVersionCheck, Admin::SystemCheck::MediaPrivacyCheck, Admin::SystemCheck::DatabaseSchemaCheck, Admin::SystemCheck::SidekiqProcessCheck, diff --git a/app/lib/admin/system_check/software_version_check.rb b/app/lib/admin/system_check/software_version_check.rb new file mode 100644 index 000000000..e142feddf --- /dev/null +++ b/app/lib/admin/system_check/software_version_check.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Admin::SystemCheck::SoftwareVersionCheck < Admin::SystemCheck::BaseCheck + include RoutingHelper + + def skip? + !current_user.can?(:view_devops) || !SoftwareUpdate.check_enabled? + end + + def pass? + software_updates.empty? + end + + def message + if software_updates.any?(&:urgent?) + Admin::SystemCheck::Message.new(:software_version_critical_check, nil, admin_software_updates_path, true) + else + Admin::SystemCheck::Message.new(:software_version_patch_check, nil, admin_software_updates_path) + end + end + + private + + def software_updates + @software_updates ||= SoftwareUpdate.pending_to_a.filter { |update| update.urgent? || update.patch_type? } + end +end diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index 5baf9b38a..990b92c33 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -45,6 +45,22 @@ class AdminMailer < ApplicationMailer end end + def new_software_updates + locale_for_account(@me) do + mail subject: default_i18n_subject(instance: @instance) + end + end + + def new_critical_software_updates + headers['Priority'] = 'urgent' + headers['X-Priority'] = '1' + headers['Importance'] = 'high' + + locale_for_account(@me) do + mail subject: default_i18n_subject(instance: @instance) + end + end + private def process_params diff --git a/app/models/software_update.rb b/app/models/software_update.rb new file mode 100644 index 000000000..cb3a6df2a --- /dev/null +++ b/app/models/software_update.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: software_updates +# +# id :bigint(8) not null, primary key +# version :string not null +# urgent :boolean default(FALSE), not null +# type :integer default("patch"), not null +# release_notes :string default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class SoftwareUpdate < ApplicationRecord + self.inheritance_column = nil + + enum type: { patch: 0, minor: 1, major: 2 }, _suffix: :type + + def gem_version + Gem::Version.new(version) + end + + class << self + def check_enabled? + ENV['UPDATE_CHECK_URL'] != '' + end + + def pending_to_a + return [] unless check_enabled? + + all.to_a.filter { |update| update.gem_version > Mastodon::Version.gem_version } + end + + def urgent_pending? + pending_to_a.any?(&:urgent?) + end + end +end diff --git a/app/models/user_settings.rb b/app/models/user_settings.rb index 678467c75..030cbec4d 100644 --- a/app/models/user_settings.rb +++ b/app/models/user_settings.rb @@ -44,6 +44,7 @@ class UserSettings setting :pending_account, default: true setting :trends, default: true setting :appeal, default: true + setting :software_updates, default: 'critical', in: %w(none critical patch all) end namespace :interactions do diff --git a/app/policies/software_update_policy.rb b/app/policies/software_update_policy.rb new file mode 100644 index 000000000..dcb565814 --- /dev/null +++ b/app/policies/software_update_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class SoftwareUpdatePolicy < ApplicationPolicy + def index? + role.can?(:view_devops) + end +end diff --git a/app/presenters/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb index b87cff51e..222cc8566 100644 --- a/app/presenters/initial_state_presenter.rb +++ b/app/presenters/initial_state_presenter.rb @@ -3,9 +3,13 @@ class InitialStatePresenter < ActiveModelSerializers::Model attributes :settings, :push_subscription, :token, :current_account, :admin, :owner, :text, :visibility, - :disabled_account, :moved_to_account + :disabled_account, :moved_to_account, :critical_updates_pending def role current_account&.user_role end + + def critical_updates_pending + role&.can?(:view_devops) && SoftwareUpdate.urgent_pending? + end end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 9660c941d..56d45c588 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -7,6 +7,8 @@ class InitialStateSerializer < ActiveModel::Serializer :media_attachments, :settings, :languages + attribute :critical_updates_pending, if: -> { object&.role&.can?(:view_devops) && SoftwareUpdate.check_enabled? } + has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer has_one :role, serializer: REST::RoleSerializer diff --git a/app/services/software_update_check_service.rb b/app/services/software_update_check_service.rb new file mode 100644 index 000000000..49b92f104 --- /dev/null +++ b/app/services/software_update_check_service.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +class SoftwareUpdateCheckService < BaseService + def call + clean_outdated_updates! + return unless SoftwareUpdate.check_enabled? + + process_update_notices!(fetch_update_notices) + end + + private + + def clean_outdated_updates! + SoftwareUpdate.find_each do |software_update| + software_update.delete if Mastodon::Version.gem_version >= software_update.gem_version + rescue ArgumentError + software_update.delete + end + 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 Oj.load(res.body_with_limit, mode: :strict) if res.code == 200 + end + rescue HTTP::Error, OpenSSL::SSL::SSLError, Oj::ParseError + nil + end + + def api_url + ENV.fetch('UPDATE_CHECK_URL', 'https://api.joinmastodon.org/update-check') + end + + def version + @version ||= Mastodon::Version.to_s.split('+')[0] + end + + def process_update_notices!(update_notices) + return if update_notices.blank? || update_notices['updatesAvailable'].blank? + + # Clear notices that are not listed by the update server anymore + SoftwareUpdate.where.not(version: update_notices['updatesAvailable'].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']) } + return if new_update_notices.blank? + + new_updates = new_update_notices.map do |notice| + SoftwareUpdate.create!(version: notice['version'], urgent: notice['urgent'], type: notice['type'], release_notes: notice['releaseNotes']) + end + + notify_devops!(new_updates) + end + + def should_notify_user?(user, urgent_version, patch_version) + case user.settings['notification_emails.software_updates'] + when 'none' + false + when 'critical' + urgent_version + when 'patch' + urgent_version || patch_version + when 'all' + true + end + end + + def notify_devops!(new_updates) + has_new_urgent_version = new_updates.any?(&:urgent?) + has_new_patch_version = new_updates.any?(&:patch_type?) + + User.those_who_can(:view_devops).includes(:account).find_each do |user| + next unless should_notify_user?(user, has_new_urgent_version, has_new_patch_version) + + if has_new_urgent_version + AdminMailer.with(recipient: user.account).new_critical_software_updates.deliver_later + else + AdminMailer.with(recipient: user.account).new_software_updates.deliver_later + end + end + end +end diff --git a/app/views/admin/software_updates/index.html.haml b/app/views/admin/software_updates/index.html.haml new file mode 100644 index 000000000..7a223ee07 --- /dev/null +++ b/app/views/admin/software_updates/index.html.haml @@ -0,0 +1,29 @@ +- content_for :page_title do + = t('admin.software_updates.title') + +.simple_form + %p.lead + = t('admin.software_updates.description') + = link_to t('admin.software_updates.documentation_link'), 'https://docs.joinmastodon.org/admin/upgrading/#automated_checks', target: '_new' + +%hr.spacer + +- unless @software_updates.empty? + .table-wrapper + %table.table + %thead + %tr + %th= t('admin.software_updates.version') + %th= t('admin.software_updates.type') + %th + %th + %tbody + - @software_updates.each do |update| + %tr + %td= update.version + %td= t("admin.software_updates.types.#{update.type}") + - if update.urgent? + %td.critical= t("admin.software_updates.critical_update") + - else + %td + %td= table_link_to 'link', t('admin.software_updates.release_notes'), update.release_notes diff --git a/app/views/admin_mailer/new_critical_software_updates.text.erb b/app/views/admin_mailer/new_critical_software_updates.text.erb new file mode 100644 index 000000000..c901bc50f --- /dev/null +++ b/app/views/admin_mailer/new_critical_software_updates.text.erb @@ -0,0 +1,5 @@ +<%= raw t('application_mailer.salutation', name: display_name(@me)) %> + +<%= raw t('admin_mailer.new_critical_software_updates.body') %> + +<%= raw t('application_mailer.view')%> <%= admin_software_updates_url %> diff --git a/app/views/admin_mailer/new_software_updates.text.erb b/app/views/admin_mailer/new_software_updates.text.erb new file mode 100644 index 000000000..2fc4d1a5f --- /dev/null +++ b/app/views/admin_mailer/new_software_updates.text.erb @@ -0,0 +1,5 @@ +<%= raw t('application_mailer.salutation', name: display_name(@me)) %> + +<%= raw t('admin_mailer.new_software_updates.body') %> + +<%= 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 0913bda9a..5cc101069 100644 --- a/app/views/settings/preferences/notifications/show.html.haml +++ b/app/views/settings/preferences/notifications/show.html.haml @@ -22,7 +22,7 @@ .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) + - if current_user.can?(:manage_reports, :manage_appeals, :manage_users, :manage_taxonomies) || (SoftwareUpdate.check_enabled? && current_user.can?(:view_devops)) %h4= t 'notifications.administration_emails' .fields-group @@ -31,6 +31,10 @@ = 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', wrapper: :with_label, label: I18n.t('simple_form.labels.notification_emails.software_updates.label'), collection: %w(none critical patch all), label_method: ->(setting) { I18n.t("simple_form.labels.notification_emails.software_updates.#{setting}") }, include_blank: false, hint: false + %h4= t 'notifications.other_settings' .fields-group diff --git a/app/workers/scheduler/software_update_check_scheduler.rb b/app/workers/scheduler/software_update_check_scheduler.rb new file mode 100644 index 000000000..c732bdedc --- /dev/null +++ b/app/workers/scheduler/software_update_check_scheduler.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Scheduler::SoftwareUpdateCheckScheduler + include Sidekiq::Worker + + sidekiq_options retry: 0, lock: :until_executed, lock_ttl: 1.hour.to_i + + def perform + SoftwareUpdateCheckService.new.call + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 693155d6e..71e5fb843 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -309,6 +309,7 @@ en: unpublish: Unpublish unpublished_msg: Announcement successfully unpublished! updated_msg: Announcement successfully updated! + critical_update_pending: Critical update pending custom_emojis: assign_category: Assign category by_domain: Domain @@ -779,6 +780,18 @@ en: site_uploads: delete: Delete uploaded file destroyed_msg: Site upload successfully deleted! + software_updates: + 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 e-mail notification preferences. + documentation_link: Learn more + release_notes: Release notes + title: Available updates + type: Type + types: + major: Major release + minor: Minor release + patch: Patch release — bugfixes and easy to apply changes + version: Version statuses: account: Author application: Application @@ -843,6 +856,12 @@ en: message_html: You haven't defined any server rules. sidekiq_process_check: message_html: No Sidekiq process running for the %{value} queue(s). Please review your Sidekiq configuration + software_version_critical_check: + action: See available updates + message_html: A critical Mastodon update is available, please update as quickly as possible. + software_version_patch_check: + action: See available updates + message_html: A bugfix Mastodon update is available. upload_check_privacy_error: action: Check here for more information message_html: "Your web server is misconfigured. The privacy of your users is at risk." @@ -956,6 +975,9 @@ en: body: "%{target} is appealing a moderation decision by %{action_taken_by} from %{date}, which was %{type}. They wrote:" next_steps: You can approve the appeal to undo the moderation decision, or ignore it. subject: "%{username} is appealing a moderation decision on %{instance}" + new_critical_software_updates: + body: New critical versions of Mastodon have been released, you may want to update as soon as possible! + subject: Critical Mastodon updates are available for %{instance}! new_pending_account: body: The details of the new account are below. You can approve or reject this application. subject: New account up for review on %{instance} (%{username}) @@ -963,6 +985,9 @@ en: body: "%{reporter} has reported %{target}" body_remote: Someone from %{domain} has reported %{target} subject: New report for %{instance} (#%{id}) + new_software_updates: + body: New Mastodon versions have been released, you may want to update! + subject: New Mastodon versions are available for %{instance}! new_trends: body: 'The following items need a review before they can be displayed publicly:' new_trending_links: diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index b1297606b..0b718c5b6 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -291,6 +291,12 @@ en: pending_account: New account needs review reblog: Someone boosted your post report: New report is submitted + software_updates: + all: Notify on all updates + critical: Notify on critical updates only + label: A new Mastodon version is available + none: Never notify of updates (not recommended) + patch: Notify on bugfix updates trending_tag: New trend requires review rule: text: Rule diff --git a/config/navigation.rb b/config/navigation.rb index f608c2eea..e86c695a9 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -3,6 +3,9 @@ SimpleNavigation::Configuration.run do |navigation| navigation.items do |n| n.item :web, safe_join([fa_icon('chevron-left fw'), t('settings.back')]), root_path + + n.item :software_updates, safe_join([fa_icon('exclamation-circle fw'), t('admin.critical_update_pending')]), admin_software_updates_path, if: -> { ENV['UPDATE_CHECK_URL'] != '' && current_user.can?(:view_devops) && SoftwareUpdate.urgent_pending? }, html: { class: 'warning' } + n.item :profile, safe_join([fa_icon('user fw'), t('settings.profile')]), settings_profile_path, if: -> { current_user.functional? }, highlights_on: %r{/settings/profile|/settings/featured_tags|/settings/verification|/settings/privacy} n.item :preferences, safe_join([fa_icon('cog fw'), t('settings.preferences')]), settings_preferences_path, if: -> { current_user.functional? } do |s| diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 4573878ed..207cb0580 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -201,4 +201,6 @@ namespace :admin do end end end + + resources :software_updates, only: [:index] end diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 12c45c22a..f1ba5651d 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -58,3 +58,7 @@ interval: 1 minute class: Scheduler::SuspendedUserCleanupScheduler queue: scheduler + software_update_check_scheduler: + interval: 30 minutes + class: Scheduler::SoftwareUpdateCheckScheduler + queue: scheduler diff --git a/db/migrate/20230822081029_create_software_updates.rb b/db/migrate/20230822081029_create_software_updates.rb new file mode 100644 index 000000000..146d5d303 --- /dev/null +++ b/db/migrate/20230822081029_create_software_updates.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateSoftwareUpdates < ActiveRecord::Migration[7.0] + def change + create_table :software_updates do |t| + t.string :version, null: false + t.boolean :urgent, default: false, null: false + t.integer :type, default: 0, null: false + t.string :release_notes, default: '', null: false + + t.timestamps + end + + add_index :software_updates, :version, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 8b758fc7d..c86106942 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[7.0].define(version: 2023_08_18_142253) do +ActiveRecord::Schema[7.0].define(version: 2023_08_22_081029) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -903,6 +903,16 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_18_142253) do t.index ["var"], name: "index_site_uploads_on_var", unique: true end + create_table "software_updates", force: :cascade do |t| + t.string "version", null: false + t.boolean "urgent", default: false, null: false + t.integer "type", default: 0, null: false + t.string "release_notes", default: "", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["version"], name: "index_software_updates_on_version", unique: true + end + create_table "status_edits", force: :cascade do |t| t.bigint "status_id", null: false t.bigint "account_id" diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index c542d5d49..65f90f93f 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -39,6 +39,10 @@ module Mastodon components.join end + def gem_version + @gem_version ||= Gem::Version.new(to_s.split('+')[0]) + end + def repository ENV.fetch('GITHUB_REPOSITORY', 'mastodon/mastodon') end diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake index 010caaf8e..f68d1cf1f 100644 --- a/lib/tasks/mastodon.rake +++ b/lib/tasks/mastodon.rake @@ -424,6 +424,10 @@ namespace :mastodon do end end + prompt.say "\n" + + env['UPDATE_CHECK_URL'] = '' unless prompt.yes?('Do you want Mastodon to periodically check for important updates and notify you? (Recommended)', default: true) + prompt.say "\n" prompt.say 'This configuration will be written to .env.production' diff --git a/spec/fabricators/software_update_fabricator.rb b/spec/fabricators/software_update_fabricator.rb new file mode 100644 index 000000000..622fff66e --- /dev/null +++ b/spec/fabricators/software_update_fabricator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Fabricator(:software_update) do + version '99.99.99' + urgent false + type 'patch' +end diff --git a/spec/features/admin/software_updates_spec.rb b/spec/features/admin/software_updates_spec.rb new file mode 100644 index 000000000..4a635d1a7 --- /dev/null +++ b/spec/features/admin/software_updates_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'finding software updates through the admin interface' do + before do + Fabricate(:software_update, version: '99.99.99', type: 'major', urgent: true, release_notes: 'https://github.com/mastodon/mastodon/releases/v99') + + sign_in Fabricate(:user, role: UserRole.find_by(name: 'Owner')), scope: :user + end + + it 'shows a link to the software updates page, which links to release notes' do + visit settings_profile_path + click_on I18n.t('admin.critical_update_pending') + + expect(page).to have_title(I18n.t('admin.software_updates.title')) + + expect(page).to have_content('99.99.99') + + click_on I18n.t('admin.software_updates.release_notes') + expect(page).to have_current_path('https://github.com/mastodon/mastodon/releases/v99', url: true) + end +end diff --git a/spec/lib/admin/system_check/software_version_check_spec.rb b/spec/lib/admin/system_check/software_version_check_spec.rb new file mode 100644 index 000000000..de4335fc5 --- /dev/null +++ b/spec/lib/admin/system_check/software_version_check_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::SystemCheck::SoftwareVersionCheck do + include RoutingHelper + + subject(:check) { described_class.new(user) } + + let(:user) { Fabricate(:user) } + + describe 'skip?' do + context 'when user cannot view devops' do + before { allow(user).to receive(:can?).with(:view_devops).and_return(false) } + + it 'returns true' do + expect(check.skip?).to be true + end + end + + context 'when user can view devops' do + before { allow(user).to receive(:can?).with(:view_devops).and_return(true) } + + it 'returns false' do + expect(check.skip?).to be false + end + + context 'when checks are disabled' do + around do |example| + ClimateControl.modify UPDATE_CHECK_URL: '' do + example.run + end + end + + it 'returns true' do + expect(check.skip?).to be true + end + end + end + end + + describe 'pass?' do + context 'when there is no known update' do + it 'returns true' do + expect(check.pass?).to be true + end + end + + context 'when there is a non-urgent major release' do + before do + Fabricate(:software_update, version: '99.99.99', type: 'major', urgent: false) + end + + it 'returns true' do + expect(check.pass?).to be true + end + end + + context 'when there is an urgent major release' do + before do + Fabricate(:software_update, version: '99.99.99', type: 'major', urgent: true) + end + + it 'returns false' do + expect(check.pass?).to be false + end + end + + context 'when there is an urgent minor release' do + before do + Fabricate(:software_update, version: '99.99.99', type: 'minor', urgent: true) + end + + it 'returns false' do + expect(check.pass?).to be false + end + end + + context 'when there is an urgent patch release' do + before do + Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: true) + end + + it 'returns false' do + expect(check.pass?).to be false + end + end + + context 'when there is a non-urgent patch release' do + before do + Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: false) + end + + it 'returns false' do + expect(check.pass?).to be false + end + end + end + + describe 'message' do + context 'when there is a non-urgent patch release pending' do + before do + Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: false) + end + + it 'sends class name symbol to message instance' do + allow(Admin::SystemCheck::Message).to receive(:new) + .with(:software_version_patch_check, anything, anything) + + check.message + + expect(Admin::SystemCheck::Message).to have_received(:new) + .with(:software_version_patch_check, nil, admin_software_updates_path) + end + end + + context 'when there is an urgent patch release pending' do + before do + Fabricate(:software_update, version: '99.99.99', type: 'patch', urgent: true) + end + + it 'sends class name symbol to message instance' do + allow(Admin::SystemCheck::Message).to receive(:new) + .with(:software_version_critical_check, anything, anything, anything) + + check.message + + expect(Admin::SystemCheck::Message).to have_received(:new) + .with(:software_version_critical_check, nil, admin_software_updates_path, true) + end + end + end +end diff --git a/spec/mailers/admin_mailer_spec.rb b/spec/mailers/admin_mailer_spec.rb index 9123804a4..423dce88a 100644 --- a/spec/mailers/admin_mailer_spec.rb +++ b/spec/mailers/admin_mailer_spec.rb @@ -85,4 +85,46 @@ RSpec.describe AdminMailer do expect(mail.body.encoded).to match 'The following items need a review before they can be displayed publicly' end end + + describe '.new_software_updates' do + let(:recipient) { Fabricate(:account, username: 'Bob') } + let(:mail) { described_class.with(recipient: recipient).new_software_updates } + + before do + recipient.user.update(locale: :en) + end + + it 'renders the headers' do + expect(mail.subject).to eq('New Mastodon versions are available for cb6e6126.ngrok.io!') + expect(mail.to).to eq [recipient.user_email] + expect(mail.from).to eq ['notifications@localhost'] + end + + it 'renders the body' do + expect(mail.body.encoded).to match 'New Mastodon versions have been released, you may want to update!' + end + end + + describe '.new_critical_software_updates' do + let(:recipient) { Fabricate(:account, username: 'Bob') } + let(:mail) { described_class.with(recipient: recipient).new_critical_software_updates } + + before do + recipient.user.update(locale: :en) + end + + it 'renders the headers', :aggregate_failures do + expect(mail.subject).to eq('Critical Mastodon updates are available for cb6e6126.ngrok.io!') + expect(mail.to).to eq [recipient.user_email] + expect(mail.from).to eq ['notifications@localhost'] + + expect(mail['Importance'].value).to eq 'high' + expect(mail['Priority'].value).to eq 'urgent' + expect(mail['X-Priority'].value).to eq '1' + end + + it 'renders the body' do + expect(mail.body.encoded).to match 'New critical versions of Mastodon have been released, you may want to update as soon as possible!' + end + end end diff --git a/spec/models/software_update_spec.rb b/spec/models/software_update_spec.rb new file mode 100644 index 000000000..0a494b0c4 --- /dev/null +++ b/spec/models/software_update_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SoftwareUpdate do + describe '.pending_to_a' do + before do + allow(Mastodon::Version).to receive(:gem_version).and_return(Gem::Version.new(mastodon_version)) + + Fabricate(:software_update, version: '3.4.42', type: 'patch', urgent: true) + Fabricate(:software_update, version: '3.5.0', type: 'minor', urgent: false) + Fabricate(:software_update, version: '4.2.0', type: 'major', urgent: false) + end + + context 'when the Mastodon version is an outdated release' do + let(:mastodon_version) { '3.4.0' } + + it 'returns the expected versions' do + expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('3.4.42', '3.5.0', '4.2.0') + end + end + + context 'when the Mastodon version is more recent than anything last returned by the server' do + let(:mastodon_version) { '5.0.0' } + + it 'returns the expected versions' do + expect(described_class.pending_to_a.pluck(:version)).to eq [] + end + end + + context 'when the Mastodon version is an outdated nightly' do + let(:mastodon_version) { '4.3.0-nightly.2023-09-10' } + + before do + Fabricate(:software_update, version: '4.3.0-nightly.2023-09-12', type: 'major', urgent: true) + end + + it 'returns the expected versions' do + expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-nightly.2023-09-12') + end + end + + context 'when the Mastodon version is a very outdated nightly' do + let(:mastodon_version) { '4.2.0-nightly.2023-07-10' } + + it 'returns the expected versions' do + expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.2.0') + end + end + + context 'when the Mastodon version is an outdated dev version' do + let(:mastodon_version) { '4.3.0-0.dev.0' } + + before do + Fabricate(:software_update, version: '4.3.0-0.dev.2', type: 'major', urgent: true) + end + + it 'returns the expected versions' do + expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-0.dev.2') + end + end + + context 'when the Mastodon version is an outdated beta version' do + let(:mastodon_version) { '4.3.0-beta1' } + + before do + Fabricate(:software_update, version: '4.3.0-beta2', type: 'major', urgent: true) + end + + it 'returns the expected versions' do + expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-beta2') + end + end + + context 'when the Mastodon version is an outdated beta version and there is a rc' do + let(:mastodon_version) { '4.3.0-beta1' } + + before do + Fabricate(:software_update, version: '4.3.0-rc1', type: 'major', urgent: true) + end + + it 'returns the expected versions' do + expect(described_class.pending_to_a.pluck(:version)).to contain_exactly('4.3.0-rc1') + end + end + end +end diff --git a/spec/policies/software_update_policy_spec.rb b/spec/policies/software_update_policy_spec.rb new file mode 100644 index 000000000..e19ba6161 --- /dev/null +++ b/spec/policies/software_update_policy_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'pundit/rspec' + +RSpec.describe SoftwareUpdatePolicy do + subject { described_class } + + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Owner')).account } + let(:john) { Fabricate(:account) } + + permissions :index? do + context 'when owner' do + it 'permits' do + expect(subject).to permit(admin, SoftwareUpdate) + end + end + + context 'when not owner' do + it 'denies' do + expect(subject).to_not permit(john, SoftwareUpdate) + end + end + end +end diff --git a/spec/services/software_update_check_service_spec.rb b/spec/services/software_update_check_service_spec.rb new file mode 100644 index 000000000..c8821348a --- /dev/null +++ b/spec/services/software_update_check_service_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SoftwareUpdateCheckService, type: :service do + subject { described_class.new } + + shared_examples 'when the feature is enabled' do + let(:full_update_check_url) { "#{update_check_url}?version=#{Mastodon::Version.to_s.split('+')[0]}" } + + let(:devops_role) { Fabricate(:user_role, name: 'DevOps', permissions: UserRole::FLAGS[:view_devops]) } + let(:owner_user) { Fabricate(:user, role: UserRole.find_by(name: 'Owner')) } + let(:old_devops_user) { Fabricate(:user) } + let(:none_user) { Fabricate(:user, role: devops_role) } + let(:patch_user) { Fabricate(:user, role: devops_role) } + let(:critical_user) { Fabricate(:user, role: devops_role) } + + around do |example| + queue_adapter = ActiveJob::Base.queue_adapter + ActiveJob::Base.queue_adapter = :test + + example.run + + ActiveJob::Base.queue_adapter = queue_adapter + end + + before do + Fabricate(:software_update, version: '3.5.0', type: 'major', urgent: false) + Fabricate(:software_update, version: '42.13.12', type: 'major', urgent: false) + + owner_user.settings.update('notification_emails.software_updates': 'all') + owner_user.save! + + old_devops_user.settings.update('notification_emails.software_updates': 'all') + old_devops_user.save! + + none_user.settings.update('notification_emails.software_updates': 'none') + none_user.save! + + patch_user.settings.update('notification_emails.software_updates': 'patch') + patch_user.save! + + critical_user.settings.update('notification_emails.software_updates': 'critical') + critical_user.save! + end + + context 'when the update server errors out' do + before do + stub_request(:get, full_update_check_url).to_return(status: 404) + end + + it 'deletes outdated update records but keeps valid update records' do + expect { subject.call }.to change { SoftwareUpdate.pluck(:version).sort }.from(['3.5.0', '42.13.12']).to(['42.13.12']) + end + end + + context 'when the server returns new versions' do + let(:server_json) do + { + updatesAvailable: [ + { + version: '4.2.1', + urgent: false, + type: 'patch', + releaseNotes: 'https://github.com/mastodon/mastodon/releases/v4.2.1', + }, + { + version: '4.3.0', + urgent: false, + type: 'minor', + releaseNotes: 'https://github.com/mastodon/mastodon/releases/v4.3.0', + }, + { + version: '5.0.0', + urgent: false, + type: 'minor', + releaseNotes: 'https://github.com/mastodon/mastodon/releases/v5.0.0', + }, + ], + } + end + + before do + stub_request(:get, full_update_check_url).to_return(body: Oj.dump(server_json)) + end + + it 'updates the list of known updates' do + expect { subject.call }.to change { SoftwareUpdate.pluck(:version).sort }.from(['3.5.0', '42.13.12']).to(['4.2.1', '4.3.0', '5.0.0']) + 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) + .with(hash_including(params: { recipient: owner_user.account })).once + .and(have_enqueued_mail(AdminMailer, :new_software_updates).with(hash_including(params: { recipient: patch_user.account })).once) + .and(have_enqueued_mail.at_most(2)) + end + end + + context 'when an update is urgent' do + let(:server_json) do + { + updatesAvailable: [ + { + version: '5.0.0', + urgent: true, + type: 'minor', + releaseNotes: 'https://github.com/mastodon/mastodon/releases/v5.0.0', + }, + ], + } + end + + it 'sends e-mail notifications according to settings', :aggregate_failures do + expect { subject.call }.to have_enqueued_mail(AdminMailer, :new_critical_software_updates) + .with(hash_including(params: { recipient: owner_user.account })).once + .and(have_enqueued_mail(AdminMailer, :new_critical_software_updates).with(hash_including(params: { recipient: patch_user.account })).once) + .and(have_enqueued_mail(AdminMailer, :new_critical_software_updates).with(hash_including(params: { recipient: critical_user.account })).once) + .and(have_enqueued_mail.at_most(3)) + end + end + end + end + + context 'when update checking is disabled' do + around do |example| + ClimateControl.modify UPDATE_CHECK_URL: '' do + example.run + end + end + + before do + Fabricate(:software_update, version: '3.5.0', type: 'major', urgent: false) + end + + it 'deletes outdated update records' do + expect { subject.call }.to change(SoftwareUpdate, :count).from(1).to(0) + end + end + + context 'when using the default update checking API' do + let(:update_check_url) { 'https://api.joinmastodon.org/update-check' } + + it_behaves_like 'when the feature is enabled' + end + + context 'when using a custom update check URL' do + let(:update_check_url) { 'https://api.example.com/update_check' } + + around do |example| + ClimateControl.modify UPDATE_CHECK_URL: 'https://api.example.com/update_check' do + example.run + end + end + + it_behaves_like 'when the feature is enabled' + end +end diff --git a/spec/workers/scheduler/software_update_check_scheduler_spec.rb b/spec/workers/scheduler/software_update_check_scheduler_spec.rb new file mode 100644 index 000000000..f596c0a1e --- /dev/null +++ b/spec/workers/scheduler/software_update_check_scheduler_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Scheduler::SoftwareUpdateCheckScheduler do + subject { described_class.new } + + describe 'perform' do + let(:service_double) { instance_double(SoftwareUpdateCheckService, call: nil) } + + before do + allow(SoftwareUpdateCheckService).to receive(:new).and_return(service_double) + end + + it 'calls SoftwareUpdateCheckService' do + subject.perform + expect(service_double).to have_received(:call) + end + end +end