~cytrogen/masto-fe

16681e0f20e1f8584e11439953c8d59b322571f5 — Claire 2 years ago be991f1
Add admin notifications for new Mastodon versions (#26582)

39 files changed, 892 insertions(+), 8 deletions(-)

A app/controllers/admin/software_updates_controller.rb
A app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx
M app/javascript/mastodon/features/home_timeline/index.jsx
M app/javascript/mastodon/initial_state.js
M app/javascript/mastodon/locales/en.json
M app/javascript/styles/mastodon/admin.scss
M app/javascript/styles/mastodon/components.scss
M app/javascript/styles/mastodon/tables.scss
M app/lib/admin/system_check.rb
A app/lib/admin/system_check/software_version_check.rb
M app/mailers/admin_mailer.rb
A app/models/software_update.rb
M app/models/user_settings.rb
A app/policies/software_update_policy.rb
M app/presenters/initial_state_presenter.rb
M app/serializers/initial_state_serializer.rb
A app/services/software_update_check_service.rb
A app/views/admin/software_updates/index.html.haml
A app/views/admin_mailer/new_critical_software_updates.text.erb
A app/views/admin_mailer/new_software_updates.text.erb
M app/views/settings/preferences/notifications/show.html.haml
A app/workers/scheduler/software_update_check_scheduler.rb
M config/locales/en.yml
M config/locales/simple_form.en.yml
M config/navigation.rb
M config/routes/admin.rb
M config/sidekiq.yml
A db/migrate/20230822081029_create_software_updates.rb
M db/schema.rb
M lib/mastodon/version.rb
M lib/tasks/mastodon.rake
A spec/fabricators/software_update_fabricator.rb
A spec/features/admin/software_updates_spec.rb
A spec/lib/admin/system_check/software_version_check_spec.rb
M spec/mailers/admin_mailer_spec.rb
A spec/models/software_update_spec.rb
A spec/policies/software_update_policy_spec.rb
A spec/services/software_update_check_service_spec.rb
A spec/workers/scheduler/software_update_check_scheduler_spec.rb
A app/controllers/admin/software_updates_controller.rb => app/controllers/admin/software_updates_controller.rb +18 -0
@@ 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

A app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx => app/javascript/mastodon/features/home_timeline/components/critical_update_banner.tsx +26 -0
@@ 0,0 1,26 @@
import { FormattedMessage } from 'react-intl';

export const CriticalUpdateBanner = () => (
  <div className='warning-banner'>
    <div className='warning-banner__message'>
      <h1>
        <FormattedMessage
          id='home.pending_critical_update.title'
          defaultMessage='Critical security update available!'
        />
      </h1>
      <p>
        <FormattedMessage
          id='home.pending_critical_update.body'
          defaultMessage='Please update your Mastodon server as soon as possible!'
        />{' '}
        <a href='/admin/software_updates'>
          <FormattedMessage
            id='home.pending_critical_update.link'
            defaultMessage='See updates'
          />
        </a>
      </p>
    </div>
  </div>
);

M app/javascript/mastodon/features/home_timeline/index.jsx => app/javascript/mastodon/features/home_timeline/index.jsx +10 -4
@@ 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(<CriticalUpdateBanner key='critical-update-banner' />);
    }

    if (tooSlow) {
      banner = <ExplorePrompt />;
      banners.push(<ExplorePrompt key='explore-prompt' />);
    }

    return (


@@ 196,7 202,7 @@ class HomeTimeline extends PureComponent {

        {signedIn ? (
          <StatusListContainer
            prepend={banner}
            prepend={banners}
            alwaysPrepend
            trackScroll={!pinned}
            scrollKey={`home_timeline-${columnId}`}

M app/javascript/mastodon/initial_state.js => app/javascript/mastodon/initial_state.js +2 -0
@@ 87,6 87,7 @@
 * @typedef InitialState
 * @property {Record<string, Account>} 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');

M app/javascript/mastodon/locales/en.json => app/javascript/mastodon/locales/en.json +3 -0
@@ 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.",

M app/javascript/styles/mastodon/admin.scss => app/javascript/styles/mastodon/admin.scss +5 -0
@@ 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;

M app/javascript/styles/mastodon/components.scss => app/javascript/styles/mastodon/components.scss +17 -1
@@ 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;

M app/javascript/styles/mastodon/tables.scss => app/javascript/styles/mastodon/tables.scss +5 -0
@@ 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 {

M app/lib/admin/system_check.rb => app/lib/admin/system_check.rb +1 -0
@@ 2,6 2,7 @@

class Admin::SystemCheck
  ACTIVE_CHECKS = [
    Admin::SystemCheck::SoftwareVersionCheck,
    Admin::SystemCheck::MediaPrivacyCheck,
    Admin::SystemCheck::DatabaseSchemaCheck,
    Admin::SystemCheck::SidekiqProcessCheck,

A app/lib/admin/system_check/software_version_check.rb => app/lib/admin/system_check/software_version_check.rb +27 -0
@@ 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

M app/mailers/admin_mailer.rb => app/mailers/admin_mailer.rb +16 -0
@@ 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

A app/models/software_update.rb => app/models/software_update.rb +40 -0
@@ 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

M app/models/user_settings.rb => app/models/user_settings.rb +1 -0
@@ 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

A app/policies/software_update_policy.rb => app/policies/software_update_policy.rb +7 -0
@@ 0,0 1,7 @@
# frozen_string_literal: true

class SoftwareUpdatePolicy < ApplicationPolicy
  def index?
    role.can?(:view_devops)
  end
end

M app/presenters/initial_state_presenter.rb => app/presenters/initial_state_presenter.rb +5 -1
@@ 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

M app/serializers/initial_state_serializer.rb => app/serializers/initial_state_serializer.rb +2 -0
@@ 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


A app/services/software_update_check_service.rb => app/services/software_update_check_service.rb +82 -0
@@ 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

A app/views/admin/software_updates/index.html.haml => app/views/admin/software_updates/index.html.haml +29 -0
@@ 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

A app/views/admin_mailer/new_critical_software_updates.text.erb => app/views/admin_mailer/new_critical_software_updates.text.erb +5 -0
@@ 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 %>

A app/views/admin_mailer/new_software_updates.text.erb => app/views/admin_mailer/new_software_updates.text.erb +5 -0
@@ 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 %>

M app/views/settings/preferences/notifications/show.html.haml => app/views/settings/preferences/notifications/show.html.haml +5 -1
@@ 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

A app/workers/scheduler/software_update_check_scheduler.rb => app/workers/scheduler/software_update_check_scheduler.rb +11 -0
@@ 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

M config/locales/en.yml => config/locales/en.yml +25 -0
@@ 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: "<strong>Your web server is misconfigured. The privacy of your users is at risk.</strong>"


@@ 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:

M config/locales/simple_form.en.yml => config/locales/simple_form.en.yml +6 -0
@@ 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

M config/navigation.rb => config/navigation.rb +3 -0
@@ 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|

M config/routes/admin.rb => config/routes/admin.rb +2 -0
@@ 201,4 201,6 @@ namespace :admin do
      end
    end
  end

  resources :software_updates, only: [:index]
end

M config/sidekiq.yml => config/sidekiq.yml +4 -0
@@ 58,3 58,7 @@
      interval: 1 minute
      class: Scheduler::SuspendedUserCleanupScheduler
      queue: scheduler
    software_update_check_scheduler:
      interval: 30 minutes
      class: Scheduler::SoftwareUpdateCheckScheduler
      queue: scheduler

A db/migrate/20230822081029_create_software_updates.rb => db/migrate/20230822081029_create_software_updates.rb +16 -0
@@ 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

M db/schema.rb => db/schema.rb +11 -1
@@ 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"

M lib/mastodon/version.rb => lib/mastodon/version.rb +4 -0
@@ 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

M lib/tasks/mastodon.rake => lib/tasks/mastodon.rake +4 -0
@@ 425,6 425,10 @@ namespace :mastodon do
      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'

      if prompt.yes?('Save configuration?')

A spec/fabricators/software_update_fabricator.rb => spec/fabricators/software_update_fabricator.rb +7 -0
@@ 0,0 1,7 @@
# frozen_string_literal: true

Fabricator(:software_update) do
  version '99.99.99'
  urgent false
  type 'patch'
end

A spec/features/admin/software_updates_spec.rb => spec/features/admin/software_updates_spec.rb +23 -0
@@ 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

A spec/lib/admin/system_check/software_version_check_spec.rb => spec/lib/admin/system_check/software_version_check_spec.rb +133 -0
@@ 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

M spec/mailers/admin_mailer_spec.rb => spec/mailers/admin_mailer_spec.rb +42 -0
@@ 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

A spec/models/software_update_spec.rb => spec/models/software_update_spec.rb +87 -0
@@ 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

A spec/policies/software_update_policy_spec.rb => spec/policies/software_update_policy_spec.rb +25 -0
@@ 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

A spec/services/software_update_check_service_spec.rb => spec/services/software_update_check_service_spec.rb +158 -0
@@ 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

A spec/workers/scheduler/software_update_check_scheduler_spec.rb => spec/workers/scheduler/software_update_check_scheduler_spec.rb +20 -0
@@ 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