Tag linked FeaturedCollection objects over ActivityPub (#38115)

This commit is contained in:
Claire
2026-03-23 14:11:33 +01:00
committed by GitHub
parent 1935f4db79
commit 39d9da3b82
23 changed files with 320 additions and 16 deletions

View File

@@ -42,6 +42,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def process_status
@tags = []
@mentions = []
@tagged_objects = []
@unresolved_mentions = []
@silenced_account_ids = []
@params = {}
@@ -56,6 +57,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
ApplicationRecord.transaction do
@status = Status.create!(@params.merge(quote: @quote))
attach_tags(@status)
attach_tagged_objects(@status)
attach_mentions(@status)
attach_counts(@status)
end
@@ -181,6 +183,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def attach_tagged_objects(status)
@tagged_objects.each do |tagged_object|
tagged_object.status = status
tagged_object.save
end
end
def attach_mentions(status)
@mentions.each do |mention|
mention.status = status
@@ -210,6 +219,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
process_mention tag
elsif equals_or_includes?(tag['type'], 'Emoji')
process_emoji tag
elsif equals_or_includes?(tag['type'], 'FeaturedCollection')
process_tagged_collection tag
end
end
end
@@ -266,6 +277,15 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def process_tagged_collection(tag)
return if tag['id'].blank?
# TODO: We probably want to resolve unknown objects and push them to an `@unresolved_tagged_objects` on failure
collection = ActivityPub::TagManager.instance.uri_to_resource(tag['id'], Collection)
@tagged_objects << TaggedObject.new(uri: ActivityPub::TagManager.instance.uri_for(collection), object: collection, ap_type: 'FeaturedCollection') if collection.present?
end
def process_attachments
return [] if @object['attachment'].nil?

View File

@@ -262,6 +262,14 @@ class ActivityPub::TagManager
uri_to_resource(uri, Account)
end
def uri_to_local_collection(uri)
path_params = Rails.application.routes.recognize_path(uri)
return unless path_params[:controller] == 'collections'
# TODO: check account, but this requires handling potentially two different schemes
Collection.find_by(id: path_params[:id])
end
def uri_to_local_conversation(uri)
path_params = Rails.application.routes.recognize_path(uri)
return unless path_params[:controller] == 'activitypub/contexts'
@@ -279,6 +287,8 @@ class ActivityPub::TagManager
uris_to_local_accounts([uri]).first
when 'Conversation'
uri_to_local_conversation(uri)
when 'Collection'
uri_to_local_collection(uri)
else
StatusFinder.new(uri).status
end

View File

@@ -80,6 +80,7 @@ class Status < ApplicationRecord
has_many :mentions, dependent: :destroy, inverse_of: :status
has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account'
has_many :media_attachments, dependent: :nullify
has_many :tagged_objects, dependent: :destroy
has_many :quotes, foreign_key: 'quoted_status_id', inverse_of: :quoted_status, dependent: :nullify
# The `dependent` option is enabled by the initial `mentions` association declaration
@@ -172,6 +173,7 @@ class Status < ApplicationRecord
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
account: [:account_stat, user: :role],
active_mentions: :account,
tagged_objects: :object,
reblog: [
:application,
:media_attachments,

View File

@@ -0,0 +1,19 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: tagged_objects
#
# id :bigint(8) not null, primary key
# ap_type :string not null
# object_type :string
# uri :string
# created_at :datetime not null
# updated_at :datetime not null
# object_id :bigint(8)
# status_id :bigint(8) not null
#
class TaggedObject < ApplicationRecord
belongs_to :status, inverse_of: :tagged_objects
belongs_to :object, polymorphic: true, optional: true
end

View File

@@ -139,7 +139,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end
def virtual_tags
object.active_mentions.to_a.sort_by(&:id) + object.tags + object.emojis
object.active_mentions.to_a.sort_by(&:id) + object.tags + object.emojis + object.tagged_objects.map(&:object)
end
def atom_uri
@@ -345,8 +345,9 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end
end
class CustomEmojiSerializer < ActivityPub::EmojiSerializer
end
class CustomEmojiSerializer < ActivityPub::EmojiSerializer; end
class CollectionSerializer < ActivityPub::FeaturedCollectionSerializer; end
class OptionSerializer < ActivityPub::Serializer
class RepliesSerializer < ActivityPub::Serializer

View File

@@ -5,7 +5,7 @@ class REST::CollectionSerializer < ActiveModel::Serializer
:local, :sensitive, :discoverable, :item_count,
:created_at, :updated_at
belongs_to :tag, serializer: REST::StatusSerializer::TagSerializer
belongs_to :tag, serializer: REST::ShallowTagSerializer
has_many :items, serializer: REST::CollectionItemSerializer

View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
class REST::ShallowTagSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :name, :url
def url
tag_url(object)
end
end

View File

@@ -28,6 +28,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
has_many :ordered_mentions, key: :mentions
has_many :tags
has_many :emojis, serializer: REST::CustomEmojiSerializer
has_many :tagged_collections, serializer: REST::CollectionSerializer
# Due to a ActiveModel::Serializer quirk, if you change any of the following, have a look at
# updating `app/serializers/rest/shallow_status_serializer.rb` as well
@@ -166,6 +167,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
object.active_mentions.to_a.sort_by(&:id)
end
def tagged_collections
object.tagged_objects.filter_map { |tagged_object| tagged_object.object if tagged_object.ap_type == 'FeaturedCollection' }
end
def quote_approval
{
automatic: object.proper.quote_policy_as_keys(:automatic),
@@ -208,13 +213,5 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
end
class TagSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :name, :url
def url
tag_url(object)
end
end
class TagSerializer < REST::ShallowTagSerializer; end
end

View File

@@ -182,9 +182,10 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
end
def update_metadata!
@raw_tags = []
@raw_tags = []
@raw_mentions = []
@raw_emojis = []
@raw_tagged_objects = []
@raw_emojis = []
as_array(@json['tag']).each do |tag|
if equals_or_includes?(tag['type'], 'Hashtag')
@@ -193,10 +194,13 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
@raw_mentions << tag['href'] if tag['href'].present?
elsif equals_or_includes?(tag['type'], 'Emoji')
@raw_emojis << tag
elsif equals_or_includes?(tag['type'], 'FeaturedCollection')
@raw_tagged_objects << tag if tag['id']
end
end
update_tags!
update_tagged_objects!
update_mentions!
update_emojis!
update_quote!
@@ -229,6 +233,24 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
end
end
def update_tagged_objects!
current_tagged_objects = @raw_tagged_objects.filter_map do |tagged_object|
url = tagged_object['id']
# TODO: We probably want to resolve unknown objects at authoring time
ActivityPub::TagManager.instance.uri_to_resource(url, Collection)
end
# Any previously-unresolved URI would be resolved here
@status.tagged_objects.upsert_all(
current_tagged_objects.uniq.map { |object| { object_type: object.class.name, object_id: object.id, uri: ActivityPub::TagManager.instance.uri_for(object), ap_type: 'FeaturedCollection' } },
unique_by: %w(status_id uri)
)
# Remove unused links
@status.tagged_objects.where.not(uri: current_tagged_objects.map { |object| ActivityPub::TagManager.instance.uri_for(object) }).delete_all
end
def update_mentions!
unresolved_mentions = []

View File

@@ -80,6 +80,7 @@ class PostStatusService < BaseService
process_mentions_service.call(@status)
safeguard_mentions!(@status)
safeguard_private_mention_quote!(@status)
attach_tagged_objects!(@status)
attach_quote!(@status)
antispam = Antispam.new(@status)
@@ -111,6 +112,10 @@ class PostStatusService < BaseService
status.quote.accept! if @quoted_status.local? && StatusPolicy.new(@status.account, @quoted_status).quote?
end
def attach_tagged_objects!(status)
ProcessLinksService.new.call(status)
end
def safeguard_mentions!(status)
return if @options[:allowed_mentions].nil?

View File

@@ -0,0 +1,52 @@
# frozen_string_literal: true
class ProcessLinksService < BaseService
include Payloadable
# Scan status for links to ActivityPub objects and attach them to statuses
# @param [Status] status
def call(status)
return unless status.local?
@status = status
@previous_objects = @status.tagged_objects.includes(:object).to_a
@current_objects = []
Status.transaction do
scan_text!
assign_tagged_objects!
end
end
private
def scan_text!
urls = @status.text.scan(FetchLinkCardService::URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
urls.each do |url|
# We only support `FeaturedCollection` at this time
# TODO: We probably want to resolve unknown objects at authoring time
object = ActivityPub::TagManager.instance.uri_to_resource(url.to_s, Collection)
tagged_object = @previous_objects.find { |x| x.object == object || x.uri == url }
tagged_object ||= @current_objects.find { |x| x.object == object || x.uri == url }
tagged_object ||= @status.tagged_objects.new(object: object, ap_type: 'FeaturedCollection', uri: ActivityPub::TagManager.instance.uri_for(object))
@current_objects << tagged_object
end
end
def assign_tagged_objects!
return unless @status.persisted?
@current_objects.each do |object|
object.save if object.new_record?
end
# If previous objects are no longer contained in the text, remove them to lighten the database
removed_objects = @previous_objects - @current_objects
TaggedObject.where(id: removed_objects.map(&:id)).delete_all unless removed_objects.empty?
end
end

View File

@@ -134,6 +134,7 @@ class UpdateStatusService < BaseService
def update_metadata!
ProcessHashtagsService.new.call(@status)
ProcessMentionsService.new.call(@status)
ProcessLinksService.new.call(@status)
end
def broadcast_updates!