diff --git a/Gemfile.lock b/Gemfile.lock index 7a8cee04f6..657c74e1ed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -352,7 +352,7 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.19.1) + json (2.19.2) json-canonicalization (1.0.0) json-jwt (1.17.0) activesupport (>= 4.2) diff --git a/app/javascript/entrypoints/admin.tsx b/app/javascript/entrypoints/admin.tsx index 92b9d1d917..91c3bd640a 100644 --- a/app/javascript/entrypoints/admin.tsx +++ b/app/javascript/entrypoints/admin.tsx @@ -166,6 +166,38 @@ on('change', '#domain_block_severity', ({ target }) => { if (target instanceof HTMLSelectElement) onDomainBlockSeverityChange(target); }); +const onChangeInviteUsersPermission = (target: HTMLInputElement) => { + const inviteBypassApprovalCheckbox = document.querySelector( + 'input#user_role_permissions_as_keys_invite_bypass_approval', + ); + + if (inviteBypassApprovalCheckbox) { + inviteBypassApprovalCheckbox.disabled = !target.checked; + + if (target.checked) { + inviteBypassApprovalCheckbox.parentElement?.classList.remove('disabled'); + inviteBypassApprovalCheckbox.parentElement?.parentElement?.classList.remove( + 'disabled', + ); + } else { + inviteBypassApprovalCheckbox.parentElement?.classList.add('disabled'); + inviteBypassApprovalCheckbox.parentElement?.parentElement?.classList.add( + 'disabled', + ); + } + } +}; + +on( + 'change', + 'input#user_role_permissions_as_keys_invite_users', + ({ target }) => { + if (target instanceof HTMLInputElement) { + onChangeInviteUsersPermission(target); + } + }, +); + function onEnableBootstrapTimelineAccountsChange(target: HTMLInputElement) { const bootstrapTimelineAccountsField = document.querySelector( @@ -291,6 +323,13 @@ ready(() => { ); if (registrationMode) onChangeRegistrationMode(registrationMode); + const inviteUsersPermissionChecbkox = + document.querySelector( + 'input#user_role_permissions_as_keys_invite_users', + ); + if (inviteUsersPermissionChecbkox) + onChangeInviteUsersPermission(inviteUsersPermissionChecbkox); + const checkAllElement = document.querySelector( '#batch_checkbox_all', ); diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 8c63b14d66..f4f137b2a1 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -158,24 +158,11 @@ class Status extends ImmutablePureComponent { newRepliesIds: [], }; - UNSAFE_componentWillMount () { + componentDidMount() { this.props.dispatch(fetchStatus(this.props.params.statusId, { forceFetch: true })); - } - - componentDidMount () { attachFullscreenListener(this.onFullScreenChange); } - UNSAFE_componentWillReceiveProps (nextProps) { - if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { - this.props.dispatch(fetchStatus(nextProps.params.statusId, { forceFetch: true })); - } - - if (nextProps.status && nextProps.status.get('id') !== this.state.loadedStatusId) { - this.setState({ showMedia: defaultMediaVisibility(nextProps.status), loadedStatusId: nextProps.status.get('id') }); - } - } - handleToggleMediaVisibility = () => { this.setState({ showMedia: !this.state.showMedia }); }; @@ -493,8 +480,8 @@ class Status extends ImmutablePureComponent { this.statusNode = c; }; - componentDidUpdate (prevProps) { - const { status, descendantsIds } = this.props; + componentDidUpdate(prevProps) { + const { status, descendantsIds, params } = this.props; const isSameStatus = status && (prevProps.status?.get('id') === status.get('id')); @@ -506,6 +493,14 @@ class Status extends ImmutablePureComponent { this.setState({newRepliesIds}); } } + + if (params.statusId && prevProps.params.statusId !== params.statusId) { + this.props.dispatch(fetchStatus(params.statusId, { forceFetch: true })); + } + + if (status && status.get('id') !== this.state.loadedStatusId) { + this.setState({ showMedia: defaultMediaVisibility(this.props.status), loadedStatusId: status.get('id') }); + } } componentWillUnmount () { diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 98cbbf7659..cc6827db4c 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -506,6 +506,10 @@ code { margin: 0; } } + + .checkbox.disabled { + opacity: 0.5; + } } label.checkbox { diff --git a/app/models/invite.rb b/app/models/invite.rb index 2e9371a074..ca692d937e 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -22,7 +22,7 @@ class Invite < ApplicationRecord COMMENT_SIZE_LIMIT = 420 ELIGIBLE_CODE_CHARACTERS = [*('a'..'z'), *('A'..'Z'), *('0'..'9')].freeze HOMOGLYPHS = %w(0 1 I l O).freeze - VALID_CODE_CHARACTERS = ELIGIBLE_CODE_CHARACTERS - HOMOGLYPHS + VALID_CODE_CHARACTERS = (ELIGIBLE_CODE_CHARACTERS - HOMOGLYPHS).freeze belongs_to :user, inverse_of: :invites has_many :users, inverse_of: :invite, dependent: nil @@ -37,6 +37,10 @@ class Invite < ApplicationRecord (max_uses.nil? || uses < max_uses) && !expired? && user&.functional? end + def bypass_approval? + user&.role&.can?(:invite_bypass_approval) + end + private def set_code diff --git a/app/models/user.rb b/app/models/user.rb index 30c0f29f08..8988a25b79 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -168,6 +168,10 @@ class User < ApplicationRecord invite_id.present? && invite.valid_for_use? end + def valid_bypassing_invitation? + valid_invitation? && invite.bypass_approval? + end + def disable! update!(disabled: true) @@ -420,7 +424,7 @@ class User < ApplicationRecord if requires_approval? false else - open_registrations? || valid_invitation? || external? + open_registrations? || valid_bypassing_invitation? || external? end end end diff --git a/app/models/user_role.rb b/app/models/user_role.rb index afeed324cc..f2597e1c43 100644 --- a/app/models/user_role.rb +++ b/app/models/user_role.rb @@ -38,6 +38,7 @@ class UserRole < ApplicationRecord manage_user_access: (1 << 18), delete_user_data: (1 << 19), view_feeds: (1 << 20), + invite_bypass_approval: (1 << 21), }.freeze EVERYONE_ROLE_ID = -99 @@ -51,10 +52,12 @@ class UserRole < ApplicationRecord ALL = FLAGS.values.reduce(&:|) DEFAULT = FLAGS[:invite_users] + SAFE = FLAGS[:invite_users] | FLAGS[:invite_bypass_approval] CATEGORIES = { invites: %i( invite_users + invite_bypass_approval ).freeze, moderation: %i( @@ -206,6 +209,6 @@ class UserRole < ApplicationRecord end def validate_dangerous_permissions - errors.add(:permissions_as_keys, :dangerous) if everyone? && Flags::DEFAULT & permissions != permissions + errors.add(:permissions_as_keys, :dangerous) if everyone? && Flags::SAFE & permissions != permissions end end diff --git a/app/services/activitypub/fetch_remote_featured_collection_service.rb b/app/services/activitypub/fetch_remote_featured_collection_service.rb new file mode 100644 index 0000000000..e9858fe34d --- /dev/null +++ b/app/services/activitypub/fetch_remote_featured_collection_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRemoteFeaturedCollectionService < BaseService + include JsonLdHelper + + def call(uri, on_behalf_of = nil) + json = fetch_resource(uri, true, on_behalf_of) + + return unless supported_context?(json) + return unless json['type'] == 'FeaturedCollection' + + # Fetching an unknown account should eventually also fetch its + # collections, so it should be OK to only handle known accounts here + account = Account.find_by(uri: json['attributedTo']) + return unless account + + existing_collection = account.collections.find_by(uri:) + return existing_collection if existing_collection.present? + + ActivityPub::ProcessFeaturedCollectionService.new.call(account, json) + end +end diff --git a/app/services/activitypub/process_featured_collection_service.rb b/app/services/activitypub/process_featured_collection_service.rb index edbb50c533..73db0d6699 100644 --- a/app/services/activitypub/process_featured_collection_service.rb +++ b/app/services/activitypub/process_featured_collection_service.rb @@ -27,7 +27,7 @@ class ActivityPub::ProcessFeaturedCollectionService tag_name: @json.dig('topic', 'name') ) - process_items! + process_items! if @json['totalItems'].positive? @collection end diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb index 0e20ad69a2..5f5851cca3 100644 --- a/app/services/backup_service.rb +++ b/app/services/backup_service.rb @@ -29,7 +29,7 @@ class BackupService < BaseService skeleton = serialize(collection_presenter(STREAM_OUTBOX, size: account.statuses.count), ActivityPub::CollectionSerializer) skeleton[:@context] = full_context skeleton[:orderedItems] = [PLACEHOLDER] - skeleton = JSON.generate(skeleton) + skeleton = skeleton.to_json prepend, append = skeleton.split(PLACEHOLDER.to_json) file.write(prepend) @@ -48,7 +48,7 @@ class BackupService < BaseService end end - JSON.generate(item) + item.to_json end.join(',')) GC.start @@ -121,10 +121,8 @@ class BackupService < BaseService download_to_zip(zipfile, account.avatar, "avatar#{File.extname(account.avatar.path)}") if account.avatar.exists? download_to_zip(zipfile, account.header, "header#{File.extname(account.header.path)}") if account.header.exists? - json = JSON.generate(actor) - zipfile.get_output_stream(STREAM_ACTOR) do |io| - io.write(json) + io.write(actor.to_json) end end @@ -133,7 +131,7 @@ class BackupService < BaseService skeleton.delete(:totalItems) skeleton[:orderedItems] = [PLACEHOLDER] - skeleton = JSON.generate(skeleton) + skeleton = skeleton.to_json prepend, append = skeleton.split(PLACEHOLDER.to_json) zipfile.get_output_stream(STREAM_LIKES) do |io| @@ -143,7 +141,7 @@ class BackupService < BaseService io.write(',') unless batch.zero? io.write(statuses.map do |status| - JSON.generate(ActivityPub::TagManager.instance.uri_for(status)) + ActivityPub::TagManager.instance.uri_for(status).to_json end.join(',')) GC.start @@ -161,7 +159,7 @@ class BackupService < BaseService skeleton = serialize(collection_presenter(STREAM_BOOKMARKS), ActivityPub::CollectionSerializer) skeleton.delete(:totalItems) skeleton[:orderedItems] = [PLACEHOLDER] - skeleton = JSON.generate(skeleton) + skeleton = skeleton.to_json prepend, append = skeleton.split(PLACEHOLDER.to_json) zipfile.get_output_stream(STREAM_BOOKMARKS) do |io| @@ -171,7 +169,7 @@ class BackupService < BaseService io.write(',') unless batch.zero? io.write(statuses.map do |status| - JSON.generate(ActivityPub::TagManager.instance.uri_for(status)) + ActivityPub::TagManager.instance.uri_for(status).to_json end.join(',')) GC.start diff --git a/config/locales/en.yml b/config/locales/en.yml index fc20a1da8c..a745021dc1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -778,6 +778,8 @@ en: administrator_description: Users with this permission will bypass every permission delete_user_data: Delete User Data delete_user_data_description: Allows users to delete other users' data without delay + invite_bypass_approval: Invite Users without review + invite_bypass_approval_description: Allows people invited to the server by these users to bypass moderation approval invite_users: Invite Users invite_users_description: Allows users to invite new people to the server manage_announcements: Manage Announcements diff --git a/db/migrate/20260318144837_add_invite_approval_bypass_permission.rb b/db/migrate/20260318144837_add_invite_approval_bypass_permission.rb new file mode 100644 index 0000000000..c42105d561 --- /dev/null +++ b/db/migrate/20260318144837_add_invite_approval_bypass_permission.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddInviteApprovalBypassPermission < ActiveRecord::Migration[8.1] + class UserRole < ApplicationRecord; end + + def up + UserRole.where('permissions & (1 << 16) = 1 << 16').update_all('permissions = permissions | (1 << 21)') + end + + def down; end +end diff --git a/db/schema.rb b/db/schema.rb index 60024bca34..a8e4a189c5 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[8.1].define(version: 2026_03_11_152331) do +ActiveRecord::Schema[8.1].define(version: 2026_03_18_144837) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb index c7bbe13092..6531608a01 100644 --- a/spec/controllers/auth/registrations_controller_spec.rb +++ b/spec/controllers/auth/registrations_controller_spec.rb @@ -298,7 +298,6 @@ RSpec.describe Auth::RegistrationsController do context 'with Approval-based registrations with valid invite and required invite text' do subject do - inviter = Fabricate(:user, confirmed_at: 2.days.ago) Setting.registrations_mode = 'approved' Setting.require_invite_text = true request.headers['Accept-Language'] = accept_language @@ -306,7 +305,9 @@ RSpec.describe Auth::RegistrationsController do post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', invite_code: invite.code, agreement: 'true' } } end - it 'redirects to setup and creates user' do + let!(:inviter) { Fabricate(:user, confirmed_at: 2.days.ago) } + + it 'redirects to setup and creates user in a non-approved state' do subject expect(response).to redirect_to auth_setup_path @@ -315,9 +316,28 @@ RSpec.describe Auth::RegistrationsController do .to be_present .and have_attributes( locale: eq(accept_language), - approved: be(true) + approved: be(false) ) end + + context 'when the inviting user has the permission to bypass approval' do + before do + inviter.role.update!(permissions: inviter.role.permissions | UserRole::FLAGS[:invite_bypass_approval]) + end + + it 'redirects to setup and creates user in an approved state' do + subject + + expect(response).to redirect_to auth_setup_path + + expect(User.find_by(email: 'test@example.com')) + .to be_present + .and have_attributes( + locale: eq(accept_language), + approved: be(true) + ) + end + end end context 'with an already taken username' do diff --git a/spec/services/activitypub/fetch_remote_featured_collection_service_spec.rb b/spec/services/activitypub/fetch_remote_featured_collection_service_spec.rb new file mode 100644 index 0000000000..f5cb9194b2 --- /dev/null +++ b/spec/services/activitypub/fetch_remote_featured_collection_service_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::FetchRemoteFeaturedCollectionService do + subject { described_class.new } + + let(:account) { Fabricate(:remote_account) } + let(:uri) { 'https://example.com/featured_collections/1' } + let(:status) { 200 } + let(:response) do + { + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => uri, + 'type' => 'FeaturedCollection', + 'name' => 'Incredible people', + 'summary' => 'These are really amazing', + 'attributedTo' => account.uri, + 'sensitive' => false, + 'discoverable' => true, + 'totalItems' => 0, + } + end + + before do + stub_request(:get, uri) + .to_return_json( + status: status, + body: response, + headers: { 'Content-Type' => 'application/activity+json' } + ) + end + + context 'when collection does not exist' do + it 'creates a new collection' do + collection = nil + expect { collection = subject.call(uri) }.to change(Collection, :count).by(1) + + expect(collection.uri).to eq uri + expect(collection.name).to eq 'Incredible people' + end + end + + context 'when collection already exists' do + let!(:collection) do + Fabricate(:remote_collection, account:, uri:, name: 'temp') + end + + it 'returns the existing collection' do + expect do + expect(subject.call(uri)).to eq collection + end.to_not change(Collection, :count) + end + end + + context 'when the URI can not be fetched' do + let(:response) { nil } + let(:status) { 404 } + + it 'returns `nil`' do + expect(subject.call(uri)).to be_nil + end + end +end