Add admin notifications for new Mastodon versions (#26582)
This commit is contained in:
parent
be991f1d18
commit
16681e0f20
39 changed files with 892 additions and 8 deletions
18
app/controllers/admin/software_updates_controller.rb
Normal file
18
app/controllers/admin/software_updates_controller.rb
Normal 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
|
|
@ -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>
|
||||
);
|
|
@ -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}`}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class Admin::SystemCheck
|
||||
ACTIVE_CHECKS = [
|
||||
Admin::SystemCheck::SoftwareVersionCheck,
|
||||
Admin::SystemCheck::MediaPrivacyCheck,
|
||||
Admin::SystemCheck::DatabaseSchemaCheck,
|
||||
Admin::SystemCheck::SidekiqProcessCheck,
|
||||
|
|
27
app/lib/admin/system_check/software_version_check.rb
Normal file
27
app/lib/admin/system_check/software_version_check.rb
Normal 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
|
|
@ -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
|
||||
|
|
40
app/models/software_update.rb
Normal file
40
app/models/software_update.rb
Normal 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
|
|
@ -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
|
||||
|
|
7
app/policies/software_update_policy.rb
Normal file
7
app/policies/software_update_policy.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class SoftwareUpdatePolicy < ApplicationPolicy
|
||||
def index?
|
||||
role.can?(:view_devops)
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
82
app/services/software_update_check_service.rb
Normal file
82
app/services/software_update_check_service.rb
Normal 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
|
29
app/views/admin/software_updates/index.html.haml
Normal file
29
app/views/admin/software_updates/index.html.haml
Normal 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
|
|
@ -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 %>
|
5
app/views/admin_mailer/new_software_updates.text.erb
Normal file
5
app/views/admin_mailer/new_software_updates.text.erb
Normal 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 %>
|
|
@ -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
|
||||
|
|
11
app/workers/scheduler/software_update_check_scheduler.rb
Normal file
11
app/workers/scheduler/software_update_check_scheduler.rb
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue