Compare commits

...

6 Commits

Author SHA1 Message Date
Claire
067a428325
Merge 2cadc7ab3b into 3b52dca405 2025-07-11 17:06:09 +00:00
Claire
3b52dca405
Fix quote attributes missing from Mastodon's context (#35354)
Some checks failed
Check i18n / check-i18n (push) Has been cancelled
Chromatic / Run Chromatic (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (ruby) (push) Has been cancelled
Check formatting / lint (push) Has been cancelled
JavaScript Linting / lint (push) Has been cancelled
Ruby Linting / lint (push) Has been cancelled
JavaScript Testing / test (push) Has been cancelled
Historical data migration test / test (14-alpine) (push) Has been cancelled
Historical data migration test / test (15-alpine) (push) Has been cancelled
Historical data migration test / test (16-alpine) (push) Has been cancelled
Historical data migration test / test (17-alpine) (push) Has been cancelled
Ruby Testing / build (production) (push) Has been cancelled
Ruby Testing / build (test) (push) Has been cancelled
Ruby Testing / test (.ruby-version) (push) Has been cancelled
Ruby Testing / test (3.2) (push) Has been cancelled
Ruby Testing / test (3.3) (push) Has been cancelled
Ruby Testing / ImageMagick tests (.ruby-version) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.2) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.3) (push) Has been cancelled
Ruby Testing / End to End testing (.ruby-version) (push) Has been cancelled
Ruby Testing / End to End testing (3.2) (push) Has been cancelled
Ruby Testing / End to End testing (3.3) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
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
Claire
2cadc7ab3b Add support for numeric IDs to local account lookup via URI
Some checks failed
Chromatic / Run Chromatic (push) Has been cancelled
2025-07-07 15:59:44 +02:00
Claire
467d61bce7 Add id_scheme attribute to Account 2025-07-07 15:59:43 +02:00
Claire
d0b3137723 Add support for numeric-based URIs for local accounts
Actors would be served at `/ap/users/:user_id` and statuses at `/ap/statuses/:id`
2025-07-07 15:59:05 +02:00
15 changed files with 167 additions and 47 deletions

View File

@ -71,6 +71,10 @@ class AccountsController < ApplicationController
params[:username] params[:username]
end end
def account_id_param
params[:id]
end
def skip_temporary_suspension_response? def skip_temporary_suspension_response?
request.format == :json request.format == :json
end end

View File

@ -18,7 +18,11 @@ module AccountOwnedConcern
end end
def set_account def set_account
@account = Account.find_local!(username_param) @account = username_param.present? ? Account.find_local!(username_param) : Account.local.find(account_id_param)
end
def account_id_param
params[:numeric_account_id]
end end
def username_param def username_param

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

@ -39,11 +39,23 @@ class ActivityPub::TagManager
case target.object_type case target.object_type
when :person when :person
target.instance_actor? ? instance_actor_url : account_url(target) if target.instance_actor?
instance_actor_url
elsif target.numeric_ap_id?
ap_account_url(target.id)
else
account_url(target)
end
when :note, :comment, :activity when :note, :comment, :activity
if target.account.numeric_ap_id?
return activity_ap_status_url(target.account, target) if target.reblog?
ap_status_url(target)
else
return activity_account_status_url(target.account, target) if target.reblog? return activity_account_status_url(target.account, target) if target.reblog?
account_status_url(target.account, target) account_status_url(target.account, target)
end
when :emoji when :emoji
emoji_url(target) emoji_url(target)
when :flag when :flag
@ -59,6 +71,10 @@ class ActivityPub::TagManager
account_url(username: username) account_url(username: username)
end end
def uri_for_account_id(id)
ap_account_url(id: id)
end
def generate_uri_for(_target) def generate_uri_for(_target)
URI.join(root_url, 'payloads', SecureRandom.uuid) URI.join(root_url, 'payloads', SecureRandom.uuid)
end end
@ -66,55 +82,67 @@ class ActivityPub::TagManager
def activity_uri_for(target) def activity_uri_for(target)
raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local? raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local?
activity_account_status_url(target.account, target) target.account.numeric_ap_id? ? activity_ap_status_url(target) : activity_account_status_url(target.account, target)
end end
def replies_uri_for(target, page_params = nil) def replies_uri_for(target, page_params = nil)
raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local? raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local?
account_status_replies_url(target.account, target, page_params) target.account.numeric_ap_id? ? ap_status_replies_url(target, page_params) : account_status_replies_url(target.account, target, page_params)
end end
def likes_uri_for(target) def likes_uri_for(target)
raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local? raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local?
account_status_likes_url(target.account, target) target.account.numeric_ap_id? ? ap_status_likes_url(target) : account_status_likes_url(target.account, target)
end end
def shares_uri_for(target) def shares_uri_for(target)
raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local? raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local?
account_status_shares_url(target.account, target) target.account.numeric_ap_id? ? ap_status_shares_url(target) : account_status_shares_url(target.account, target)
end end
def following_uri_for(target, ...) def following_uri_for(target, ...)
raise ArgumentError, 'target must be a local account' unless target.local? raise ArgumentError, 'target must be a local account' unless target.local?
account_following_index_url(target, ...) target.numeric_ap_id? ? ap_account_following_index_url(target.id, ...) : account_following_index_url(target, ...)
end end
def followers_uri_for(target, ...) def followers_uri_for(target, ...)
return target.followers_url.presence unless target.local? return target.followers_url.presence unless target.local?
account_followers_url(target, ...) target.numeric_ap_id? ? ap_account_followers_url(target.id, ...) : account_followers_url(target, ...)
end end
def collection_uri_for(target, ...) def collection_uri_for(target, ...)
raise NotImplementedError unless target.local? raise ArgumentError, 'target must be a local account' unless target.local?
account_collection_url(target, ...) target.numeric_ap_id? ? ap_account_collection_url(target.id, ...) : account_collection_url(target, ...)
end end
def inbox_uri_for(target) def inbox_uri_for(target)
raise NotImplementedError unless target.local? raise ArgumentError, 'target must be a local account' unless target.local?
target.instance_actor? ? instance_actor_inbox_url : account_inbox_url(target) if target.instance_actor?
instance_actor_inbox_url
elsif target.numeric_ap_id?
ap_account_inbox_url(target.id)
else
account_inbox_url(target)
end
end end
def outbox_uri_for(target, ...) def outbox_uri_for(target, ...)
raise NotImplementedError unless target.local? raise ArgumentError, 'target must be a local account' unless target.local?
target.instance_actor? ? instance_actor_outbox_url(...) : account_outbox_url(target, ...) if target.instance_actor?
instance_actor_outbox_url(...)
elsif target.numeric_ap_id?
ap_account_outbox_url(target.id, ...)
else
account_outbox_url(target, ...)
end
end end
# Primary audience of a status # Primary audience of a status
@ -247,10 +275,9 @@ class ActivityPub::TagManager
path_params = Rails.application.routes.recognize_path(uri) path_params = Rails.application.routes.recognize_path(uri)
# TODO: handle numeric IDs
case path_params[:controller] case path_params[:controller]
when 'accounts' when 'accounts'
[:username, path_params[:username]] path_params.key?(:username) ? [:username, path_params[:username]] : [:id, path_params[:id]]
when 'instance_actors' when 'instance_actors'
[:id, -99] [:id, -99]
end end

View File

@ -51,6 +51,7 @@
# requested_review_at :datetime # requested_review_at :datetime
# indexable :boolean default(FALSE), not null # indexable :boolean default(FALSE), not null
# attribution_domains :string default([]), is an Array # attribution_domains :string default([]), is an Array
# id_scheme :integer default("username_ap_id")
# #
class Account < ApplicationRecord class Account < ApplicationRecord
@ -104,6 +105,7 @@ class Account < ApplicationRecord
enum :protocol, { ostatus: 0, activitypub: 1 } enum :protocol, { ostatus: 0, activitypub: 1 }
enum :suspension_origin, { local: 0, remote: 1 }, prefix: true enum :suspension_origin, { local: 0, remote: 1 }, prefix: true
enum :id_scheme, { username_ap_id: 0, numeric_ap_id: 1 }
validates :username, presence: true validates :username, presence: true
validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? } validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }

View File

@ -215,8 +215,10 @@ module Account::Interactions
def local_followers_hash def local_followers_hash
Rails.cache.fetch("followers_hash:#{id}:local") do Rails.cache.fetch("followers_hash:#{id}:local") do
digest = "\x00" * 32 digest = "\x00" * 32
followers.where(domain: nil).pluck_each(:username) do |username| # TODO
Xorcist.xor!(digest, Digest::SHA256.digest(ActivityPub::TagManager.instance.uri_for_username(username))) followers.where(domain: nil).pluck_each('false as numeric_ap_id', :id, :username) do |numeric_ap_id, id, username|
uri = numeric_ap_id ? ActivityPub::TagManager.instance.uri_for_account_id(id) : ActivityPub::TagManager.instance.uri_for_username(username)
Xorcist.xor!(digest, Digest::SHA256.digest(uri))
end end
digest.unpack1('H*') digest.unpack1('H*')
end end

View File

@ -95,7 +95,19 @@ Rails.application.routes.draw do
get '/authorize_follow', to: redirect { |_, request| "/authorize_interaction?#{request.params.to_query}" } get '/authorize_follow', to: redirect { |_, request| "/authorize_interaction?#{request.params.to_query}" }
resources :accounts, path: 'users', only: [:show], param: :username do concern :account_resources do
resources :followers, only: [:index], controller: :follower_accounts
resources :following, only: [:index], controller: :following_accounts
scope module: :activitypub do
resource :outbox, only: [:show]
resource :inbox, only: [:create]
resources :collections, only: [:show]
resource :followers_synchronization, only: [:show]
end
end
resources :accounts, path: 'users', only: [:show], param: :username, concerns: :account_resources do
resources :statuses, only: [:show] do resources :statuses, only: [:show] do
member do member do
get :activity get :activity
@ -106,15 +118,19 @@ Rails.application.routes.draw do
resources :likes, only: [:index], module: :activitypub resources :likes, only: [:index], module: :activitypub
resources :shares, only: [:index], module: :activitypub resources :shares, only: [:index], module: :activitypub
end end
end
resources :followers, only: [:index], controller: :follower_accounts scope path: 'ap', as: 'ap' do
resources :following, only: [:index], controller: :following_accounts resources :accounts, path: 'users', only: [:show], param: :id, concerns: :account_resources do
resources :statuses, module: :activitypub, only: [:show] do
member do
get :activity
end
scope module: :activitypub do resources :replies, only: [:index]
resource :outbox, only: [:show] resources :likes, only: [:index]
resource :inbox, only: [:create] resources :shares, only: [:index]
resources :collections, only: [:show] end
resource :followers_synchronization, only: [:show]
end end
end end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddIdSchemeToAccounts < ActiveRecord::Migration[8.0]
def change
add_column :accounts, :id_scheme, :integer, default: 0
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do ActiveRecord::Schema[8.0].define(version: 2025_07_07_120259) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@ -198,6 +198,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_27_132728) do
t.datetime "requested_review_at", precision: nil t.datetime "requested_review_at", precision: nil
t.boolean "indexable", default: false, null: false t.boolean "indexable", default: false, null: false
t.string "attribution_domains", default: [], array: true t.string "attribution_domains", default: [], array: true
t.integer "id_scheme", default: 0
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
t.index ["domain", "id"], name: "index_accounts_on_domain_and_id" t.index ["domain", "id"], name: "index_accounts_on_domain_and_id"

View File

@ -5,6 +5,14 @@ require 'rails_helper'
RSpec.describe 'Accounts show response' do RSpec.describe 'Accounts show response' do
let(:account) { Fabricate(:account) } let(:account) { Fabricate(:account) }
context 'with numeric-based identifiers' do
it 'returns http success' do
get "/ap/users/#{account.id}"
expect(response).to have_http_status(200)
end
end
context 'with an unapproved account' do context 'with an unapproved account' do
before { account.user.update(approved: false) } before { account.user.update(approved: false) }

View File

@ -6,7 +6,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do
subject { described_class.new } subject { described_class.new }
let(:actor) { Fabricate(:account, domain: 'example.com', uri: 'http://example.com/account', inbox_url: 'http://example.com/inbox') } let(:actor) { Fabricate(:account, domain: 'example.com', uri: 'http://example.com/account', inbox_url: 'http://example.com/inbox') }
let(:alice) { Fabricate(:account, username: 'alice') } let(:alice) { Fabricate(:account, username: 'alice', id_scheme: :numeric_ap_id) }
let(:bob) { Fabricate(:account, username: 'bob') } let(:bob) { Fabricate(:account, username: 'bob') }
let(:eve) { Fabricate(:account, username: 'eve') } let(:eve) { Fabricate(:account, username: 'eve') }
let(:mallory) { Fabricate(:account, username: 'mallory') } let(:mallory) { Fabricate(:account, username: 'mallory') }