Merge tag 'v4.4.0-rc.1' into chinwag-next

This commit is contained in:
Mike Barnes 2025-09-14 11:47:14 +10:00
commit fbbcaf4efd
2660 changed files with 83548 additions and 52192 deletions

View file

@ -20,9 +20,9 @@ class ActivityPub::Activity
end
class << self
def factory(json, account, **options)
def factory(json, account, **)
@json = json
klass&.new(json, account, **options)
klass&.new(json, account, **)
end
private
@ -57,6 +57,8 @@ class ActivityPub::Activity
ActivityPub::Activity::Remove
when 'Move'
ActivityPub::Activity::Move
when 'QuoteRequest'
ActivityPub::Activity::QuoteRequest
end
end
end
@ -130,12 +132,7 @@ class ActivityPub::Activity
def first_mentioned_local_account
audience = (as_array(@json['to']) + as_array(@json['cc'])).map { |x| value_or_id(x) }.uniq
local_usernames = audience.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }
.map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) }
return if local_usernames.empty?
Account.local.where(username: local_usernames).first
ActivityPub::TagManager.instance.uris_to_local_accounts(audience).first
end
def first_local_follower

View file

@ -3,6 +3,7 @@
class ActivityPub::Activity::Announce < ActivityPub::Activity
def perform
return reject_payload! if delete_arrived_first?(@json['id']) || !related_to_local_activity?
return reject_payload! if @object.nil?
with_redis_lock("announce:#{value_or_id(@object)}") do
original_status = status_from_object

View file

@ -45,15 +45,20 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@unresolved_mentions = []
@silenced_account_ids = []
@params = {}
@quote = nil
@quote_uri = nil
process_status_params
process_tags
process_quote
process_audience
ApplicationRecord.transaction do
@status = Status.create!(@params)
attach_tags(@status)
attach_mentions(@status)
attach_counts(@status)
attach_quote(@status)
end
resolve_thread(@status)
@ -78,7 +83,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
def process_status_params
@status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: @account.followers_url, object: @object)
@status_parser = ActivityPub::Parser::StatusParser.new(
@json,
followers_collection: @account.followers_url,
actor_uri: ActivityPub::TagManager.instance.uri_for(@account),
object: @object
)
attachment_ids = process_attachments.take(Status::MEDIA_ATTACHMENTS_LIMIT).map(&:id)
@ -100,6 +110,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
media_attachment_ids: attachment_ids,
ordered_media_attachment_ids: attachment_ids,
poll: process_poll,
quote_approval_policy: @status_parser.quote_policy,
}
end
@ -176,6 +187,30 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def attach_counts(status)
likes = @status_parser.favourites_count
shares = @status_parser.reblogs_count
return if likes.nil? && shares.nil?
status.status_stat.tap do |status_stat|
status_stat.untrusted_reblogs_count = shares unless shares.nil?
status_stat.untrusted_favourites_count = likes unless likes.nil?
status_stat.save if status_stat.changed?
end
end
def attach_quote(status)
return if @quote.nil?
@quote.status = status
@quote.save
embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @json['context'])
ActivityPub::VerifyQuoteService.new.call(@quote, fetchable_quoted_uri: @quote_uri, prefetched_quoted_object: embedded_quote, request_id: @options[:request_id])
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, @quote.id, @quote_uri, { 'request_id' => @options[:request_id] })
end
def process_tags
return if @object['tag'].nil?
@ -190,6 +225,15 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def process_quote
@quote_uri = @status_parser.quote_uri
return if @quote_uri.blank?
approval_uri = @status_parser.quote_approval_uri
approval_uri = nil if unsupported_uri_scheme?(approval_uri)
@quote = Quote.new(account: @account, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?)
end
def process_hashtag(tag)
return if tag['name'].blank?
@ -209,7 +253,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
return if account.nil?
@mentions << Mention.new(account: account, silent: false)
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
@unresolved_mentions << tag['href']
end
@ -260,7 +304,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
media_attachment.download_file!
media_attachment.download_thumbnail!
media_attachment.save
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
RedownloadMediaWorker.perform_in(rand(30..600).seconds, media_attachment.id)
rescue Seahorse::Client::NetworkingError => e
Rails.logger.warn "Error storing media attachment: #{e}"
@ -325,7 +369,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
collection = @object['replies']
return if collection.blank?
replies = ActivityPub::FetchRepliesService.new.call(status, collection, allow_synchronous_requests: false, request_id: @options[:request_id])
replies = ActivityPub::FetchRepliesService.new.call(status.account.uri, collection, allow_synchronous_requests: false, request_id: @options[:request_id])
return unless replies.nil?
uri = value_or_id(collection)
@ -399,11 +443,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def addresses_local_accounts?
return true if @options[:delivered_to_account_id]
local_usernames = (audience_to + audience_cc).uniq.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }.map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) }
return false if local_usernames.empty?
Account.local.exists?(username: local_usernames)
ActivityPub::TagManager.instance.uris_to_local_accounts((audience_to + audience_cc).uniq).exists?
end
def tombstone_exists?

View file

@ -5,7 +5,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
if @account.uri == object_uri
delete_person
else
delete_note
delete_object
end
end
@ -17,7 +17,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
end
end
def delete_note
def delete_object
return if object_uri.nil?
with_redis_lock("delete_status_in_progress:#{object_uri}", raise_on_failure: false) do
@ -32,21 +32,38 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
Tombstone.find_or_create_by(uri: object_uri, account: @account)
end
@status = Status.find_by(uri: object_uri, account: @account)
@status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
return if @status.nil?
forwarder.forward! if forwarder.forwardable?
delete_now!
case @object['type']
when 'QuoteAuthorization'
revoke_quote
when 'Note', 'Question'
delete_status
else
delete_status || revoke_quote
end
end
end
def delete_status
@status = Status.find_by(uri: object_uri, account: @account)
@status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
return if @status.nil?
forwarder.forward! if forwarder.forwardable?
RemoveStatusService.new.call(@status, redraft: false)
true
end
def revoke_quote
@quote = Quote.find_by(approval_uri: object_uri, quoted_account: @account)
return if @quote.nil?
ActivityPub::Forwarder.new(@account, @json, @quote.status).forward!
@quote.reject!
end
def forwarder
@forwarder ||= ActivityPub::Forwarder.new(@account, @json, @status)
end
def delete_now!
RemoveStatusService.new.call(@status, redraft: false)
end
end

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
class ActivityPub::Activity::QuoteRequest < ActivityPub::Activity
include Payloadable
def perform
return if non_matching_uri_hosts?(@account.uri, @json['id'])
quoted_status = status_from_uri(object_uri)
return if quoted_status.nil? || !quoted_status.account.local? || !quoted_status.distributable?
# For now, we don't support being quoted by external servers
reject_quote_request!(quoted_status)
end
private
def reject_quote_request!(quoted_status)
quote = Quote.new(
quoted_status: quoted_status,
quoted_account: quoted_status.account,
status: Status.new(account: @account, uri: @json['instrument']),
account: @account,
activity_uri: @json['id']
)
json = Oj.dump(serialize_payload(quote, ActivityPub::RejectQuoteRequestSerializer))
ActivityPub::DeliveryWorker.perform_async(json, quoted_status.account_id, @account.inbox_url)
end
end

View file

@ -17,7 +17,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
options = serialization_options(options)
serialized_hash = serializer.serializable_hash(options.merge(named_contexts: named_contexts, context_extensions: context_extensions))
serialized_hash = serialized_hash.select { |k, _| options[:fields].include?(k) } if options[:fields]
serialized_hash = serialized_hash.slice(*options[:fields]) if options[:fields]
serialized_hash = self.class.transform_key_casing!(serialized_hash, instance_options)
{ '@context': serialized_context(named_contexts, context_extensions) }.merge(serialized_hash)

View file

@ -15,13 +15,15 @@ class ActivityPub::Parser::MediaAttachmentParser
end
def remote_url
Addressable::URI.parse(@json['url'])&.normalize&.to_s
url = Addressable::URI.parse(url_to_href(@json['url']))&.normalize&.to_s
url unless unsupported_uri_scheme?(url)
rescue Addressable::URI::InvalidURIError
nil
end
def thumbnail_remote_url
Addressable::URI.parse(@json['icon'].is_a?(Hash) ? @json['icon']['url'] : @json['icon'])&.normalize&.to_s
url = Addressable::URI.parse(@json['icon'].is_a?(Hash) ? @json['icon']['url'] : @json['icon'])&.normalize&.to_s
url unless unsupported_uri_scheme?(url)
rescue Addressable::URI::InvalidURIError
nil
end
@ -41,7 +43,7 @@ class ActivityPub::Parser::MediaAttachmentParser
end
def file_content_type
@json['mediaType']
@json['mediaType'] || url_to_media_type(@json['url'])
end
private

View file

@ -8,6 +8,7 @@ class ActivityPub::Parser::StatusParser
# @param [Hash] json
# @param [Hash] options
# @option options [String] :followers_collection
# @option options [String] :actor_uri
# @option options [Hash] :object
def initialize(json, **options)
@json = json
@ -28,7 +29,10 @@ class ActivityPub::Parser::StatusParser
end
def url
url_to_href(@object['url'], 'text/html') if @object['url'].present?
return if @object['url'].blank?
url = url_to_href(@object['url'], 'text/html')
url unless unsupported_uri_scheme?(url)
end
def text
@ -93,8 +97,70 @@ class ActivityPub::Parser::StatusParser
lang.presence && NORMALIZED_LOCALE_NAMES.fetch(lang.downcase.to_sym, lang)
end
def favourites_count
@object['likes']['totalItems'] if @object.is_a?(Hash) && @object['likes'].is_a?(Hash)
end
def reblogs_count
@object['shares']['totalItems'] if @object.is_a?(Hash) && @object['shares'].is_a?(Hash)
end
def quote_policy
flags = 0
policy = @object.dig('interactionPolicy', 'canQuote')
return flags if policy.blank?
flags |= quote_subpolicy(policy['automaticApproval'])
flags <<= 16
flags |= quote_subpolicy(policy['manualApproval'])
flags
end
def quote_uri
%w(quote _misskey_quote quoteUrl quoteUri).filter_map do |key|
value_or_id(as_array(@object[key]).first)
end.first
end
def legacy_quote?
!@object.key?('quote')
end
# The inlined quote; out of the attributes we support, only `https://w3id.org/fep/044f#quote` explicitly supports inlined objects
def quoted_object
as_array(@object['quote']).first
end
def quote_approval_uri
as_array(@object['quoteAuthorization']).first
end
private
def quote_subpolicy(subpolicy)
flags = 0
allowed_actors = as_array(subpolicy)
allowed_actors.uniq!
flags |= Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] if allowed_actors.delete('as:Public') || allowed_actors.delete('Public') || allowed_actors.delete('https://www.w3.org/ns/activitystreams#Public')
flags |= Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] if allowed_actors.delete(@options[:followers_collection])
# TODO: we don't actually store that collection URI
# flags |= Status::QUOTE_APPROVAL_POLICY_FLAGS[:followed]
# Remove the special-meaning actor URI
allowed_actors.delete(@options[:actor_uri])
# Tagged users are always allowed, so remove them
allowed_actors -= as_array(@object['tag']).filter_map { |tag| tag['href'] if equals_or_includes?(tag['type'], 'Mention') }
# Any unrecognized actor is marked as unknown
flags |= Status::QUOTE_APPROVAL_POLICY_FLAGS[:unknown] unless allowed_actors.empty?
flags
end
def raw_language_code
if content_language_map?
@object['contentMap'].keys.first

View file

@ -4,6 +4,7 @@ require 'singleton'
class ActivityPub::TagManager
include Singleton
include JsonLdHelper
include RoutingHelper
CONTEXT = 'https://www.w3.org/ns/activitystreams'
@ -13,11 +14,11 @@ class ActivityPub::TagManager
}.freeze
def public_collection?(uri)
uri == COLLECTIONS[:public] || uri == 'as:Public' || uri == 'Public'
uri == COLLECTIONS[:public] || %w(as:Public Public).include?(uri)
end
def url_for(target)
return target.url if target.respond_to?(:local?) && !target.local?
return unsupported_uri_scheme?(target.url) ? nil : target.url if target.respond_to?(:local?) && !target.local?
return unless target.respond_to?(:object_type)
@ -86,8 +87,34 @@ class ActivityPub::TagManager
account_status_shares_url(target.account, target)
end
def followers_uri_for(target)
target.local? ? account_followers_url(target) : target.followers_url.presence
def following_uri_for(target, ...)
raise ArgumentError, 'target must be a local account' unless target.local?
account_following_index_url(target, ...)
end
def followers_uri_for(target, ...)
return target.followers_url.presence unless target.local?
account_followers_url(target, ...)
end
def collection_uri_for(target, ...)
raise NotImplementedError unless target.local?
account_collection_url(target, ...)
end
def inbox_uri_for(target)
raise NotImplementedError unless target.local?
target.instance_actor? ? instance_actor_inbox_url : account_inbox_url(target)
end
def outbox_uri_for(target, ...)
raise NotImplementedError unless target.local?
target.instance_actor? ? instance_actor_outbox_url(...) : account_outbox_url(target, ...)
end
# Primary audience of a status
@ -99,7 +126,7 @@ class ActivityPub::TagManager
when 'public'
[COLLECTIONS[:public]]
when 'unlisted', 'private'
[account_followers_url(status.account)]
[followers_uri_for(status.account)]
when 'direct', 'limited'
if status.account.silenced?
# Only notify followers if the account is locally silenced
@ -133,7 +160,7 @@ class ActivityPub::TagManager
case status.visibility
when 'public'
cc << account_followers_url(status.account)
cc << followers_uri_for(status.account)
when 'unlisted'
cc << COLLECTIONS[:public]
end
@ -177,6 +204,19 @@ class ActivityPub::TagManager
path_params[param]
end
def uris_to_local_accounts(uris)
usernames = []
ids = []
uris.each do |uri|
param, value = uri_to_local_account_params(uri)
usernames << value.downcase if param == :username
ids << value if param == :id
end
Account.local.with_username(usernames).or(Account.local.where(id: ids))
end
def uri_to_actor(uri)
uri_to_resource(uri, Account)
end
@ -187,7 +227,7 @@ class ActivityPub::TagManager
if local_uri?(uri)
case klass.name
when 'Account'
klass.find_local(uri_to_local_id(uri, :username))
uris_to_local_accounts([uri]).first
else
StatusFinder.new(uri).status
end
@ -199,4 +239,20 @@ class ActivityPub::TagManager
rescue ActiveRecord::RecordNotFound
nil
end
private
def uri_to_local_account_params(uri)
return unless local_uri?(uri)
path_params = Rails.application.routes.recognize_path(uri)
# TODO: handle numeric IDs
case path_params[:controller]
when 'accounts'
[:username, path_params[:username]]
when 'instance_actors'
[:id, -99]
end
end
end