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