Compare commits

...

20 Commits

Author SHA1 Message Date
Angus McLeod
202fa241ea
Merge a5979402ce 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
Angus McLeod
a5979402ce Sidekiq needs to run inline now in specs 2025-02-24 08:53:51 +01:00
Angus McLeod
c8fbc194e9 Merge remote-tracking branch 'upstream/main' into add_username_change_integration_test 2025-02-24 08:48:08 +01:00
Angus McLeod
3975ce0780 Use shared_example for does not update username scenarios 2025-02-20 09:02:33 +01:00
Angus McLeod
e1ce48753d Add more checks and tests 2025-02-20 09:02:33 +01:00
Angus McLeod
5168786cf0 Fix failing specs 2025-02-20 09:02:33 +01:00
Angus McLeod
40ba0134a3 Add webfinger confirmation to username update scenario 2025-02-20 09:02:33 +01:00
Angus McLeod
c30914d20b Simplify ops assignment 2025-02-20 09:02:33 +01:00
Angus McLeod
95bb3d8fd7 Don't update non unique usernames on remote domains 2025-02-20 09:02:33 +01:00
Angus McLeod
24ac1c1204 Allow username updates for Update Actor 2025-02-20 09:02:33 +01:00
Angus McLeod
eb18e5df29 Change sidekiq inline invocation 2025-02-20 08:59:06 +01:00
Angus McLeod
b7768d9057 Remove Update and Follow from inbox integration test 2025-02-20 08:59:06 +01:00
Angus McLeod
ddd480bcad Update spec/requests/activitypub/inboxes_controller_spec.rb
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
2025-02-20 08:59:05 +01:00
Angus McLeod
fa06f50432 Add more cases to search spec 2025-02-20 08:59:05 +01:00
Angus McLeod
cc7e4479b5 Improve and add Update and Follow tests 2025-02-20 08:59:05 +01:00
Angus McLeod
4199a0de62 Add remote username changed search integration test 2025-02-20 08:59:05 +01:00
Angus McLeod
e8a19a6ce6 Make username state explicit 2025-02-20 08:59:05 +01:00
Angus McLeod
1707c38dd6 Add integration test for when remote actor username changes 2025-02-20 08:59:05 +01:00
12 changed files with 635 additions and 34 deletions

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

@ -20,7 +20,14 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
def update_account def update_account
return reject_payload! if @account.uri != object_uri return reject_payload! if @account.uri != object_uri
ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true, request_id: @options[:request_id]) opts = {
signed_with_known_key: true,
request_id: @options[:request_id],
}
opts[:allow_username_update] = allow_username_update? if @account.username != @object['preferredUsername']
ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, opts)
end end
def update_status def update_status
@ -32,4 +39,26 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
ActivityPub::ProcessStatusUpdateService.new.call(@status, @json, @object, request_id: @options[:request_id]) ActivityPub::ProcessStatusUpdateService.new.call(@status, @json, @object, request_id: @options[:request_id])
end end
def allow_username_update?
updated_username_unique? && updated_username_confirmed?
end
def updated_username_unique?
account_proxy = @account.dup
account_proxy.username = @object['preferredUsername']
UniqueUsernameValidator.new.validate(account_proxy)
account_proxy.errors.blank?
end
def updated_username_confirmed?
begin
webfinger = Webfinger.new("acct:#{@object['preferredUsername']}@#{@account.domain}").perform
rescue Webfinger::Error
return false
end
confirmed_username, confirmed_domain = webfinger.subject.delete_prefix('acct:').split('@')
confirmed_username == @object['preferredUsername'] && confirmed_domain == @account.domain
end
end end

View File

@ -27,7 +27,7 @@ class ActivityPub::ProcessAccountService < BaseService
@options[:request_id] ||= "#{Time.now.utc.to_i}-#{username}@#{domain}" @options[:request_id] ||= "#{Time.now.utc.to_i}-#{username}@#{domain}"
with_redis_lock("process_account:#{@uri}") do with_redis_lock("process_account:#{@uri}") do
@account = Account.remote.find_by(uri: @uri) if @options[:only_key] @account = Account.remote.find_by(uri: @uri) if find_remote_account_by_uri?
@account ||= Account.find_remote(@username, @domain) @account ||= Account.find_remote(@username, @domain)
@old_public_key = @account&.public_key @old_public_key = @account&.public_key
@old_protocol = @account&.protocol @old_protocol = @account&.protocol
@ -69,6 +69,10 @@ class ActivityPub::ProcessAccountService < BaseService
private private
def find_remote_account_by_uri?
@options[:only_key] || @options[:allow_username_update]
end
def create_account def create_account
@account = Account.new @account = Account.new
@account.protocol = :activitypub @account.protocol = :activitypub
@ -131,6 +135,7 @@ class ActivityPub::ProcessAccountService < BaseService
@account.indexable = @json['indexable'] || false @account.indexable = @json['indexable'] || false
@account.memorial = @json['memorial'] || false @account.memorial = @json['memorial'] || false
@account.attribution_domains = as_array(@json['attributionDomains'] || []).map { |item| value_or_id(item) } @account.attribution_domains = as_array(@json['attributionDomains'] || []).map { |item| value_or_id(item) }
@account.username = @json['preferredUsername'] if @options[:allow_username_update]
end end
def set_fetchable_key! def set_fetchable_key!

View File

@ -55,13 +55,122 @@ RSpec.describe ActivityPub::Activity::Update do
stub_request(:get, actor_json[:following]).to_return(status: 404) stub_request(:get, actor_json[:following]).to_return(status: 404)
stub_request(:get, actor_json[:featured]).to_return(status: 404) stub_request(:get, actor_json[:featured]).to_return(status: 404)
stub_request(:get, actor_json[:featuredTags]).to_return(status: 404) stub_request(:get, actor_json[:featuredTags]).to_return(status: 404)
subject.perform
end end
it 'updates profile' do it 'updates profile' do
subject.perform
expect(sender.reload.display_name).to eq 'Totally modified now' expect(sender.reload.display_name).to eq 'Totally modified now'
end end
context 'when Actor username changes' do
let!(:original_username) { sender.username }
let!(:original_handle) { "#{original_username}@#{sender.domain}" }
let!(:updated_username) { 'updated_username' }
let!(:updated_handle) { "#{updated_username}@#{sender.domain}" }
let(:updated_username_json) { actor_json.merge(preferredUsername: updated_username) }
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Update',
actor: sender.uri,
object: updated_username_json,
}.with_indifferent_access
end
before do
stub_request(:get, 'https://example.com/.well-known/host-meta').to_return(status: 404)
end
context 'when updated username is unique and confirmed' do
before do
stub_request(:get, "https://example.com/.well-known/webfinger?resource=acct:#{updated_handle}")
.to_return(
body: {
subject: "acct:#{updated_handle}",
links: [
{
rel: 'self',
type: 'application/activity+json',
href: sender.uri,
},
],
}.to_json,
headers: {
'Content-Type' => 'application/json',
},
status: 200
)
end
it 'updates profile' do
subject.perform
expect(sender.reload.display_name).to eq 'Totally modified now'
end
it 'updates username' do
subject.perform
expect(sender.reload.username).to eq updated_username
end
end
shared_examples 'does not update username' do
it 'updates profile' do
subject.perform
expect(sender.reload.display_name).to eq 'Totally modified now'
end
it 'does not update username' do
subject.perform
expect(sender.reload.username).to eq original_username
end
end
context 'when updated username is not unique for domain' do
before do
Fabricate(:account,
username: updated_username,
domain: 'example.com',
inbox_url: "https://example.com/#{updated_username}/inbox",
outbox_url: "https://example.com/#{updated_username}/outbox")
end
include_examples 'does not update username'
end
context 'when webfinger of updated username does not contain updated username' do
before do
stub_request(:get, "https://example.com/.well-known/webfinger?resource=acct:#{updated_handle}")
.to_return(
body: {
subject: "acct:#{original_handle}",
links: [
{
rel: 'self',
type: 'application/activity+json',
href: sender.uri,
},
],
}.to_json,
headers: {
'Content-Type' => 'application/json',
},
status: 200
)
end
include_examples 'does not update username'
end
context 'when webfinger request of updated username fails' do
before do
stub_request(:get, "https://example.com/.well-known/webfinger?resource=acct:#{updated_handle}")
.to_return(status: 404)
end
include_examples 'does not update username'
end
end
end end
context 'with a Question object' do context 'with a Question object' do

View File

@ -0,0 +1,210 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::InboxesController, :sidekiq_inline do
let!(:current_datetime) { 'Wed, 20 Dec 2023 10:00:00 GMT' }
let!(:remote_actor_keypair) do
OpenSSL::PKey.read(<<~PEM_TEXT)
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAqIAYvNFGbZ5g4iiK6feSdXD4bDStFM58A7tHycYXaYtzZQpI
eHXAmaXuZzXIwtrP4N0gIk8JNwZvXj2UPS+S07t0V9wNK94he01LV5EMz/GN4eNn
FmDL64HIEuKLvV8TvgjbUPRD6Y5X0UpKi2ZIFLSb96Q5w0Z/k7ntpVKV52y8kz5F
jr/O/0JuHryZe0yItzJh8kzFfeMf0EXzfSnaKvT7P9jhgC6uTre+jXyvVZjiHDrn
qvvucdI3I7DRfXo1OqARBrLjy+TdseUAjNYJ+OuPRI1URIWQI01DCHqcohVu9+Ar
+BiCjFp3ua+XMuJvrvbD61d1Fvig/9nbBRR+8QIDAQABAoIBAAgySHnFWI6gItR3
fkfiqIm80cHCN3Xk1C6iiVu+3oBOZbHpW9R7vl9e/WOA/9O+LPjiSsQOegtWnVvd
RRjrl7Hj20VDlZKv5Mssm6zOGAxksrcVbqwdj+fUJaNJCL0AyyseH0x/IE9T8rDC
I1GH+3tB3JkhkIN/qjipdX5ab8MswEPu8IC4ViTpdBgWYY/xBcAHPw4xuL0tcwzh
FBlf4DqoEVQo8GdK5GAJ2Ny0S4xbXHUURzx/R4y4CCts7niAiLGqd9jmLU1kUTMk
QcXfQYK6l+unLc7wDYAz7sFEHh04M48VjWwiIZJnlCqmQbLda7uhhu8zkF1DqZTu
ulWDGQECgYEA0TIAc8BQBVab979DHEEmMdgqBwxLY3OIAk0b+r50h7VBGWCDPRsC
STD73fQY3lNet/7/jgSGwwAlAJ5PpMXxXiZAE3bUwPmHzgF7pvIOOLhA8O07tHSO
L2mvQe6NPzjZ+6iAO2U9PkClxcvGvPx2OBvisfHqZLmxC9PIVxzruQECgYEAzjM6
BTUXa6T/qHvLFbN699BXsUOGmHBGaLRapFDBfVvgZrwqYQcZpBBhesLdGTGSqwE7
gWsITPIJ+Ldo+38oGYyVys+w/V67q6ud7hgSDTW3hSvm+GboCjk6gzxlt9hQ0t9X
8vfDOYhEXvVUJNv3mYO60ENqQhILO4bQ0zi+VfECgYBb/nUccfG+pzunU0Cb6Dp3
qOuydcGhVmj1OhuXxLFSDG84Tazo7juvHA9mp7VX76mzmDuhpHPuxN2AzB2SBEoE
cSW0aYld413JRfWukLuYTc6hJHIhBTCRwRQFFnae2s1hUdQySm8INT2xIc+fxBXo
zrp+Ljg5Wz90SAnN5TX0AQKBgDaatDOq0o/r+tPYLHiLtfWoE4Dau+rkWJDjqdk3
lXWn/e3WyHY3Vh/vQpEqxzgju45TXjmwaVtPATr+/usSykCxzP0PMPR3wMT+Rm1F
rIoY/odij+CaB7qlWwxj0x/zRbwB7x1lZSp4HnrzBpxYL+JUUwVRxPLIKndSBTza
GvVRAoGBAIVBcNcRQYF4fvZjDKAb4fdBsEuHmycqtRCsnkGOz6ebbEQznSaZ0tZE
+JuouZaGjyp8uPjNGD5D7mIGbyoZ3KyG4mTXNxDAGBso1hrNDKGBOrGaPhZx8LgO
4VXJ+ybXrATf4jr8ccZYsZdFpOphPzz+j55Mqg5vac5P1XjmsGTb
-----END RSA PRIVATE KEY-----
PEM_TEXT
end
let!(:remote_actor_inbox_url) { 'https://remote.domain/users/bob/inbox' }
let!(:remote_actor_original_username) { 'original_username' }
let!(:remote_actor) do
Fabricate(:account,
domain: 'remote.domain',
uri: 'https://remote.domain/users/bob',
private_key: nil,
public_key: remote_actor_keypair.public_key.to_pem,
username: remote_actor_original_username,
protocol: :activitypub,
inbox_url: remote_actor_inbox_url)
end
let!(:local_actor) { Fabricate(:account) }
let!(:base_headers) do
{
'Host' => 'www.remote.domain',
'Date' => current_datetime,
}
end
let!(:note_content) { 'note from remote actor' }
let!(:object_json) do
{
id: 'https://remote.domain/activities/objects/1',
type: 'Note',
content: note_content,
to: ActivityPub::TagManager.instance.uri_for(local_actor),
}
end
before do
travel_to current_datetime
end
context 'when remote actor username has changed' do
let(:remote_actor_new_username) { 'new_username' }
let(:remote_actor_new_handle) { "#{remote_actor_new_username}@#{remote_actor.domain}" }
let(:updated_remote_actor_json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: remote_actor.uri,
type: 'Person',
preferredUsername: remote_actor_new_username,
inbox: remote_actor.inbox_url,
publicKey: {
id: "#{remote_actor.uri}#main-key",
owner: remote_actor.uri,
publicKeyPem: remote_actor.public_key,
},
}.with_indifferent_access
end
let(:remote_actor_webfinger_response) do
{
subject: "acct:#{remote_actor_new_handle}",
links: [
{
rel: 'self',
type: 'application/activity+json',
href: remote_actor.uri,
},
],
}
end
before do
stub_request(:get, 'https://remote.domain/users/bob#main-key')
.to_return(
body: updated_remote_actor_json.to_json,
headers: {
'Content-Type' => 'application/activity+json',
},
status: 200
)
stub_request(:get, 'https://remote.domain/users/bob')
.to_return(
body: updated_remote_actor_json.to_json,
headers: {
'Content-Type' => 'application/activity+json',
},
status: 200
)
stub_request(:get, "https://remote.domain/.well-known/webfinger?resource=acct:#{remote_actor_new_handle}")
.to_return(
body: remote_actor_webfinger_response.to_json,
headers: {
'Content-Type' => 'application/json',
},
status: 200
)
Sidekiq::Testing.inline!
end
context 'with a create note' do
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'https://remote.domain/activities/create/1',
type: 'Create',
actor: remote_actor.uri,
object: object_json,
}.with_indifferent_access
end
let(:digest_header) { digest_value(json.to_json) }
let(:signature_header) do
build_signature_string(
remote_actor_keypair,
'https://remote.domain/users/bob#main-key',
"post /users/#{local_actor.username}/inbox",
base_headers.merge(
'Digest' => digest_header
)
)
end
let(:headers) do
base_headers.merge(
'Digest' => digest_header,
'Signature' => signature_header
)
end
it 'creates the note' do
post "/users/#{local_actor.username}/inbox", params: json.to_json, headers: headers
expect(response).to have_http_status(202)
expect(Status.exists?(uri: object_json[:id])).to be(true)
end
it 'does not change the local record of the remote actor' do
post "/users/#{local_actor.username}/inbox", params: json.to_json, headers: headers
expect(remote_actor.reload.username).to eq(remote_actor_original_username)
end
end
context 'with an update actor' do
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'https://remote.domain/activities/update/1',
type: 'Update',
actor: remote_actor.uri,
object: updated_remote_actor_json,
}.with_indifferent_access
end
let(:digest_header) { digest_value(json.to_json) }
let(:signature_header) do
build_signature_string(
remote_actor_keypair,
'https://remote.domain/users/bob#main-key',
"post /users/#{local_actor.username}/inbox",
base_headers.merge(
'Digest' => digest_header
)
)
end
let(:headers) do
base_headers.merge(
'Digest' => digest_header,
'Signature' => signature_header
)
end
it 'does not increase the number of accounts' do
expect do
post "/users/#{local_actor.username}/inbox", params: json.to_json, headers: headers
end.to(not_change { Account.count })
end
it 'updates the remote actors username' do
post "/users/#{local_actor.username}/inbox", params: json.to_json, headers: headers
expect(response).to have_http_status(202)
expect(remote_actor.reload.username).to eq(remote_actor_new_username)
end
end
end
end

View File

@ -93,6 +93,205 @@ RSpec.describe 'Search API' do
expect(response.parsed_body[:accounts].pluck(:id)).to contain_exactly(ana.id.to_s) expect(response.parsed_body[:accounts].pluck(:id)).to contain_exactly(ana.id.to_s)
end end
end end
context 'when a remote actor username has changed' do
let!(:remote_actor_keypair) do
OpenSSL::PKey.read(<<~PEM_TEXT)
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAqIAYvNFGbZ5g4iiK6feSdXD4bDStFM58A7tHycYXaYtzZQpI
eHXAmaXuZzXIwtrP4N0gIk8JNwZvXj2UPS+S07t0V9wNK94he01LV5EMz/GN4eNn
FmDL64HIEuKLvV8TvgjbUPRD6Y5X0UpKi2ZIFLSb96Q5w0Z/k7ntpVKV52y8kz5F
jr/O/0JuHryZe0yItzJh8kzFfeMf0EXzfSnaKvT7P9jhgC6uTre+jXyvVZjiHDrn
qvvucdI3I7DRfXo1OqARBrLjy+TdseUAjNYJ+OuPRI1URIWQI01DCHqcohVu9+Ar
+BiCjFp3ua+XMuJvrvbD61d1Fvig/9nbBRR+8QIDAQABAoIBAAgySHnFWI6gItR3
fkfiqIm80cHCN3Xk1C6iiVu+3oBOZbHpW9R7vl9e/WOA/9O+LPjiSsQOegtWnVvd
RRjrl7Hj20VDlZKv5Mssm6zOGAxksrcVbqwdj+fUJaNJCL0AyyseH0x/IE9T8rDC
I1GH+3tB3JkhkIN/qjipdX5ab8MswEPu8IC4ViTpdBgWYY/xBcAHPw4xuL0tcwzh
FBlf4DqoEVQo8GdK5GAJ2Ny0S4xbXHUURzx/R4y4CCts7niAiLGqd9jmLU1kUTMk
QcXfQYK6l+unLc7wDYAz7sFEHh04M48VjWwiIZJnlCqmQbLda7uhhu8zkF1DqZTu
ulWDGQECgYEA0TIAc8BQBVab979DHEEmMdgqBwxLY3OIAk0b+r50h7VBGWCDPRsC
STD73fQY3lNet/7/jgSGwwAlAJ5PpMXxXiZAE3bUwPmHzgF7pvIOOLhA8O07tHSO
L2mvQe6NPzjZ+6iAO2U9PkClxcvGvPx2OBvisfHqZLmxC9PIVxzruQECgYEAzjM6
BTUXa6T/qHvLFbN699BXsUOGmHBGaLRapFDBfVvgZrwqYQcZpBBhesLdGTGSqwE7
gWsITPIJ+Ldo+38oGYyVys+w/V67q6ud7hgSDTW3hSvm+GboCjk6gzxlt9hQ0t9X
8vfDOYhEXvVUJNv3mYO60ENqQhILO4bQ0zi+VfECgYBb/nUccfG+pzunU0Cb6Dp3
qOuydcGhVmj1OhuXxLFSDG84Tazo7juvHA9mp7VX76mzmDuhpHPuxN2AzB2SBEoE
cSW0aYld413JRfWukLuYTc6hJHIhBTCRwRQFFnae2s1hUdQySm8INT2xIc+fxBXo
zrp+Ljg5Wz90SAnN5TX0AQKBgDaatDOq0o/r+tPYLHiLtfWoE4Dau+rkWJDjqdk3
lXWn/e3WyHY3Vh/vQpEqxzgju45TXjmwaVtPATr+/usSykCxzP0PMPR3wMT+Rm1F
rIoY/odij+CaB7qlWwxj0x/zRbwB7x1lZSp4HnrzBpxYL+JUUwVRxPLIKndSBTza
GvVRAoGBAIVBcNcRQYF4fvZjDKAb4fdBsEuHmycqtRCsnkGOz6ebbEQznSaZ0tZE
+JuouZaGjyp8uPjNGD5D7mIGbyoZ3KyG4mTXNxDAGBso1hrNDKGBOrGaPhZx8LgO
4VXJ+ybXrATf4jr8ccZYsZdFpOphPzz+j55Mqg5vac5P1XjmsGTb
-----END RSA PRIVATE KEY-----
PEM_TEXT
end
let!(:remote_actor_inbox_url) { 'https://remote.domain/users/bob/inbox' }
let!(:remote_actor_original_username) { 'original_username' }
let!(:remote_actor) do
Fabricate(:account,
domain: 'remote.domain',
uri: 'https://remote.domain/users/bob',
private_key: nil,
public_key: remote_actor_keypair.public_key.to_pem,
username: remote_actor_original_username,
protocol: 1, # activitypub
inbox_url: remote_actor_inbox_url)
end
let!(:remote_actor_old_handle) { "#{remote_actor_original_username}@remote.domain" }
let!(:remote_actor_new_username) { 'new_username' }
let!(:remote_actor_json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: remote_actor.uri,
type: 'Person',
preferredUsername: remote_actor_new_username,
inbox: remote_actor.inbox_url,
publicKey: {
id: "#{remote_actor.uri}#main-key",
owner: remote_actor.uri,
publicKeyPem: remote_actor.public_key,
},
}.with_indifferent_access
end
let!(:remote_actor_new_handle) { "#{remote_actor_new_username}@remote.domain" }
let(:webfinger_response) do
{
subject: "acct:#{remote_actor_new_handle}",
links: [
{
rel: 'self',
type: 'application/activity+json',
href: remote_actor.uri,
},
],
}
end
before do
sign_in(user)
tom.follow!(remote_actor)
stub_request(:get, "https://remote.domain/.well-known/webfinger?resource=acct:#{remote_actor_new_handle}")
.to_return(
body: webfinger_response.to_json,
headers: {
'Content-Type' => 'application/json',
},
status: 200
)
stub_request(:get, remote_actor.uri)
.to_return(
body: remote_actor_json.to_json,
headers: {
'Content-Type' => 'application/activity+json',
},
status: 200
)
Sidekiq::Testing.inline!
end
context 'when requesting the old handle' do
let!(:params) { { q: remote_actor_old_handle, resolve: '1' } }
it 'does not increase the number of accounts' do
expect do
get '/api/v2/search', headers: headers, params: params
end.to(not_change { Account.count })
end
it 'does not change the remote actor account' do
get '/api/v2/search', headers: headers, params: params
expect(remote_actor.reload.username).to eq(remote_actor_original_username)
end
it 'returns the remote actor account' do
get '/api/v2/search', headers: headers, params: params
expect(body_as_json[:accounts].pluck(:id)).to contain_exactly(remote_actor.id.to_s)
end
end
context 'when requesting the old handle of a stale account' do
let!(:params) { { q: remote_actor_old_handle, resolve: '1' } }
before do
stub_request(:get, 'https://remote.domain/.well-known/host-meta').to_return(status: 404)
remote_actor.update(last_webfingered_at: 2.days.ago)
end
it 'makes a webfinger request with the old handle' do
stub_request(:get, "https://remote.domain/.well-known/webfinger?resource=acct:#{remote_actor_old_handle}")
get '/api/v2/search', headers: headers, params: params
expect(
a_request(
:get,
"https://remote.domain/.well-known/webfinger?resource=acct:#{remote_actor_old_handle}"
)
).to have_been_made.once
end
it 'does nothing if the webfinger request returns not found' do
stub_request(:get, "https://remote.domain/.well-known/webfinger?resource=acct:#{remote_actor_old_handle}")
.to_return(
status: 404
)
get '/api/v2/search', headers: headers, params: params
expect(body_as_json[:accounts].empty?).to be(true)
expect(remote_actor.reload.username).to eq(remote_actor_original_username)
end
it 'merges the old account with the new account if the webfinger request succeeds' do
stub_request(:get, "https://remote.domain/.well-known/webfinger?resource=acct:#{remote_actor_old_handle}")
.to_return(
body: {
subject: "acct:#{remote_actor_old_handle}",
links: [
{
rel: 'self',
type: 'application/activity+json',
href: remote_actor.uri,
},
],
}.to_json,
headers: {
'Content-Type' => 'application/json',
},
status: 200
)
expect do
get '/api/v2/search', headers: headers, params: params
end.to(not_change { Account.count })
expect(Account.exists?(id: remote_actor.id)).to be(false)
new_remote_actor = Account.find_by(
uri: remote_actor.uri,
username: remote_actor_new_username
)
expect(new_remote_actor.present?).to be(true)
expect(tom.following?(new_remote_actor)).to be(true)
end
end
context 'when requesting the new handle' do
let(:params) { { q: remote_actor_new_handle, resolve: '1' } }
it 'does not increase the number of accounts' do
expect do
get '/api/v2/search', headers: headers, params: params
end.to(not_change { Account.count })
end
it 'merges the old account with the new account' do
get '/api/v2/search', headers: headers, params: params
expect(Account.exists?(id: remote_actor.id)).to be(false)
new_remote_actor = Account.find_by(
uri: remote_actor.uri,
username: remote_actor_new_username
)
expect(new_remote_actor.present?).to be(true)
expect(tom.following?(new_remote_actor)).to be(true)
end
end
end
end end
context 'when search raises syntax error' do context 'when search raises syntax error' do

View File

@ -707,17 +707,4 @@ RSpec.describe 'signature verification concern' do
alias_method :signature_required, :success alias_method :signature_required, :success
end end
end end
def digest_value(body)
"SHA-256=#{Digest::SHA256.base64digest(body)}"
end
def build_signature_string(keypair, key_id, request_target, headers)
algorithm = 'rsa-sha256'
signed_headers = headers.merge({ '(request-target)' => request_target })
signed_string = signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n")
signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
"keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\""
end
end end

View File

@ -1,6 +1,19 @@
# frozen_string_literal: true # frozen_string_literal: true
module SignedRequestHelpers module SignedRequestHelpers
def digest_value(body)
"SHA-256=#{Digest::SHA256.base64digest(body)}"
end
def build_signature_string(keypair, key_id, request_target, headers)
algorithm = 'rsa-sha256'
signed_headers = headers.merge({ '(request-target)' => request_target })
signed_string = signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n")
signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
"keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\""
end
def get(path, headers: nil, sign_with: nil, **args) def get(path, headers: nil, sign_with: nil, **args)
return super(path, headers: headers, **args) if sign_with.nil? return super(path, headers: headers, **args) if sign_with.nil?