From 0114b389b012a8ab3da9e5a3e331ebe53e1cd016 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 22 Jul 2026 10:39:29 +0100 Subject: [PATCH] Add support for `Link` objects in `attachment` (#36104) Co-authored-by: Claire --- app/lib/activitypub/activity/create.rb | 9 ++- .../activitypub/parser/preview_card_parser.rb | 21 +++++++ .../activitypub/note_serializer.rb | 14 ++++- .../process_status_update_service.rb | 14 ++++- app/services/fetch_link_card_service.rb | 4 +- app/workers/link_crawl_worker.rb | 4 +- spec/lib/activitypub/activity/create_spec.rb | 61 ++++++++++++++++--- .../activitypub/note_serializer_spec.rb | 19 ++++++ 8 files changed, 127 insertions(+), 19 deletions(-) create mode 100644 app/lib/activitypub/parser/preview_card_parser.rb diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 0ed0a10f377..0e932a695aa 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -50,6 +50,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity @tags = [] @mentions = [] @tagged_objects = [] + @links = [] @unresolved_mentions = [] @unresolved_collections = [] @silenced_account_ids = [] @@ -82,7 +83,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def distribute # Spread out crawling randomly to avoid DDoSing the link - LinkCrawlWorker.perform_in(rand(DISTRIBUTE_DELAY), @status.id) + LinkCrawlWorker.perform_in(rand(DISTRIBUTE_DELAY), @status.id, @links.first) # Distribute into home and list feeds and notify mentioned accounts ::DistributionWorker.perform_async(@status.id, { 'silenced_account_ids' => @silenced_account_ids }) if @options[:override_timestamps] || @status.within_realtime_window? @@ -297,6 +298,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity media_attachments = [] as_array(@object['attachment']).each do |attachment| + if attachment['href'].present? + preview_card_parser = ActivityPub::Parser::PreviewCardParser.new(attachment) + @links << preview_card_parser.url if preview_card_parser.url.present? + next + end + media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment) next if media_attachment_parser.remote_url.blank? || media_attachments.size >= Status::MEDIA_ATTACHMENTS_LIMIT diff --git a/app/lib/activitypub/parser/preview_card_parser.rb b/app/lib/activitypub/parser/preview_card_parser.rb new file mode 100644 index 00000000000..5c679d8fce1 --- /dev/null +++ b/app/lib/activitypub/parser/preview_card_parser.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class ActivityPub::Parser::PreviewCardParser + include JsonLdHelper + + def initialize(json) + @json = json + end + + # @param [PreviewCard] previous_record + def significantly_changes?(previous_record) + url != previous_record.url + end + + def url + url = Addressable::URI.parse(@json['href'])&.normalize&.to_s + url unless unsupported_uri_scheme?(url) + rescue Addressable::URI::InvalidURIError + nil + end +end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index d67770933b8..5caab3f95e8 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -135,7 +135,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer end def virtual_attachments - object.ordered_media_attachments + object.ordered_media_attachments + [object.preview_card].compact end def virtual_tags @@ -248,6 +248,18 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer } end + class PreviewCardSerializer < ActivityPub::Serializer + attributes :type, :href + + def type + 'Link' + end + + def href + object.original_url.presence || object.url + end + end + class MediaAttachmentSerializer < ActivityPub::Serializer context_extensions :blurhash, :focal_point diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 954e5f0de09..d36eca842fd 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -22,6 +22,8 @@ class ActivityPub::ProcessStatusUpdateService < BaseService @quote_changed = false @request_id = request_id @quote = nil + @next_media_attachments = [] + @next_links = [] return @status if !expected_type? || already_updated_more_recently? @@ -87,9 +89,14 @@ class ActivityPub::ProcessStatusUpdateService < BaseService def update_media_attachments! previous_media_attachments = @status.media_attachments.to_a previous_media_attachments_ids = @status.ordered_media_attachment_ids || previous_media_attachments.map(&:id) - @next_media_attachments = [] as_array(@json['attachment']).each do |attachment| + if attachment['href'].present? + preview_card_parser = ActivityPub::Parser::PreviewCardParser.new(attachment) + @next_links << preview_card_parser.url if preview_card_parser.url.present? + next + end + media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment) next if media_attachment_parser.remote_url.blank? || @next_media_attachments.size > Status::MEDIA_ATTACHMENTS_LIMIT @@ -385,7 +392,8 @@ class ActivityPub::ProcessStatusUpdateService < BaseService def update_counts! likes = @status_parser.favourites_count - shares = @status_parser.reblogs_count + shares = @status_parser.reblogs_count + return if likes.nil? && shares.nil? @status.status_stat.tap do |status_stat| @@ -438,7 +446,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService def reset_preview_card! @status.reset_preview_card! - LinkCrawlWorker.perform_in(rand(CRAWL_DELAY), @status.id) + LinkCrawlWorker.perform_in(rand(CRAWL_DELAY), @status.id, @next_links.first) end def broadcast_updates! diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index 53b6861349b..4c9fc2e230f 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -15,9 +15,9 @@ class FetchLinkCardService < BaseService ) }iox - def call(status) + def call(status, original_url = nil) @status = status - @original_url = parse_urls + @original_url = original_url || parse_urls return if @original_url.nil? || @status.with_preview_card? || @status.with_media? || @status.quote.present? diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb index c63af1e43aa..d49a1728d92 100644 --- a/app/workers/link_crawl_worker.rb +++ b/app/workers/link_crawl_worker.rb @@ -5,8 +5,8 @@ class LinkCrawlWorker sidekiq_options queue: 'pull', retry: 0 - def perform(status_id) - FetchLinkCardService.new.call(Status.find(status_id)) + def perform(status_id, url = nil) + FetchLinkCardService.new.call(Status.find(status_id), url) rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordNotUnique true end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index dac1b2a33e4..8724a840149 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -277,24 +277,16 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'with a standalone' do + context 'with a standalone post' do let(:object_json) { build_object } - it 'creates status' do + it 'creates a status with expected text and direct privacy' do expect { subject.perform }.to change(sender.statuses, :count).by(1) status = sender.statuses.first expect(status).to_not be_nil expect(status.text).to eq 'Lorem ipsum' - end - - it 'missing to/cc defaults to direct privacy' do - expect { subject.perform }.to change(sender.statuses, :count).by(1) - - status = sender.statuses.first - - expect(status).to_not be_nil expect(status.visibility).to eq 'direct' end end @@ -333,6 +325,55 @@ RSpec.describe ActivityPub::Activity::Create do end end + context 'with a public status mentioning multiple links' do + let(:object_json) do + build_object( + to: 'as:Public', + content: 'This is a test https://joinmastodon.org https://activitypub.rocks/' + ) + end + + it 'creates a status and schedules link fetching job for first link' do + expect { subject.perform } + .to change(sender.statuses, :count).by(1) + + status = sender.statuses.first + + expect(status).to_not be_nil + + expect(LinkCrawlWorker).to have_enqueued_sidekiq_job(status.id, nil) + + expect(status.visibility).to eq 'public' + end + end + + context 'with a public status specifying a link attachment (FEP-8967)' do + let(:object_json) do + build_object( + to: 'as:Public', + content: 'This is a test https://joinmastodon.org https://activitypub.rocks/', + attachment: [ + { + href: 'https://activitypub.rocks/', + }, + ] + ) + end + + it 'creates a status and schedules link fetching job for specified link' do + expect { subject.perform } + .to change(sender.statuses, :count).by(1) + + status = sender.statuses.first + + expect(status).to_not be_nil + + expect(LinkCrawlWorker).to have_enqueued_sidekiq_job(status.id, 'https://activitypub.rocks/') + + expect(status.visibility).to eq 'public' + end + end + context 'when public with Public' do let(:object_json) do build_object( diff --git a/spec/serializers/activitypub/note_serializer_spec.rb b/spec/serializers/activitypub/note_serializer_spec.rb index dc71a19c6e2..2176d37cdce 100644 --- a/spec/serializers/activitypub/note_serializer_spec.rb +++ b/spec/serializers/activitypub/note_serializer_spec.rb @@ -107,4 +107,23 @@ RSpec.describe ActivityPub::NoteSerializer do }) end end + + context 'with a preview card' do + let(:preview_card) { Fabricate(:preview_card) } + + before do + PreviewCardsStatus.create(status: parent, preview_card: preview_card) + end + + it 'has the expected shape (using FEP-8967)' do + expect(subject).to include({ + 'type' => 'Note', + 'attachment' => contain_exactly( + a_hash_including( + 'href' => preview_card.url + ) + ), + }) + end + end end