gearheads
/
mastodon
Archived
2
0
Fork 0

Add admin notifications for new Mastodon versions (#26582)

gh/dev^2
Claire 2023-09-01 17:47:07 +02:00 committed by GitHub
parent be991f1d18
commit 16681e0f20
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 892 additions and 8 deletions

View File

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

View File

@ -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>
);

View File

@ -14,7 +14,7 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/an
import { IconWithBadge } from 'mastodon/components/icon_with_badge'; import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container'; 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 { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { expandHomeTimeline } from '../../actions/timelines'; import { expandHomeTimeline } from '../../actions/timelines';
@ -23,6 +23,7 @@ import ColumnHeader from '../../components/column_header';
import StatusListContainer from '../ui/containers/status_list_container'; import StatusListContainer from '../ui/containers/status_list_container';
import { ColumnSettings } from './components/column_settings'; import { ColumnSettings } from './components/column_settings';
import { CriticalUpdateBanner } from './components/critical_update_banner';
import { ExplorePrompt } from './components/explore_prompt'; import { ExplorePrompt } from './components/explore_prompt';
const messages = defineMessages({ const messages = defineMessages({
@ -156,8 +157,9 @@ class HomeTimeline extends PureComponent {
const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props; const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
const { signedIn } = this.context.identity; const { signedIn } = this.context.identity;
const banners = [];
let announcementsButton, banner; let announcementsButton;
if (hasAnnouncements) { if (hasAnnouncements) {
announcementsButton = ( announcementsButton = (
@ -173,8 +175,12 @@ class HomeTimeline extends PureComponent {
); );
} }
if (criticalUpdatesPending) {
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
}
if (tooSlow) { if (tooSlow) {
banner = <ExplorePrompt />; banners.push(<ExplorePrompt key='explore-prompt' />);
} }
return ( return (
@ -196,7 +202,7 @@ class HomeTimeline extends PureComponent {
{signedIn ? ( {signedIn ? (
<StatusListContainer <StatusListContainer
prepend={banner} prepend={banners}
alwaysPrepend alwaysPrepend
trackScroll={!pinned} trackScroll={!pinned}
scrollKey={`home_timeline-${columnId}`} scrollKey={`home_timeline-${columnId}`}

View File

@ -87,6 +87,7 @@
* @typedef InitialState * @typedef InitialState
* @property {Record<string, Account>} accounts * @property {Record<string, Account>} accounts
* @property {InitialStateLanguage[]} languages * @property {InitialStateLanguage[]} languages
* @property {boolean=} critical_updates_pending
* @property {InitialStateMeta} meta * @property {InitialStateMeta} meta
*/ */
@ -140,6 +141,7 @@ export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items'); export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version'); export const version = getMeta('version');
export const languages = initialState?.languages; export const languages = initialState?.languages;
export const criticalUpdatesPending = initialState?.critical_updates_pending;
// @ts-expect-error // @ts-expect-error
export const statusPageUrl = getMeta('status_page_url'); export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect'); export const sso_redirect = getMeta('sso_redirect');

View File

@ -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.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.explore_prompt.title": "This is your home base within Mastodon.",
"home.hide_announcements": "Hide announcements", "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", "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.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.", "interaction_modal.description.follow": "With an account on Mastodon, you can follow {name} to receive their posts in your home feed.",

View File

@ -143,6 +143,11 @@ $content-width: 840px;
} }
} }
.warning a {
color: $gold-star;
font-weight: 700;
}
.simple-navigation-active-leaf a { .simple-navigation-active-leaf a {
color: $primary-text-color; color: $primary-text-color;
background-color: $ui-highlight-color; background-color: $ui-highlight-color;

View File

@ -8860,7 +8860,8 @@ noscript {
} }
} }
.dismissable-banner { .dismissable-banner,
.warning-banner {
position: relative; position: relative;
margin: 10px; margin: 10px;
margin-bottom: 5px; 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 { .image {
position: relative; position: relative;
overflow: hidden; overflow: hidden;

View File

@ -12,6 +12,11 @@
border-top: 1px solid $ui-base-color; border-top: 1px solid $ui-base-color;
text-align: start; text-align: start;
background: darken($ui-base-color, 4%); background: darken($ui-base-color, 4%);
&.critical {
font-weight: 700;
color: $gold-star;
}
} }
& > thead > tr > th { & > thead > tr > th {

View File

@ -2,6 +2,7 @@
class Admin::SystemCheck class Admin::SystemCheck
ACTIVE_CHECKS = [ ACTIVE_CHECKS = [
Admin::SystemCheck::SoftwareVersionCheck,
Admin::SystemCheck::MediaPrivacyCheck, Admin::SystemCheck::MediaPrivacyCheck,
Admin::SystemCheck::DatabaseSchemaCheck, Admin::SystemCheck::DatabaseSchemaCheck,
Admin::SystemCheck::SidekiqProcessCheck, Admin::SystemCheck::SidekiqProcessCheck,

View File

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

View File

@ -45,6 +45,22 @@ class AdminMailer < ApplicationMailer
end end
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 private
def process_params def process_params

View File

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

View File

@ -44,6 +44,7 @@ class UserSettings
setting :pending_account, default: true setting :pending_account, default: true
setting :trends, default: true setting :trends, default: true
setting :appeal, default: true setting :appeal, default: true
setting :software_updates, default: 'critical', in: %w(none critical patch all)
end end
namespace :interactions do namespace :interactions do

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class SoftwareUpdatePolicy < ApplicationPolicy
def index?
role.can?(:view_devops)
end
end

View File

@ -3,9 +3,13 @@
class InitialStatePresenter < ActiveModelSerializers::Model class InitialStatePresenter < ActiveModelSerializers::Model
attributes :settings, :push_subscription, :token, attributes :settings, :push_subscription, :token,
:current_account, :admin, :owner, :text, :visibility, :current_account, :admin, :owner, :text, :visibility,
:disabled_account, :moved_to_account :disabled_account, :moved_to_account, :critical_updates_pending
def role def role
current_account&.user_role current_account&.user_role
end end
def critical_updates_pending
role&.can?(:view_devops) && SoftwareUpdate.urgent_pending?
end
end end

View File

@ -7,6 +7,8 @@ class InitialStateSerializer < ActiveModel::Serializer
:media_attachments, :settings, :media_attachments, :settings,
:languages :languages
attribute :critical_updates_pending, if: -> { object&.role&.can?(:view_devops) && SoftwareUpdate.check_enabled? }
has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
has_one :role, serializer: REST::RoleSerializer has_one :role, serializer: REST::RoleSerializer

View File

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

View File

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

View File

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

View File

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

View File

@ -22,7 +22,7 @@
.fields-group .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') = 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' %h4= t 'notifications.administration_emails'
.fields-group .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.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) = 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' %h4= t 'notifications.other_settings'
.fields-group .fields-group

View File

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

View File

@ -309,6 +309,7 @@ en:
unpublish: Unpublish unpublish: Unpublish
unpublished_msg: Announcement successfully unpublished! unpublished_msg: Announcement successfully unpublished!
updated_msg: Announcement successfully updated! updated_msg: Announcement successfully updated!
critical_update_pending: Critical update pending
custom_emojis: custom_emojis:
assign_category: Assign category assign_category: Assign category
by_domain: Domain by_domain: Domain
@ -779,6 +780,18 @@ en:
site_uploads: site_uploads:
delete: Delete uploaded file delete: Delete uploaded file
destroyed_msg: Site upload successfully deleted! 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: statuses:
account: Author account: Author
application: Application application: Application
@ -843,6 +856,12 @@ en:
message_html: You haven't defined any server rules. message_html: You haven't defined any server rules.
sidekiq_process_check: sidekiq_process_check:
message_html: No Sidekiq process running for the %{value} queue(s). Please review your Sidekiq configuration 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: upload_check_privacy_error:
action: Check here for more information action: Check here for more information
message_html: "<strong>Your web server is misconfigured. The privacy of your users is at risk.</strong>" 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:" 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. next_steps: You can approve the appeal to undo the moderation decision, or ignore it.
subject: "%{username} is appealing a moderation decision on %{instance}" 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: new_pending_account:
body: The details of the new account are below. You can approve or reject this application. 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}) subject: New account up for review on %{instance} (%{username})
@ -963,6 +985,9 @@ en:
body: "%{reporter} has reported %{target}" body: "%{reporter} has reported %{target}"
body_remote: Someone from %{domain} has reported %{target} body_remote: Someone from %{domain} has reported %{target}
subject: New report for %{instance} (#%{id}) 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: new_trends:
body: 'The following items need a review before they can be displayed publicly:' body: 'The following items need a review before they can be displayed publicly:'
new_trending_links: new_trending_links:

View File

@ -291,6 +291,12 @@ en:
pending_account: New account needs review pending_account: New account needs review
reblog: Someone boosted your post reblog: Someone boosted your post
report: New report is submitted 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 trending_tag: New trend requires review
rule: rule:
text: Rule text: Rule

View File

@ -3,6 +3,9 @@
SimpleNavigation::Configuration.run do |navigation| SimpleNavigation::Configuration.run do |navigation|
navigation.items do |n| navigation.items do |n|
n.item :web, safe_join([fa_icon('chevron-left fw'), t('settings.back')]), root_path 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 :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| n.item :preferences, safe_join([fa_icon('cog fw'), t('settings.preferences')]), settings_preferences_path, if: -> { current_user.functional? } do |s|

View File

@ -201,4 +201,6 @@ namespace :admin do
end end
end end
end end
resources :software_updates, only: [:index]
end end

View File

@ -58,3 +58,7 @@
interval: 1 minute interval: 1 minute
class: Scheduler::SuspendedUserCleanupScheduler class: Scheduler::SuspendedUserCleanupScheduler
queue: scheduler queue: scheduler
software_update_check_scheduler:
interval: 30 minutes
class: Scheduler::SoftwareUpdateCheckScheduler
queue: scheduler

View File

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

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" 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 t.index ["var"], name: "index_site_uploads_on_var", unique: true
end 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| create_table "status_edits", force: :cascade do |t|
t.bigint "status_id", null: false t.bigint "status_id", null: false
t.bigint "account_id" t.bigint "account_id"

View File

@ -39,6 +39,10 @@ module Mastodon
components.join components.join
end end
def gem_version
@gem_version ||= Gem::Version.new(to_s.split('+')[0])
end
def repository def repository
ENV.fetch('GITHUB_REPOSITORY', 'mastodon/mastodon') ENV.fetch('GITHUB_REPOSITORY', 'mastodon/mastodon')
end end

View File

@ -424,6 +424,10 @@ namespace :mastodon do
end end
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 "\n"
prompt.say 'This configuration will be written to .env.production' prompt.say 'This configuration will be written to .env.production'

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
Fabricator(:software_update) do
version '99.99.99'
urgent false
type 'patch'
end

View File

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

View File

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

View File

@ -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' expect(mail.body.encoded).to match 'The following items need a review before they can be displayed publicly'
end end
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 end

View File

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

View File

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

View File

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

View File

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