diff --git a/.env.production.sample b/.env.production.sample index b604c4b04dfa41..8a446d9cdc7a58 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -251,6 +251,11 @@ SMTP_FROM_ADDRESS=notifications@example.com # Maximum allowed character count MAX_TOOT_CHARS=500 +# Maximum allowed hashtags to follow in a feed column +# Note that setting this value higher may cause significant +# database load +MAX_FEED_HASHTAGS=4 + # Maximum number of pinned posts MAX_PINNED_TOOTS=5 diff --git a/Gemfile b/Gemfile index cd0ef255b1eb7c..ad7f0b3d40dc4d 100644 --- a/Gemfile +++ b/Gemfile @@ -26,7 +26,7 @@ gem 'blurhash', '~> 0.1' gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.8' -gem 'bootsnap', '~> 1.17.0', require: false +gem 'bootsnap', '~> 1.18.0', require: false gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'chewy', '~> 7.3' @@ -63,7 +63,7 @@ gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' gem 'mime-types', '~> 3.5.0', require: 'mime/types/columnar' gem 'nokogiri', '~> 1.15' -gem 'nsa', github: 'jhawthorn/nsa', ref: 'e020fcc3a54d993ab45b7194d89ab720296c111b' +gem 'nsa' gem 'oj', '~> 3.14' gem 'ox', '~> 2.14' gem 'parslet' diff --git a/Gemfile.lock b/Gemfile.lock index 57b25807222386..45ee4dcb30ca89 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,17 +7,6 @@ GIT hkdf (~> 0.2) jwt (~> 2.0) -GIT - remote: https://github.com/jhawthorn/nsa.git - revision: e020fcc3a54d993ab45b7194d89ab720296c111b - ref: e020fcc3a54d993ab45b7194d89ab720296c111b - specs: - nsa (0.2.8) - activesupport (>= 4.2, < 7.2) - concurrent-ruby (~> 1.0, >= 1.0.2) - sidekiq (>= 3.5) - statsd-ruby (~> 1.4, >= 1.4.0) - GEM remote: https://rubygems.org/ specs: @@ -155,9 +144,9 @@ GEM binding_of_caller (1.0.0) debug_inspector (>= 0.0.1) blurhash (0.1.7) - bootsnap (1.17.1) + bootsnap (1.18.3) msgpack (~> 1.2) - brakeman (6.1.1) + brakeman (6.1.2) racc browser (5.3.1) brpoplpush-redis_script (0.1.3) @@ -167,11 +156,11 @@ GEM bundler-audit (0.9.1) bundler (>= 1.2.0, < 3) thor (~> 1.0) - capybara (3.39.2) + capybara (3.40.0) addressable matrix mini_mime (>= 0.1.3) - nokogiri (~> 1.8) + nokogiri (~> 1.11) rack (>= 1.6.0) rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) @@ -180,7 +169,7 @@ GEM activesupport cbor (0.5.9.6) charlock_holmes (0.7.7) - chewy (7.5.0) + chewy (7.5.1) activesupport (>= 5.2) elasticsearch (>= 7.12.0, < 7.14.0) elasticsearch-dsl @@ -319,7 +308,7 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.55.0) + haml_lint (0.56.0) haml (>= 5.0) parallel (~> 1.10) rainbow @@ -465,9 +454,14 @@ GEM net-smtp (0.4.0.1) net-protocol nio4r (2.5.9) - nokogiri (1.16.0) + nokogiri (1.16.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) + nsa (0.3.0) + activesupport (>= 4.2, < 7.2) + concurrent-ruby (~> 1.0, >= 1.0.2) + sidekiq (>= 3.5) + statsd-ruby (~> 1.4, >= 1.4.0) oj (3.16.3) bigdecimal (>= 3.0) omniauth (2.1.1) @@ -712,7 +706,7 @@ GEM rufus-scheduler (~> 3.2) sidekiq (>= 6, < 8) tilt (>= 1.4.0) - sidekiq-unique-jobs (7.1.31) + sidekiq-unique-jobs (7.1.33) brpoplpush-redis_script (> 0.1.1, <= 2.0.0) concurrent-ruby (~> 1.0, >= 1.0.5) redis (< 5.0) @@ -771,7 +765,7 @@ GEM unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2023.4) + tzinfo-data (1.2024.1) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext @@ -829,7 +823,7 @@ DEPENDENCIES better_errors (~> 2.9) binding_of_caller (~> 1.0) blurhash (~> 0.1) - bootsnap (~> 1.17.0) + bootsnap (~> 1.18.0) brakeman (~> 6.0) browser bundler-audit (~> 0.9) @@ -886,7 +880,7 @@ DEPENDENCIES net-http (~> 0.4.0) net-ldap (~> 0.18) nokogiri (~> 1.15) - nsa! + nsa oj (~> 3.14) omniauth (~> 2.0) omniauth-cas (~> 3.0.0.beta.1) diff --git a/app/controllers/auth/omniauth_callbacks_controller.rb b/app/controllers/auth/omniauth_callbacks_controller.rb index 707b50ef9e1c03..9d496220a3d277 100644 --- a/app/controllers/auth/omniauth_callbacks_controller.rb +++ b/app/controllers/auth/omniauth_callbacks_controller.rb @@ -7,7 +7,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController def self.provides_callback_for(provider) define_method provider do @provider = provider - @user = User.find_for_oauth(request.env['omniauth.auth'], current_user) + @user = User.find_for_omniauth(request.env['omniauth.auth'], current_user) if @user.persisted? record_login_activity @@ -17,6 +17,9 @@ def self.provides_callback_for(provider) session["devise.#{provider}_data"] = request.env['omniauth.auth'] redirect_to new_user_registration_url end + rescue ActiveRecord::RecordInvalid + flash[:alert] = I18n.t('devise.failure.omniauth_user_creation_failure') if is_navigational_format? + redirect_to new_user_session_url end end diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.jsx b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.jsx index c60de4c5189469..4488c5b2a0e26e 100644 --- a/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.jsx +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.jsx @@ -9,6 +9,8 @@ import { NonceProvider } from 'react-select'; import AsyncSelect from 'react-select/async'; import Toggle from 'react-toggle'; +import { maxFeedHashtags } from 'flavours/glitch/initial_state'; + import SettingToggle from '../../notifications/components/setting_toggle'; const messages = defineMessages({ @@ -46,9 +48,9 @@ class ColumnSettings extends PureComponent { onSelect = mode => value => { const oldValue = this.tags(mode); - // Prevent changes that add more than 4 tags, but allow removing - // tags that were already added before - if ((value.length > 4) && !(value < oldValue)) { + // Prevent changes that add more than the number of configured + // tags, but allow removing tags that were already added before + if ((value.length > maxFeedHashtags) && !(value < oldValue)) { return; } diff --git a/app/javascript/flavours/glitch/features/ui/components/doodle_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/doodle_modal.jsx index d682ef161d1acd..ba593fc9fc3f24 100644 --- a/app/javascript/flavours/glitch/features/ui/components/doodle_modal.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/doodle_modal.jsx @@ -9,6 +9,10 @@ import { connect } from 'react-redux'; import Atrament from 'atrament'; // the doodling library import { debounce, mapValues } from 'lodash'; +import ColorsIcon from '@/material-icons/400-24px/colors.svg?react'; +import DeleteIcon from '@/material-icons/400-24px/delete.svg?react'; +import EditIcon from '@/material-icons/400-24px/edit.svg?react'; +import UndoIcon from '@/material-icons/400-24px/undo.svg?react'; import { doodleSet, uploadCompose } from 'flavours/glitch/actions/compose'; import { Button } from 'flavours/glitch/components/button'; import { IconButton } from 'flavours/glitch/components/icon_button'; @@ -584,10 +588,10 @@ class DoodleModal extends ImmutablePureComponent {
- - - - + + + +
{ diff --git a/app/javascript/flavours/glitch/initial_state.js b/app/javascript/flavours/glitch/initial_state.js index 52066aac420546..452f2991898686 100644 --- a/app/javascript/flavours/glitch/initial_state.js +++ b/app/javascript/flavours/glitch/initial_state.js @@ -70,6 +70,7 @@ export const hasMultiColumnPath = initialPath === '/' * @property {InitialStateMeta} meta * @property {object} local_settings * @property {number} max_toot_chars + * @property {number} max_feed_hashtags * @property {number} poll_limits * @property {number} max_reactions */ @@ -136,6 +137,7 @@ export const sso_redirect = getMeta('sso_redirect'); // Glitch-soc-specific settings export const maxChars = (initialState && initialState.max_toot_chars) || 500; +export const maxFeedHashtags = (initialState && initialState.max_feed_hashtags) || 4; export const favouriteModal = getMeta('favourite_modal'); export const pollLimits = (initialState && initialState.poll_limits); export const defaultContentType = getMeta('default_content_type'); diff --git a/app/javascript/flavours/glitch/locales/id.json b/app/javascript/flavours/glitch/locales/id.json index d360fed7222d91..f37788bc802eb5 100644 --- a/app/javascript/flavours/glitch/locales/id.json +++ b/app/javascript/flavours/glitch/locales/id.json @@ -1,4 +1,51 @@ { + "about.fork_disclaimer": "Glitch-soc adalah perangkat lunak sumber terbuka yang merupakan fork dari Mastodon.", + "account.disclaimer_full": "Informasi di bawah ini mungkin tidak mencerminkan profil pengguna secara lengkap.", + "account.follows": "Mengikuti", + "account.joined": "Bergabung {date}", + "account.suspended_disclaimer_full": "Pengguna ini telah ditangguhkan oleh moderator.", + "account.view_full_profile": "Tampilkan profil lengkap", + "advanced_options.icon_title": "Opsi lanjutan", + "advanced_options.local-only.long": "Jangan mengunggah ke instance lain", + "advanced_options.local-only.short": "Hanya lokal", + "advanced_options.local-only.tooltip": "Postingan ini hanya untuk lokal", + "advanced_options.threaded_mode.long": "Secara otomatis membuka balasan pada postingan", + "advanced_options.threaded_mode.short": "Mode Utasan", + "advanced_options.threaded_mode.tooltip": "Mode utasan dinyalakan", + "boost_modal.missing_description": "Toot ini berisi beberapa media tanpa deskripsi", + "column.favourited_by": "Disukai oleh", + "column.heading": "Lainnya", + "column.reblogged_by": "Dibagikan oleh", + "column.subheading": "Opsi lain-lain", + "column_header.profile": "Profil", + "column_subheading.lists": "Daftar", + "column_subheading.navigation": "Penelusuran", + "community.column_settings.allow_local_only": "Tampilkan toot lokal saja", + "compose.attach": "Lampirkan...", + "compose.attach.doodle": "Gambar sesuatu", + "compose.attach.upload": "Unggah file", + "compose.content-type.html": "HTML", + "compose.content-type.markdown": "Bahasa Markdown", + "compose.content-type.plain": "Teks biasa", + "compose_form.poll.multiple_choices": "Izinkan beberapa pilihan", + "compose_form.poll.single_choice": "Izinkan hanya satu pilihan", + "compose_form.spoiler": "Sembunyikan teks di balik peringatan", + "confirmation_modal.do_not_ask_again": "Jangan minta konfirmasi lagi", + "confirmations.deprecated_settings.confirm": "Gunakan preferensi Mastodon", + "confirmations.deprecated_settings.message": "Beberapa {app_settings} khusus perangkat Glitch-soc yang Anda gunakan telah digantikan oleh {preferences} Mastodon dan akan diganti:", + "confirmations.missing_media_description.confirm": "Tetap kirim", + "confirmations.missing_media_description.edit": "Sunting media", + "confirmations.missing_media_description.message": "Setidaknya satu lampiran media tidak memiliki deskripsi. Pertimbangkan untuk mendeskripsikan semua lampiran media untuk pengguna tunanetra sebelum mengirim toot Anda.", + "confirmations.unfilter.author": "Penulis", + "confirmations.unfilter.confirm": "Tampilkan", + "confirmations.unfilter.edit_filter": "Ubah saringan", + "content-type.change": "Jenis konten", + "direct.group_by_conversations": "Grupkan berdasarkan percakapan", + "endorsed_accounts_editor.endorsed_accounts": "Akun pilihan", + "favourite_modal.combo": "Anda dapat menekan {combo} untuk melewati ini lain kali", + "firehose.column_settings.allow_local_only": "Tampilkan postingan khusus lokal di \"Semua\"", + "home.column_settings.advanced": "Lanjutan", + "home.column_settings.filter_regex": "Saring dengan ekspresi reguler", "settings.content_warnings": "Content warnings", "settings.preferences": "Preferences" } diff --git a/app/javascript/flavours/glitch/locales/sv.json b/app/javascript/flavours/glitch/locales/sv.json index d360fed7222d91..3212f7ff88d725 100644 --- a/app/javascript/flavours/glitch/locales/sv.json +++ b/app/javascript/flavours/glitch/locales/sv.json @@ -1,4 +1,52 @@ { + "account.follows": "Följer", + "account.joined": "Gick med {date}", + "account.suspended_disclaimer_full": "Denna användare har stängts av av en moderator.", + "account.view_full_profile": "Visa full profil", + "advanced_options.icon_title": "Avancerade inställningar", + "advanced_options.local-only.long": "Lägg inte ut på andra instanser", + "advanced_options.local-only.short": "Endast lokalt", + "advanced_options.local-only.tooltip": "Detta inlägg är endast tillgängligt lokalt", + "advanced_options.threaded_mode.long": "Öppnar automatiskt ett svar vid publicering", + "advanced_options.threaded_mode.short": "Tråd-läge", + "advanced_options.threaded_mode.tooltip": "Tråd-läge på", + "boost_modal.missing_description": "Denna toot innehåller viss media utan beskrivning", + "column.favourited_by": "Favoritmarkerad av", + "column.heading": "Övrigt", + "column.reblogged_by": "Boostad av", + "column.subheading": "Övriga val", + "column_header.profile": "Profil", + "column_subheading.lists": "Listor", + "column_subheading.navigation": "Navigering", + "community.column_settings.allow_local_only": "Visa endast lokala toots", + "compose.attach": "Bifoga...", + "compose.attach.doodle": "Rita något", + "compose.attach.upload": "Ladda upp en fil", + "compose.content-type.html": "HTML", + "compose.content-type.markdown": "Markdown", + "compose.content-type.plain": "Klartext", + "compose_form.poll.multiple_choices": "Tillåt flera val", + "compose_form.poll.single_choice": "Tillåt ett val", + "compose_form.spoiler": "Göm text bakom varning", + "confirmation_modal.do_not_ask_again": "Fråga mig inte igen", + "confirmations.deprecated_settings.confirm": "Använd Mastodon-preferenser", + "confirmations.deprecated_settings.message": "Några av de glitch-soc-enhetsspecifika {app_settings} som du använder har ersatts av Mastodon-{preferences} och kommer att åsidosättas:", + "confirmations.missing_media_description.confirm": "Lägg ut ändå", + "confirmations.missing_media_description.edit": "Redigera media", + "confirmations.missing_media_description.message": "Minst en mediebilaga saknar beskrivning. Överväg att beskriva all media för synskadade innan du skickar din toot.", + "confirmations.unfilter.author": "Användare", + "confirmations.unfilter.confirm": "Visa", + "confirmations.unfilter.edit_filter": "Redigera filter", + "confirmations.unfilter.filters": "Matchande {count, plural, one {filter} other {filters}}", + "content-type.change": "Innehållstyp", + "direct.group_by_conversations": "Sortera efter konversation", + "endorsed_accounts_editor.endorsed_accounts": "Utvalda konton", + "favourite_modal.combo": "Du kan trycka på {combo} för att skippa detta nästa gång", + "firehose.column_settings.allow_local_only": "Visa endast lokala inlägg i \"Alla\"", + "home.column_settings.advanced": "Avancerat", + "home.column_settings.filter_regex": "Filtrera bort med reguljära uttryck", + "home.column_settings.show_direct": "Visa privata omnämningar", + "home.settings": "Kolumninställningar", "settings.content_warnings": "Content warnings", "settings.preferences": "Preferences" } diff --git a/app/javascript/material-icons/400-24px/colors-fill.svg b/app/javascript/material-icons/400-24px/colors-fill.svg new file mode 100644 index 00000000000000..5e4b534fe22f21 --- /dev/null +++ b/app/javascript/material-icons/400-24px/colors-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/colors.svg b/app/javascript/material-icons/400-24px/colors.svg new file mode 100644 index 00000000000000..5e4b534fe22f21 --- /dev/null +++ b/app/javascript/material-icons/400-24px/colors.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/undo-fill.svg b/app/javascript/material-icons/400-24px/undo-fill.svg new file mode 100644 index 00000000000000..c451e1adc73700 --- /dev/null +++ b/app/javascript/material-icons/400-24px/undo-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/undo.svg b/app/javascript/material-icons/400-24px/undo.svg new file mode 100644 index 00000000000000..c451e1adc73700 --- /dev/null +++ b/app/javascript/material-icons/400-24px/undo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/lib/application_extension.rb b/app/lib/application_extension.rb index fb442e2c2d2fca..400c51a023d09b 100644 --- a/app/lib/application_extension.rb +++ b/app/lib/application_extension.rb @@ -4,14 +4,34 @@ module ApplicationExtension extend ActiveSupport::Concern included do + include Redisable + has_many :created_users, class_name: 'User', foreign_key: 'created_by_application_id', inverse_of: :created_by_application validates :name, length: { maximum: 60 } validates :website, url: true, length: { maximum: 2_000 }, if: :website? validates :redirect_uri, length: { maximum: 2_000 } + + # The relationship used between Applications and AccessTokens is using + # dependent: delete_all, which means the ActiveRecord callback in + # AccessTokenExtension is not run, so instead we manually announce to + # streaming that these tokens are being deleted. + before_destroy :push_to_streaming_api, prepend: true end def confirmation_redirect_uri redirect_uri.lines.first.strip end + + def push_to_streaming_api + # TODO: #28793 Combine into a single topic + payload = Oj.dump(event: :kill) + access_tokens.in_batches do |tokens| + redis.pipelined do |pipeline| + tokens.ids.each do |id| + pipeline.publish("timeline:access_token:#{id}", payload) + end + end + end + end end diff --git a/app/models/concerns/user/omniauthable.rb b/app/models/concerns/user/omniauthable.rb index 113bfda23043eb..396a0598f87b82 100644 --- a/app/models/concerns/user/omniauthable.rb +++ b/app/models/concerns/user/omniauthable.rb @@ -19,17 +19,18 @@ def email_present? end class_methods do - def find_for_oauth(auth, signed_in_resource = nil) + def find_for_omniauth(auth, signed_in_resource = nil) # EOLE-SSO Patch auth.uid = (auth.uid[0][:uid] || auth.uid[0][:user]) if auth.uid.is_a? Hashie::Array - identity = Identity.find_for_oauth(auth) + identity = Identity.find_for_omniauth(auth) # If a signed_in_resource is provided it always overrides the existing user # to prevent the identity being locked with accidentally created accounts. # Note that this may leave zombie accounts (with no associated identity) which # can be cleaned up at a later date. user = signed_in_resource || identity.user - user ||= create_for_oauth(auth) + user ||= reattach_for_auth(auth) + user ||= create_for_auth(auth) if identity.user.nil? identity.user = user @@ -39,19 +40,35 @@ def find_for_oauth(auth, signed_in_resource = nil) user end - def create_for_oauth(auth) - # Check if the user exists with provided email. If no email was provided, - # we assign a temporary email and ask the user to verify it on - # the next step via Auth::SetupController.show + private - strategy = Devise.omniauth_configs[auth.provider.to_sym].strategy - assume_verified = strategy&.security&.assume_email_is_verified - email_is_verified = auth.info.verified || auth.info.verified_email || auth.info.email_verified || assume_verified - email = auth.info.verified_email || auth.info.email + def reattach_for_auth(auth) + # If allowed, check if a user exists with the provided email address, + # and return it if they does not have an associated identity with the + # current authentication provider. + + # This can be used to provide a choice of alternative auth providers + # or provide smooth gradual transition between multiple auth providers, + # but this is discouraged because any insecure provider will put *all* + # local users at risk, regardless of which provider they registered with. + + return unless ENV['ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH'] == 'true' - user = User.find_by(email: email) if email_is_verified + email, email_is_verified = email_from_auth(auth) + return unless email_is_verified - return user unless user.nil? + user = User.find_by(email: email) + return if user.nil? || Identity.exists?(provider: auth.provider, user_id: user.id) + + user + end + + def create_for_auth(auth) + # Create a user for the given auth params. If no email was provided, + # we assign a temporary email and ask the user to verify it on + # the next step via Auth::SetupController.show + + email, email_is_verified = email_from_auth(auth) user = User.new(user_params_from_auth(email, auth)) @@ -66,7 +83,14 @@ def create_for_oauth(auth) user end - private + def email_from_auth(auth) + strategy = Devise.omniauth_configs[auth.provider.to_sym].strategy + assume_verified = strategy&.security&.assume_email_is_verified + email_is_verified = auth.info.verified || auth.info.verified_email || auth.info.email_verified || assume_verified + email = auth.info.verified_email || auth.info.email + + [email, email_is_verified] + end def user_params_from_auth(email, auth) { diff --git a/app/models/identity.rb b/app/models/identity.rb index c95a68a6f63abb..77821b78fa2550 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -17,7 +17,7 @@ class Identity < ApplicationRecord validates :uid, presence: true, uniqueness: { scope: :provider } validates :provider, presence: true - def self.find_for_oauth(auth) + def self.find_for_omniauth(auth) find_or_create_by(uid: auth.uid, provider: auth.provider) end end diff --git a/app/models/tag_feed.rb b/app/models/tag_feed.rb index fbbdbaae279656..051b0d13064079 100644 --- a/app/models/tag_feed.rb +++ b/app/models/tag_feed.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class TagFeed < PublicFeed - LIMIT_PER_MODE = 4 + LIMIT_PER_MODE = (ENV['MAX_FEED_HASHTAGS'] || 4).to_i # @param [Tag] tag # @param [Account] account diff --git a/app/models/user.rb b/app/models/user.rb index cc56c2f54e7556..388be31fab7976 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -342,6 +342,16 @@ def revoke_access! Doorkeeper::AccessToken.by_resource_owner(self).in_batches do |batch| batch.update_all(revoked_at: Time.now.utc) Web::PushSubscription.where(access_token_id: batch).delete_all + + # Revoke each access token for the Streaming API, since `update_all`` + # doesn't trigger ActiveRecord Callbacks: + # TODO: #28793 Combine into a single topic + payload = Oj.dump(event: :kill) + redis.pipelined do |pipeline| + batch.ids.each do |id| + pipeline.publish("timeline:access_token:#{id}", payload) + end + end end end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index f96246f6eb9265..c1d9f6d7002634 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -5,7 +5,7 @@ class InitialStateSerializer < ActiveModel::Serializer attributes :meta, :compose, :accounts, :media_attachments, :settings, - :max_toot_chars, :poll_limits, + :max_toot_chars, :max_feed_hashtags, :poll_limits, :languages, :max_reactions attribute :critical_updates_pending, if: -> { object&.role&.can?(:view_devops) && SoftwareUpdate.check_enabled? } @@ -17,6 +17,10 @@ def max_toot_chars StatusLengthValidator::MAX_CHARS end + def max_feed_hashtags + TagFeed::LIMIT_PER_MODE + end + def max_reactions StatusReactionValidator::LIMIT end diff --git a/app/views/user_mailer/confirmation_instructions.html.haml b/app/views/user_mailer/confirmation_instructions.html.haml index 74b2d49a4710fe..13e68c722b2fc8 100644 --- a/app/views/user_mailer/confirmation_instructions.html.haml +++ b/app/views/user_mailer/confirmation_instructions.html.haml @@ -8,9 +8,7 @@ %td.email-inner-card-td.email-prose %p= t @resource.approved? ? 'devise.mailer.confirmation_instructions.explanation' : 'devise.mailer.confirmation_instructions.explanation_when_pending', host: site_hostname - if @resource.created_by_application - = render 'application/mailer/button', text: t('settings.account_settings'), url: edit_user_registration_url - = link_to confirmation_url(@resource, confirmation_token: @token, redirect_to_app: 'true') do - %span= t 'devise.mailer.confirmation_instructions.action_with_app', app: @resource.created_by_application.name + = render 'application/mailer/button', text: t('devise.mailer.confirmation_instructions.action_with_app', app: @resource.created_by_application.name), url: confirmation_url(@resource, confirmation_token: @token, redirect_to_app: 'true') - else = render 'application/mailer/button', text: t('devise.mailer.confirmation_instructions.action'), url: confirmation_url(@resource, confirmation_token: @token) %p= t 'devise.mailer.confirmation_instructions.extra_html', terms_path: about_more_url, policy_path: privacy_policy_url diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index fe3871d2e72fbb..f9d47a205ccd9c 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -21,9 +21,14 @@ user unless user&.otp_required_for_login? end - # If you want to restrict access to the web interface for adding oauth authorized applications, you need to declare the block below. + # Doorkeeper provides some administrative interfaces for managing OAuth + # Applications, allowing creation, edit, and deletion of applications from the + # server. At present, these administrative routes are not integrated into + # Mastodon, and as such, we've disabled them by always return a 403 forbidden + # response for them. This does not affect the ability for users to manage + # their own OAuth Applications. admin_authenticator do - current_user&.admin? || redirect_to(new_user_session_url) + head 403 end # Authorization Code expiration time (default 10 minutes). diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 9a2743ed5b0263..53b02edc40227e 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -26,6 +26,7 @@ 'queue' => 'scheduler', }, } + SidekiqScheduler::Scheduler.instance.reload_schedule! end end diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml index 4439397c8eead1..61bd33851b6fca 100644 --- a/config/locales/devise.en.yml +++ b/config/locales/devise.en.yml @@ -12,6 +12,7 @@ en: last_attempt: You have one more attempt before your account is locked. locked: Your account is locked. not_found_in_database: Invalid %{authentication_keys} or password. + omniauth_user_creation_failure: Error creating an account for this identity. pending: Your account is still under review. timeout: Your session expired. Please login again to continue. unauthenticated: You need to login or sign up before continuing. diff --git a/config/routes.rb b/config/routes.rb index 49116d08a28330..0b03acef2ee2da 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'sidekiq_unique_jobs/web' +require 'sidekiq_unique_jobs/web' if ENV['ENABLE_SIDEKIQ_UNIQUE_JOBS_UI'] == true require 'sidekiq-scheduler/web' class RedirectWithVary < ActionDispatch::Routing::PathRedirect diff --git a/lib/tasks/sidekiq_unique_jobs.rake b/lib/tasks/sidekiq_unique_jobs.rake new file mode 100644 index 00000000000000..bedc8fe4c650c4 --- /dev/null +++ b/lib/tasks/sidekiq_unique_jobs.rake @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +namespace :sidekiq_unique_jobs do + task delete_all_locks: :environment do + digests = SidekiqUniqueJobs::Digests.new + digests.delete_by_pattern('*', count: digests.count) + + expiring_digests = SidekiqUniqueJobs::ExpiringDigests.new + expiring_digests.delete_by_pattern('*', count: expiring_digests.count) + end +end diff --git a/spec/models/identity_spec.rb b/spec/models/identity_spec.rb index 70224544433c8d..d5a2ffbc869fad 100644 --- a/spec/models/identity_spec.rb +++ b/spec/models/identity_spec.rb @@ -3,19 +3,19 @@ require 'rails_helper' RSpec.describe Identity do - describe '.find_for_oauth' do + describe '.find_for_omniauth' do let(:auth) { Fabricate(:identity, user: Fabricate(:user)) } it 'calls .find_or_create_by' do allow(described_class).to receive(:find_or_create_by) - described_class.find_for_oauth(auth) + described_class.find_for_omniauth(auth) expect(described_class).to have_received(:find_or_create_by).with(uid: auth.uid, provider: auth.provider) end it 'returns an instance of Identity' do - expect(described_class.find_for_oauth(auth)).to be_instance_of described_class + expect(described_class.find_for_omniauth(auth)).to be_instance_of described_class end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 213022e8301b95..3cc804af431687 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -438,7 +438,10 @@ let!(:access_token) { Fabricate(:access_token, resource_owner_id: user.id) } let!(:web_push_subscription) { Fabricate(:web_push_subscription, access_token: access_token) } + let(:redis_pipeline_stub) { instance_double(Redis::Namespace, publish: nil) } + before do + allow(redis).to receive(:pipelined).and_yield(redis_pipeline_stub) user.reset_password! end @@ -455,6 +458,10 @@ expect(Doorkeeper::AccessToken.active_for(user).count).to eq 0 end + it 'revokes streaming access for all access tokens' do + expect(redis_pipeline_stub).to have_received(:publish).with("timeline:access_token:#{access_token.id}", Oj.dump(event: :kill)).once + end + it 'removes push subscriptions' do expect(Web::PushSubscription.where(user: user).or(Web::PushSubscription.where(access_token: access_token)).count).to eq 0 expect { web_push_subscription.reload }.to raise_error(ActiveRecord::RecordNotFound) diff --git a/spec/requests/disabled_oauth_endpoints_spec.rb b/spec/requests/disabled_oauth_endpoints_spec.rb new file mode 100644 index 00000000000000..7c2c09f3804bf3 --- /dev/null +++ b/spec/requests/disabled_oauth_endpoints_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Disabled OAuth routes' do + # These routes are disabled via the doorkeeper configuration for + # `admin_authenticator`, as these routes should only be accessible by server + # administrators. For now, these routes are not properly designed and + # integrated into Mastodon, so we're disabling them completely + describe 'GET /oauth/applications' do + it 'returns 403 forbidden' do + get oauth_applications_path + + expect(response).to have_http_status(403) + end + end + + describe 'POST /oauth/applications' do + it 'returns 403 forbidden' do + post oauth_applications_path + + expect(response).to have_http_status(403) + end + end + + describe 'GET /oauth/applications/new' do + it 'returns 403 forbidden' do + get new_oauth_application_path + + expect(response).to have_http_status(403) + end + end + + describe 'GET /oauth/applications/:id' do + let(:application) { Fabricate(:application, scopes: 'read') } + + it 'returns 403 forbidden' do + get oauth_application_path(application) + + expect(response).to have_http_status(403) + end + end + + describe 'PATCH /oauth/applications/:id' do + let(:application) { Fabricate(:application, scopes: 'read') } + + it 'returns 403 forbidden' do + patch oauth_application_path(application) + + expect(response).to have_http_status(403) + end + end + + describe 'PUT /oauth/applications/:id' do + let(:application) { Fabricate(:application, scopes: 'read') } + + it 'returns 403 forbidden' do + put oauth_application_path(application) + + expect(response).to have_http_status(403) + end + end + + describe 'DELETE /oauth/applications/:id' do + let(:application) { Fabricate(:application, scopes: 'read') } + + it 'returns 403 forbidden' do + delete oauth_application_path(application) + + expect(response).to have_http_status(403) + end + end + + describe 'GET /oauth/applications/:id/edit' do + let(:application) { Fabricate(:application, scopes: 'read') } + + it 'returns 403 forbidden' do + get edit_oauth_application_path(application) + + expect(response).to have_http_status(403) + end + end +end diff --git a/spec/requests/omniauth_callbacks_spec.rb b/spec/requests/omniauth_callbacks_spec.rb index 0d37c411403bdc..095535e48598e0 100644 --- a/spec/requests/omniauth_callbacks_spec.rb +++ b/spec/requests/omniauth_callbacks_spec.rb @@ -39,16 +39,35 @@ Fabricate(:user, email: 'user@host.example') end - it 'matches the existing user, creates an identity, and redirects to root path' do - expect { subject } - .to not_change(User, :count) - .and change(Identity, :count) - .by(1) - .and change(LoginActivity, :count) - .by(1) + context 'when ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH is set to true' do + around do |example| + ClimateControl.modify ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH: 'true' do + example.run + end + end + + it 'matches the existing user, creates an identity, and redirects to root path' do + expect { subject } + .to not_change(User, :count) + .and change(Identity, :count) + .by(1) + .and change(LoginActivity, :count) + .by(1) + + expect(Identity.find_by(user: User.last).uid).to eq('123') + expect(response).to redirect_to(root_path) + end + end - expect(Identity.find_by(user: User.last).uid).to eq('123') - expect(response).to redirect_to(root_path) + context 'when ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH is not set to true' do + it 'does not match the existing user or create an identity, and redirects to login page' do + expect { subject } + .to not_change(User, :count) + .and not_change(Identity, :count) + .and not_change(LoginActivity, :count) + + expect(response).to redirect_to(new_user_session_url) + end end end @@ -96,7 +115,7 @@ context 'when a user cannot be built' do before do - allow(User).to receive(:find_for_oauth).and_return(User.new) + allow(User).to receive(:find_for_omniauth).and_return(User.new) end it 'redirects to the new user signup page' do