Compare commits

...

7 Commits

Author SHA1 Message Date
Takeshi Umeda
0a604dcea1
Merge b23596109f into 3b52dca405 2025-07-11 17:04:07 +00:00
Claire
3b52dca405
Fix quote attributes missing from Mastodon's context (#35354)
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
2025-07-11 16:35:06 +00:00
Echo
853a0c466e
Make bio hashtags open the local page instead of the remote instance (#35349) 2025-07-11 15:18:34 +00:00
noellabo
b23596109f Fix admin_user 2025-05-08 23:15:57 +09:00
noellabo
75707d05a9 Add spec activity/create 2025-05-08 23:15:57 +09:00
noellabo
c1f8763e9b Delete unuse svg file 2025-05-08 23:15:57 +09:00
noellabo
129dd5bd9c Add reject pattern to Admin setting 2025-05-08 23:15:57 +09:00
19 changed files with 237 additions and 18 deletions

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class Admin::Settings::ProtectionsController < Admin::SettingsController
private
def after_update_redirect_path
admin_settings_protections_path
end
end

View File

@ -26,6 +26,12 @@ module ContextHelper
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } }, attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' }, quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
quotes: {
'quote' => 'https://w3id.org/fep/044f#quote',
'quoteUri' => 'http://fedibird.com/ns#quoteUri',
'_misskey_quote' => 'https://misskey-hub.net/ns#_misskey_quote',
'quoteAuthorization' => 'https://w3id.org/fep/044f#quoteAuthorization',
},
interaction_policies: { interaction_policies: {
'gts' => 'https://gotosocial.org/ns#', 'gts' => 'https://gotosocial.org/ns#',
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' }, 'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },

View File

@ -1,12 +1,30 @@
import { useCallback } from 'react';
import { useLinks } from 'mastodon/hooks/useLinks'; import { useLinks } from 'mastodon/hooks/useLinks';
export const AccountBio: React.FC<{ interface AccountBioProps {
note: string; note: string;
className: string; className: string;
}> = ({ note, className }) => { dropdownAccountId?: string;
const handleClick = useLinks(); }
if (note.length === 0 || note === '<p></p>') { export const AccountBio: React.FC<AccountBioProps> = ({
note,
className,
dropdownAccountId,
}) => {
const handleClick = useLinks(!!dropdownAccountId);
const handleNodeChange = useCallback(
(node: HTMLDivElement | null) => {
if (!dropdownAccountId || !node || node.childNodes.length === 0) {
return;
}
addDropdownToHashtags(node, dropdownAccountId);
},
[dropdownAccountId],
);
if (note.length === 0) {
return null; return null;
} }
@ -15,6 +33,28 @@ export const AccountBio: React.FC<{
className={`${className} translate`} className={`${className} translate`}
dangerouslySetInnerHTML={{ __html: note }} dangerouslySetInnerHTML={{ __html: note }}
onClickCapture={handleClick} onClickCapture={handleClick}
ref={handleNodeChange}
/> />
); );
}; };
function addDropdownToHashtags(node: HTMLElement | null, accountId: string) {
if (!node) {
return;
}
for (const childNode of node.childNodes) {
if (!(childNode instanceof HTMLElement)) {
continue;
}
if (
childNode instanceof HTMLAnchorElement &&
(childNode.classList.contains('hashtag') ||
childNode.innerText.startsWith('#')) &&
!childNode.dataset.menuHashtag
) {
childNode.dataset.menuHashtag = accountId;
} else if (childNode.childNodes.length > 0) {
addDropdownToHashtags(childNode, accountId);
}
}
}

View File

@ -6,6 +6,7 @@ import classNames from 'classnames';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { AccountBio } from '@/mastodon/components/account_bio';
import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
@ -773,7 +774,6 @@ export const AccountHeader: React.FC<{
); );
} }
const content = { __html: account.note_emojified };
const displayNameHtml = { __html: account.display_name_html }; const displayNameHtml = { __html: account.display_name_html };
const fields = account.fields; const fields = account.fields;
const isLocal = !account.acct.includes('@'); const isLocal = !account.acct.includes('@');
@ -897,12 +897,11 @@ export const AccountHeader: React.FC<{
<AccountNote accountId={accountId} /> <AccountNote accountId={accountId} />
)} )}
{account.note.length > 0 && account.note !== '<p></p>' && ( <AccountBio
<div note={account.note_emojified}
className='account__header__content translate' dropdownAccountId={accountId}
dangerouslySetInnerHTML={content} className='account__header__content'
/> />
)}
<div className='account__header__fields'> <div className='account__header__fields'>
<dl> <dl>

View File

@ -8,13 +8,14 @@ import { openURL } from 'mastodon/actions/search';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch } from 'mastodon/store';
const isMentionClick = (element: HTMLAnchorElement) => const isMentionClick = (element: HTMLAnchorElement) =>
element.classList.contains('mention'); element.classList.contains('mention') &&
!element.classList.contains('hashtag');
const isHashtagClick = (element: HTMLAnchorElement) => const isHashtagClick = (element: HTMLAnchorElement) =>
element.textContent?.[0] === '#' || element.textContent?.[0] === '#' ||
element.previousSibling?.textContent?.endsWith('#'); element.previousSibling?.textContent?.endsWith('#');
export const useLinks = () => { export const useLinks = (skipHashtags?: boolean) => {
const history = useHistory(); const history = useHistory();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -61,12 +62,12 @@ export const useLinks = () => {
if (isMentionClick(target)) { if (isMentionClick(target)) {
e.preventDefault(); e.preventDefault();
void handleMentionClick(target); void handleMentionClick(target);
} else if (isHashtagClick(target)) { } else if (isHashtagClick(target) && !skipHashtags) {
e.preventDefault(); e.preventDefault();
handleHashtagClick(target); handleHashtagClick(target);
} }
}, },
[handleMentionClick, handleHashtagClick], [skipHashtags, handleMentionClick, handleHashtagClick],
); );
return handleClick; return handleClick;

View File

@ -126,6 +126,9 @@ export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) {
? accountJSON.username ? accountJSON.username
: accountJSON.display_name; : accountJSON.display_name;
const accountNote =
accountJSON.note && accountJSON.note !== '<p></p>' ? accountJSON.note : '';
return AccountFactory({ return AccountFactory({
...accountJSON, ...accountJSON,
moved: moved?.id, moved: moved?.id,
@ -142,8 +145,8 @@ export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) {
escapeTextContentForBrowser(displayName), escapeTextContentForBrowser(displayName),
emojiMap, emojiMap,
), ),
note_emojified: emojify(accountJSON.note, emojiMap), note_emojified: emojify(accountNote, emojiMap),
note_plain: unescapeHTML(accountJSON.note), note_plain: unescapeHTML(accountNote),
url: url:
accountJSON.url.startsWith('http://') || accountJSON.url.startsWith('http://') ||
accountJSON.url.startsWith('https://') accountJSON.url.startsWith('https://')

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-80q-139-35-229.5-159.5T160-516v-244l320-120 320 120v244q0 152-90.5 276.5T480-80Zm0-84q97-30 162-118.5T718-480H480v-315l-240 90v207q0 7 2 18h238v316Z"/></svg>

After

Width:  |  Height:  |  Size: 259 B

View File

@ -14,7 +14,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
private private
def create_status def create_status
return reject_payload! if unsupported_object_type? || non_matching_uri_hosts?(@account.uri, object_uri) || tombstone_exists? || !related_to_local_activity? return reject_payload! if unsupported_object_type? || non_matching_uri_hosts?(@account.uri, object_uri) || tombstone_exists? || !related_to_local_activity? || reject_pattern?
with_redis_lock("create:#{object_uri}") do with_redis_lock("create:#{object_uri}") do
return if delete_arrived_first?(object_uri) || poll_vote? return if delete_arrived_first?(object_uri) || poll_vote?
@ -450,6 +450,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
Tombstone.exists?(uri: object_uri) Tombstone.exists?(uri: object_uri)
end end
def reject_pattern?
Setting.reject_pattern.present? && @object['content']&.match?(Setting.reject_pattern)
end
def forward_for_reply def forward_for_reply
return unless @status.distributable? && @json['signature'].present? && reply_to_local? return unless @status.distributable? && @json['signature'].present? && reply_to_local?

View File

@ -41,6 +41,7 @@ class Form::AdminSettings
app_icon app_icon
favicon favicon
min_age min_age
reject_pattern
).freeze ).freeze
INTEGER_KEYS = %i( INTEGER_KEYS = %i(
@ -94,6 +95,7 @@ class Form::AdminSettings
validates :media_cache_retention_period, :content_cache_retention_period, :backups_retention_period, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@media_cache_retention_period) || defined?(@content_cache_retention_period) || defined?(@backups_retention_period) } validates :media_cache_retention_period, :content_cache_retention_period, :backups_retention_period, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@media_cache_retention_period) || defined?(@content_cache_retention_period) || defined?(@backups_retention_period) }
validates :min_age, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@min_age) } validates :min_age, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@min_age) }
validates :site_short_description, length: { maximum: DESCRIPTION_LIMIT }, if: -> { defined?(@site_short_description) } validates :site_short_description, length: { maximum: DESCRIPTION_LIMIT }, if: -> { defined?(@site_short_description) }
validates :reject_pattern, regexp_syntax: true, if: -> { defined?(@reject_pattern) }
validates :status_page_url, url: true, allow_blank: true validates :status_page_url, url: true, allow_blank: true
validate :validate_site_uploads validate :validate_site_uploads

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class RegexpSyntaxValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
Regexp.compile(value)
rescue RegexpError => e
record.errors.add(attribute, I18n.t('applications.invalid_regexp', message: e.message))
end
end

View File

@ -0,0 +1,22 @@
- content_for :page_title do
= t('admin.settings.protections.title')
- content_for :heading do
%h2= t('admin.settings.title')
= render partial: 'admin/settings/shared/links'
= simple_form_for @admin_settings, url: admin_settings_protections_path, html: { method: :patch } do |f|
= render 'shared/error_messages', object: @admin_settings
%p.lead= t('admin.settings.protections.preamble')
%h4= t('admin.settings.protections.activitypub')
.fields-group
= f.input :reject_pattern,
as: :text,
input_html: { rows: 8 },
wrapper: :with_block_label
.actions
= f.button :button, t('generic.save_changes'), type: :submit

View File

@ -7,3 +7,4 @@
primary.item :discovery, safe_join([material_symbol('search'), t('admin.settings.discovery.title')]), admin_settings_discovery_path primary.item :discovery, safe_join([material_symbol('search'), t('admin.settings.discovery.title')]), admin_settings_discovery_path
primary.item :content_retention, safe_join([material_symbol('history'), t('admin.settings.content_retention.title')]), admin_settings_content_retention_path primary.item :content_retention, safe_join([material_symbol('history'), t('admin.settings.content_retention.title')]), admin_settings_content_retention_path
primary.item :appearance, safe_join([material_symbol('computer'), t('admin.settings.appearance.title')]), admin_settings_appearance_path primary.item :appearance, safe_join([material_symbol('computer'), t('admin.settings.appearance.title')]), admin_settings_appearance_path
primary.item :protections, safe_join([material_symbol('security'), t('admin.settings.protections.title')]), admin_settings_protections_path

View File

@ -842,6 +842,10 @@ en:
all: To everyone all: To everyone
disabled: To no one disabled: To no one
users: To logged-in local users users: To logged-in local users
protections:
activitypub: ActivityPub
preamble: Settings to protect your server
title: Protection
registrations: registrations:
moderation_recommandation: Please make sure you have an adequate and reactive moderation team before you open registrations to everyone! moderation_recommandation: Please make sure you have an adequate and reactive moderation team before you open registrations to everyone!
preamble: Control who can create an account on your server. preamble: Control who can create an account on your server.
@ -1178,6 +1182,7 @@ en:
applications: applications:
created: Application successfully created created: Application successfully created
destroyed: Application successfully deleted destroyed: Application successfully deleted
invalid_regexp: 'The provided Regexp is invalid: %{message}'
logout: Logout logout: Logout
regenerate_token: Regenerate access token regenerate_token: Regenerate access token
token_regenerated: Access token successfully regenerated token_regenerated: Access token successfully regenerated

View File

@ -94,6 +94,7 @@ en:
min_age: Users will be asked to confirm their date of birth during sign-up min_age: Users will be asked to confirm their date of birth during sign-up
peers_api_enabled: A list of domain names this server has encountered in the fediverse. No data is included here about whether you federate with a given server, just that your server knows about it. This is used by services that collect statistics on federation in a general sense. peers_api_enabled: A list of domain names this server has encountered in the fediverse. No data is included here about whether you federate with a given server, just that your server knows about it. This is used by services that collect statistics on federation in a general sense.
profile_directory: The profile directory lists all users who have opted-in to be discoverable. profile_directory: The profile directory lists all users who have opted-in to be discoverable.
reject_pattern: Set a regular expression pattern to inspect Create Activity content, and refuse Activity if you match
require_invite_text: When sign-ups require manual approval, make the “Why do you want to join?” text input mandatory rather than optional require_invite_text: When sign-ups require manual approval, make the “Why do you want to join?” text input mandatory rather than optional
site_contact_email: How people can reach you for legal or support inquiries. site_contact_email: How people can reach you for legal or support inquiries.
site_contact_username: How people can reach you on Mastodon. site_contact_username: How people can reach you on Mastodon.
@ -285,6 +286,7 @@ en:
peers_api_enabled: Publish list of discovered servers in the API peers_api_enabled: Publish list of discovered servers in the API
profile_directory: Enable profile directory profile_directory: Enable profile directory
registrations_mode: Who can sign-up registrations_mode: Who can sign-up
reject_pattern: Reject Pattern
require_invite_text: Require a reason to join require_invite_text: Require a reason to join
show_domain_blocks: Show domain blocks show_domain_blocks: Show domain blocks
show_domain_blocks_rationale: Show why domains were blocked show_domain_blocks_rationale: Show why domains were blocked

View File

@ -68,6 +68,7 @@ namespace :admin do
resource :about, only: [:show, :update], controller: 'about' resource :about, only: [:show, :update], controller: 'about'
resource :appearance, only: [:show, :update], controller: 'appearance' resource :appearance, only: [:show, :update], controller: 'appearance'
resource :discovery, only: [:show, :update], controller: 'discovery' resource :discovery, only: [:show, :update], controller: 'discovery'
resource :protections, only: [:show, :update], controller: 'protections'
end end
resources :site_uploads, only: [:destroy] resources :site_uploads, only: [:destroy]

View File

@ -52,6 +52,7 @@ defaults: &defaults
backups_retention_period: 7 backups_retention_period: 7
captcha_enabled: false captcha_enabled: false
allow_referer_origin: false allow_referer_origin: false
reject_pattern: ''
development: development:
<<: *defaults <<: *defaults

View File

@ -26,6 +26,8 @@ RSpec.describe ActivityPub::Activity::Create do
before do before do
sender.update(uri: ActivityPub::TagManager.instance.uri_for(sender)) sender.update(uri: ActivityPub::TagManager.instance.uri_for(sender))
Setting.reject_pattern = 'spam'
stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt')) stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt'))
stub_request(:get, 'http://example.com/emoji.png').to_return(body: attachment_fixture('emojo.png')) stub_request(:get, 'http://example.com/emoji.png').to_return(body: attachment_fixture('emojo.png'))
stub_request(:get, 'http://example.com/emojib.png').to_return(body: attachment_fixture('emojo.png'), headers: { 'Content-Type' => 'application/octet-stream' }) stub_request(:get, 'http://example.com/emojib.png').to_return(body: attachment_fixture('emojo.png'), headers: { 'Content-Type' => 'application/octet-stream' })
@ -95,6 +97,24 @@ RSpec.describe ActivityPub::Activity::Create do
} }
end end
let(:spam_object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), 'post3'].join('/'),
type: 'Note',
to: [
'https://www.w3.org/ns/activitystreams#Public',
ActivityPub::TagManager.instance.uri_for(follower),
],
content: '@bob lorem ispam',
published: 1.hour.ago.utc.iso8601,
updated: 1.hour.ago.utc.iso8601,
tag: {
type: 'Mention',
href: ActivityPub::TagManager.instance.uri_for(follower),
},
}
end
def activity_for_object(json) def activity_for_object(json)
{ {
'@context': 'https://www.w3.org/ns/activitystreams', '@context': 'https://www.w3.org/ns/activitystreams',
@ -168,6 +188,17 @@ RSpec.describe ActivityPub::Activity::Create do
# It has queued a mention resolve job # It has queued a mention resolve job
expect(MentionResolveWorker).to have_enqueued_sidekiq_job(status.id, invalid_mention_json[:tag][:href], anything) expect(MentionResolveWorker).to have_enqueued_sidekiq_job(status.id, invalid_mention_json[:tag][:href], anything)
end end
it 'do not process posts that contain reject patterns', :aggregate_failures do
stub_request(:get, spam_object_json[:id]).to_return(status: 500)
described_class.new(activity_for_object(spam_object_json), sender, delivery: true).perform
# …it creates a status
status = Status.find_by(uri: spam_object_json[:id])
# Check the process did crash
expect(status.nil?).to be true
end
end end
describe '#perform' do describe '#perform' do

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Admin::Settings::Protections' do
let(:admin_user) { Fabricate(:admin_user) }
before { sign_in(admin_user) }
it 'Saves changes to protections settings' do
visit admin_settings_protections_path
fill_in reject_pattern_field,
with: 'https://foo.bar'
click_on submit_button
expect(page)
.to have_content(success_message)
end
def reject_pattern_field
form_label 'form_admin_settings.reject_pattern'
end
end

View File

@ -0,0 +1,55 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe RegexpSyntaxValidator do
let(:record_class) do
Class.new do
include ActiveModel::Validations
attr_accessor :text
validates :text, regexp_syntax: true
end
end
let(:record) { record_class.new }
describe '#validate_each' do
context 'with a nil value' do
it 'does not add errors' do
record.text = nil
expect(record).to be_valid
expect(record.errors).to be_empty
end
end
context 'with a blank' do
it 'does not add errors' do
record.text = ''
expect(record).to be_valid
expect(record.errors).to be_empty
end
end
context 'with valid regexp' do
it 'does not add errors' do
record.text = 'spam|https://foo.bar'
expect(record).to be_valid
expect(record.errors).to be_empty
end
end
context 'with more lines than limit' do
it 'adds an error' do
record.text = '('
expect(record).to_not be_valid
expect(record.errors.where(:text)).to_not be_empty
end
end
end
end