diff --git a/app/models/concerns/discourse_activity_pub/ap/identifier_validations.rb b/app/models/concerns/discourse_activity_pub/ap/identifier_validations.rb index a83a66f2..e6e3aa56 100644 --- a/app/models/concerns/discourse_activity_pub/ap/identifier_validations.rb +++ b/app/models/concerns/discourse_activity_pub/ap/identifier_validations.rb @@ -4,21 +4,16 @@ module AP module IdentifierValidations extend ActiveSupport::Concern include JsonLd + include TypeValidations included do - before_validation :ensure_ap_type before_validation :ensure_ap_key, if: :local? before_validation :ensure_ap_id, if: :local? - validates :ap_type, presence: true validates :ap_key, uniqueness: true, allow_nil: true # foreign objects don't have keys validates :ap_id, uniqueness: true, presence: true end - def ap - @ap ||= DiscourseActivityPub::AP::Object.get_klass(ap_type)&.new(stored: self) - end - def local? !!self.local end @@ -27,27 +22,6 @@ def remote? !local? end - def _model - self.respond_to?(:model) ? self.model : self.actor.model - end - - def ensure_ap_type - self.ap_type = _model.activity_pub_default_object_type if !self.ap_type - - unless ap - self.errors.add( - :ap_type, - I18n.t( - "activerecord.errors.models.discourse_activity_pub_activity.attributes.ap_type.invalid", - ), - ) - - raise ActiveRecord::RecordInvalid - end - - self.ap_type = ap.type - end - def ensure_ap_key self.ap_key = generate_key if !self.ap_key end diff --git a/app/models/concerns/discourse_activity_pub/ap/model_callbacks.rb b/app/models/concerns/discourse_activity_pub/ap/model_callbacks.rb index 7849527e..e613bb9a 100644 --- a/app/models/concerns/discourse_activity_pub/ap/model_callbacks.rb +++ b/app/models/concerns/discourse_activity_pub/ap/model_callbacks.rb @@ -144,6 +144,20 @@ def update_activity_pub_activity_object if performing_activity.create? || performing_activity.update? performing_activity_object.name = self.activity_pub_name if self.activity_pub_name performing_activity_object.content = self.activity_pub_content + + if self.activity_pub_attachments.present? + self.activity_pub_attachments.each do |attachment| + performing_activity_object.attachments.build( + object_id: performing_activity_object.id, + object_type: performing_activity_object.class.name, + ap_type: attachment.type, + url: attachment.url.href, + name: attachment.name, + media_type: attachment.media_type, + ) + end + end + performing_activity_object.save! end end diff --git a/app/models/concerns/discourse_activity_pub/ap/type_validations.rb b/app/models/concerns/discourse_activity_pub/ap/type_validations.rb new file mode 100644 index 00000000..4d3ddd1f --- /dev/null +++ b/app/models/concerns/discourse_activity_pub/ap/type_validations.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +module DiscourseActivityPub + module AP + module TypeValidations + extend ActiveSupport::Concern + + included do + before_validation :ensure_ap_type + validates :ap_type, presence: true + end + + def ap + @ap ||= DiscourseActivityPub::AP::Object.get_klass(ap_type)&.new(stored: self) + end + + def _model + self.respond_to?(:model) ? self.model : self.actor.model + end + + def ensure_ap_type + self.ap_type = _model.activity_pub_default_object_type if !self.ap_type && _model.present? + + unless ap + self.errors.add( + :ap_type, + I18n.t( + "activerecord.errors.models.discourse_activity_pub_activity.attributes.ap_type.invalid", + ), + ) + + raise ActiveRecord::RecordInvalid + end + + self.ap_type = ap.type + end + end + end +end diff --git a/app/models/discourse_activity_pub_attachment.rb b/app/models/discourse_activity_pub_attachment.rb new file mode 100644 index 00000000..237aa296 --- /dev/null +++ b/app/models/discourse_activity_pub_attachment.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class DiscourseActivityPubAttachment < ActiveRecord::Base + include DiscourseActivityPub::AP::TypeValidations + include DiscourseActivityPub::AP::ObjectValidations + + belongs_to :object, class_name: "DiscourseActivityPubObject", polymorphic: true + + validate :validate_media_type + + protected + + def validate_media_type + unless MiniMime.lookup_by_content_type(self.media_type) + self.errors.add( + :media_type, + I18n.t( + "activerecord.errors.models.discourse_activity_pub_attachment.attributes.media_type.invalid", + ), + ) + raise ActiveRecord::RecordInvalid + end + end +end + +# == Schema Information +# +# Table name: discourse_activity_pub_attachments +# +# id :bigint not null, primary key +# ap_type :string not null +# object_id :bigint not null +# object_type :string not null +# url :string +# name :string +# media_type :string(200) +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/app/models/discourse_activity_pub_object.rb b/app/models/discourse_activity_pub_object.rb index c35b9576..c0572a71 100644 --- a/app/models/discourse_activity_pub_object.rb +++ b/app/models/discourse_activity_pub_object.rb @@ -7,12 +7,21 @@ class DiscourseActivityPubObject < ActiveRecord::Base belongs_to :model, -> { unscope(where: :deleted_at) }, polymorphic: true, optional: true belongs_to :collection, class_name: "DiscourseActivityPubCollection", foreign_key: "collection_id" + belongs_to :reply_to, + class_name: "DiscourseActivityPubObject", + primary_key: "ap_id", + foreign_key: "reply_to_id" + belongs_to :attributed_to, + class_name: "DiscourseActivityPubActor", + primary_key: "ap_id", + foreign_key: "attributed_to_id" - has_many :activities, class_name: "DiscourseActivityPubActivity", foreign_key: "object_id" has_one :create_activity, -> { where(ap_type: DiscourseActivityPub::AP::Activity::Create.type) }, class_name: "DiscourseActivityPubActivity", foreign_key: "object_id" + + has_many :activities, class_name: "DiscourseActivityPubActivity", foreign_key: "object_id" has_many :announcements, class_name: "DiscourseActivityPubActivity", through: :activities, @@ -21,20 +30,11 @@ class DiscourseActivityPubObject < ActiveRecord::Base -> { likes }, class_name: "DiscourseActivityPubActivity", foreign_key: "object_id" - - belongs_to :reply_to, - class_name: "DiscourseActivityPubObject", - primary_key: "ap_id", - foreign_key: "reply_to_id" has_many :replies, class_name: "DiscourseActivityPubObject", primary_key: "ap_id", foreign_key: "reply_to_id" - - belongs_to :attributed_to, - class_name: "DiscourseActivityPubActor", - primary_key: "ap_id", - foreign_key: "attributed_to_id" + has_many :attachments, class_name: "DiscourseActivityPubAttachment", foreign_key: "object_id" def url if local? @@ -159,6 +159,10 @@ def attributed_to end end + def attachment + self.attachments + end + def likes_collection @likes_collection ||= begin diff --git a/app/serializers/discourse_activity_pub/ap/object/article_serializer.rb b/app/serializers/discourse_activity_pub/ap/object/article_serializer.rb index 4badb449..ddcfda3e 100644 --- a/app/serializers/discourse_activity_pub/ap/object/article_serializer.rb +++ b/app/serializers/discourse_activity_pub/ap/object/article_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class DiscourseActivityPub::AP::Object::ArticleSerializer < DiscourseActivityPub::AP::ObjectSerializer - attributes :content, :inReplyTo, :url, :updated + attributes :content, :inReplyTo, :url, :updated, :attachment def inReplyTo object.in_reply_to @@ -18,4 +18,12 @@ def include_content? def deleted? !object.stored.model || object.stored.model.trashed? end + + def attachment + object.attachment.map(&:json) + end + + def include_attachment? + object.attachment.present? + end end diff --git a/app/serializers/discourse_activity_pub/ap/object/document_serializer.rb b/app/serializers/discourse_activity_pub/ap/object/document_serializer.rb new file mode 100644 index 00000000..f7662e7d --- /dev/null +++ b/app/serializers/discourse_activity_pub/ap/object/document_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class DiscourseActivityPub::AP::Object::DocumentSerializer < DiscourseActivityPub::AP::ObjectSerializer + attributes :media_type +end diff --git a/app/serializers/discourse_activity_pub/ap/object/image_serializer.rb b/app/serializers/discourse_activity_pub/ap/object/image_serializer.rb new file mode 100644 index 00000000..1cfb059f --- /dev/null +++ b/app/serializers/discourse_activity_pub/ap/object/image_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class DiscourseActivityPub::AP::Object::ImageSerializer < DiscourseActivityPub::AP::ObjectSerializer + attributes :media_type +end diff --git a/app/serializers/discourse_activity_pub/ap/object/note_serializer.rb b/app/serializers/discourse_activity_pub/ap/object/note_serializer.rb index bba51c69..5e280bcd 100644 --- a/app/serializers/discourse_activity_pub/ap/object/note_serializer.rb +++ b/app/serializers/discourse_activity_pub/ap/object/note_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class DiscourseActivityPub::AP::Object::NoteSerializer < DiscourseActivityPub::AP::ObjectSerializer - attributes :content, :inReplyTo, :context + attributes :content, :inReplyTo, :context, :attachment def content content = object.content @@ -30,4 +30,12 @@ def include_content? def deleted? !object.stored.model || object.stored.model.trashed? end + + def attachment + object.attachment.map(&:json) + end + + def include_attachment? + object.attachment.present? + end end diff --git a/app/serializers/discourse_activity_pub/ap/object_serializer.rb b/app/serializers/discourse_activity_pub/ap/object_serializer.rb index e4a5c728..a2f6543c 100644 --- a/app/serializers/discourse_activity_pub/ap/object_serializer.rb +++ b/app/serializers/discourse_activity_pub/ap/object_serializer.rb @@ -20,8 +20,9 @@ def attributes(*args) hash end - def context - object.context + def include_id? + return false if object.attachment? + object.id.present? end def include_context? diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 719a2a2b..f9bd238a 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -21,6 +21,10 @@ en: credentials: required: "are required" invalid: "is not valid" + discourse_activity_pub_attachment: + attributes: + media_type: + invalid: "is not valid" discourse_activity_pub: attributes: model_type: diff --git a/db/migrate/20250319134150_create_discourse_activity_pub_attachments.rb b/db/migrate/20250319134150_create_discourse_activity_pub_attachments.rb new file mode 100644 index 00000000..9ffd5424 --- /dev/null +++ b/db/migrate/20250319134150_create_discourse_activity_pub_attachments.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +class CreateDiscourseActivityPubAttachments < ActiveRecord::Migration[7.2] + def change + create_table :discourse_activity_pub_attachments do |t| + t.string :ap_type, null: false + t.bigint :object_id, null: false + t.string :object_type, null: false + t.string :url + t.string :name + t.string :media_type, limit: 200 + + t.timestamps + end + end +end diff --git a/lib/discourse_activity_pub/ap/link.rb b/lib/discourse_activity_pub/ap/link.rb new file mode 100644 index 00000000..065e3a37 --- /dev/null +++ b/lib/discourse_activity_pub/ap/link.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +module DiscourseActivityPub + module AP + class Link + attr_accessor :value + + def initialize(value = nil) + @value = value + end + + def type + "Link" + end + + def href + return value if value.is_a?(String) + value[:href] if value.is_a?(Hash) + end + + def media_type + value[:mediaType] if value.is_a?(Hash) + end + + def name + value[:name] if value.is_a?(Hash) + end + end + end +end diff --git a/lib/discourse_activity_pub/ap/object.rb b/lib/discourse_activity_pub/ap/object.rb index e5003cf8..dd776628 100644 --- a/lib/discourse_activity_pub/ap/object.rb +++ b/lib/discourse_activity_pub/ap/object.rb @@ -7,6 +7,8 @@ class Object include HasErrors include Handlers + ATTACHMENT_TYPES = %w[Image Document] + attr_writer :json attr_writer :attributed_to attr_writer :context @@ -50,8 +52,16 @@ def actor? base_type == "Actor" end + def attachment? + ATTACHMENT_TYPES.include?(type) + end + def url - stored.respond_to?(:url) && stored&.url + if stored + stored.respond_to?(:url) && stored&.url + elsif json.present? + Link.new(json[:url]) + end end def audience @@ -95,7 +105,11 @@ def summary end def name - stored.respond_to?(:name) && stored&.name + if stored.present? + stored.respond_to?(:name) && stored&.name + elsif json.present? + json[:name] + end end def context @@ -110,6 +124,17 @@ def delivered_to @delivered_to ||= [] end + def attachment + if stored.respond_to?(:attachment) + @attachment ||= (stored.attachment || []).map { |a| a.ap } + elsif json.present? && json[:attachment].present? + json[:attachment].each_with_object([]) do |attachment_json, result| + obj = AP::Object.factory(attachment_json) + result << obj if obj.present? + end + end + end + def cache @cache ||= {} end diff --git a/lib/discourse_activity_pub/ap/object/document.rb b/lib/discourse_activity_pub/ap/object/document.rb new file mode 100644 index 00000000..d6571abc --- /dev/null +++ b/lib/discourse_activity_pub/ap/object/document.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +module DiscourseActivityPub + module AP + class Object + class Document < Object + def type + "Document" + end + + def media_type + if stored && stored.respond_to?(:media_type) + stored.media_type + elsif json + json[:mediaType] + end + end + + def can_belong_to + %i[remote] + end + end + end + end +end diff --git a/lib/discourse_activity_pub/ap/object/image.rb b/lib/discourse_activity_pub/ap/object/image.rb new file mode 100644 index 00000000..91baab00 --- /dev/null +++ b/lib/discourse_activity_pub/ap/object/image.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +module DiscourseActivityPub + module AP + class Object + class Image < Object + def type + "Image" + end + + def media_type + if stored && stored.respond_to?(:media_type) + stored.media_type + elsif json + json[:mediaType] + end + end + + def can_belong_to + %i[remote] + end + end + end + end +end diff --git a/lib/discourse_activity_pub/post_handler.rb b/lib/discourse_activity_pub/post_handler.rb index ca75ac08..bf58b4ee 100644 --- a/lib/discourse_activity_pub/post_handler.rb +++ b/lib/discourse_activity_pub/post_handler.rb @@ -31,7 +31,7 @@ def create( return nil if !import_mode && !new_topic && !reply_to && !topic_id params = { - raw: object.content, + raw: build_post_raw, skip_events: true, skip_validations: true, skip_jobs: true, @@ -149,6 +149,25 @@ def self.ensure_activity_has_post(activity) protected + def supported_image?(media_type) + extension = MiniMime.lookup_by_content_type(media_type)&.extension + FileHelper.supported_images.include?(extension) + end + + def build_post_raw + raw = object.content + if object.attachments.present? + object.attachments.each do |attachment| + raw += attached_image_html(attachment) if supported_image?(attachment.media_type) + end + end + raw + end + + def attached_image_html(attachment) + "\n\"#{attachment.name}\"/" + end + def can_create_topic?(category) category&.activity_pub_ready? end diff --git a/plugin.rb b/plugin.rb index 33d95412..ba5c63cc 100644 --- a/plugin.rb +++ b/plugin.rb @@ -49,6 +49,7 @@ require_relative "lib/discourse_activity_pub/context_resolver" require_relative "lib/discourse_activity_pub/ap" require_relative "lib/discourse_activity_pub/ap/handlers" + require_relative "lib/discourse_activity_pub/ap/link" require_relative "lib/discourse_activity_pub/ap/object" require_relative "lib/discourse_activity_pub/ap/actor" require_relative "lib/discourse_activity_pub/ap/actor/group" @@ -70,11 +71,14 @@ require_relative "lib/discourse_activity_pub/ap/activity/like" require_relative "lib/discourse_activity_pub/ap/object/note" require_relative "lib/discourse_activity_pub/ap/object/article" + require_relative "lib/discourse_activity_pub/ap/object/document" + require_relative "lib/discourse_activity_pub/ap/object/image" require_relative "lib/discourse_activity_pub/ap/collection" require_relative "lib/discourse_activity_pub/ap/collection/collection_page" require_relative "lib/discourse_activity_pub/ap/collection/ordered_collection_page" require_relative "lib/discourse_activity_pub/ap/collection/ordered_collection" require_relative "lib/discourse_activity_pub/admin" + require_relative "app/models/concerns/discourse_activity_pub/ap/type_validations" require_relative "app/models/concerns/discourse_activity_pub/ap/identifier_validations" require_relative "app/models/concerns/discourse_activity_pub/ap/object_validations" require_relative "app/models/concerns/discourse_activity_pub/ap/model_validations" @@ -90,6 +94,7 @@ require_relative "app/models/discourse_activity_pub_log" require_relative "app/models/discourse_activity_pub_object" require_relative "app/models/discourse_activity_pub_collection" + require_relative "app/models/discourse_activity_pub_attachment" require_relative "app/jobs/discourse_activity_pub_process" require_relative "app/jobs/discourse_activity_pub_deliver" require_relative "app/jobs/discourse_activity_pub_log_rotate" @@ -135,6 +140,8 @@ require_relative "app/serializers/discourse_activity_pub/ap/actor/person_serializer" require_relative "app/serializers/discourse_activity_pub/ap/object/note_serializer" require_relative "app/serializers/discourse_activity_pub/ap/object/article_serializer" + require_relative "app/serializers/discourse_activity_pub/ap/object/image_serializer" + require_relative "app/serializers/discourse_activity_pub/ap/object/document_serializer" require_relative "app/serializers/discourse_activity_pub/ap/collection_serializer" require_relative "app/serializers/discourse_activity_pub/ap/collection/ordered_collection_serializer" require_relative "app/serializers/discourse_activity_pub/about_serializer" @@ -550,6 +557,19 @@ @activity_pub_topic_trashed ||= Topic.with_deleted.find_by(id: self.topic_id) end add_to_class(:post, :activity_pub_object_id) { activity_pub_object&.ap_id } + add_to_class(:post, :activity_pub_attachments) do + uploads + .where(extension: FileHelper.supported_images) + .map do |upload| + DiscourseActivityPub::AP::Object::Image.new( + json: { + name: upload.original_filename, + url: UrlHelper.absolute(upload.url), + mediaType: MiniMime.lookup_by_extension(upload.extension).content_type, + }, + ) + end + end add_model_callback(:post, :after_destroy) do # We need these to create a Delete activity when the post is actually destroyed @@ -1166,6 +1186,32 @@ object_id: object.json[:id], ) end + + if object.json[:attachment].present? + object.json[:attachment].each do |json| + attachment = DiscourseActivityPub::AP::Object.factory(json) + if attachment + # Some platforms (e.g. Mastodon) put attachment url media types on the attachment itself, + # instead of on a Link object in the url attribute. Technically this violates the specification, + # but we need to support it nevertheless. See further https://www.w3.org/TR/activitystreams-vocabulary/#dfn-mediatype + media_type = attachment.url.media_type || attachment.media_type + name = attachment.url.name || attachment.name + + begin + DiscourseActivityPubAttachment.create( + object_id: object.stored.id, + object_type: "DiscourseActivityPubObject", + ap_type: attachment.type, + url: attachment.url.href, + name: name, + media_type: media_type, + ) + rescue ActiveRecord::RecordInvalid => error + # fail silently if an attachment does not validate + end + end + end + end end end end diff --git a/spec/fabricators/discourse_activity_pub_attachment_fabricator.rb b/spec/fabricators/discourse_activity_pub_attachment_fabricator.rb new file mode 100644 index 00000000..1d236012 --- /dev/null +++ b/spec/fabricators/discourse_activity_pub_attachment_fabricator.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +Fabricator(:discourse_activity_pub_attachment) do + ap_type { "Image" } + media_type { "image/png" } + + before_create do |object| + filename = "#{SecureRandom.hex(8)}-image" + object.url = "https://local.com/attachment/image/#{filename}.png" + object.name = filename + end +end diff --git a/spec/jobs/discourse_activity_pub_deliver_spec.rb b/spec/jobs/discourse_activity_pub_deliver_spec.rb index b7ea0da6..86e11926 100644 --- a/spec/jobs/discourse_activity_pub_deliver_spec.rb +++ b/spec/jobs/discourse_activity_pub_deliver_spec.rb @@ -335,6 +335,29 @@ def find_announce end end end + + context "when object has attachments" do + let!(:topic) { Fabricate(:topic, category: group.model) } + let!(:post) { Fabricate(:post, topic: topic) } + let!(:note) { Fabricate(:discourse_activity_pub_object_note, local: true, model: post) } + let!(:attachment1) { Fabricate(:discourse_activity_pub_attachment, object: note) } + let!(:attachment2) { Fabricate(:discourse_activity_pub_attachment, object: note) } + let!(:activity) do + Fabricate(:discourse_activity_pub_activity_create, object: note, actor: group) + end + + it "publishes the attachments" do + json = published_json(activity) + expect(json[:object][:attachment].size).to eq(2) + expect(json[:object][:attachment].first[:url]).to eq(attachment1.url) + expect(json[:object][:attachment].second[:url]).to eq(attachment2.url) + end + + it "performs the right request" do + expect_request(body: published_json(activity), actor_id: group.id, uri: person.inbox) + execute_job(object_id: activity.id, from_actor_id: activity.actor.id) + end + end end end end diff --git a/spec/lib/discourse_activity_pub/ap/activity/create_spec.rb b/spec/lib/discourse_activity_pub/ap/activity/create_spec.rb index 9e4c9405..8c8e5b10 100644 --- a/spec/lib/discourse_activity_pub/ap/activity/create_spec.rb +++ b/spec/lib/discourse_activity_pub/ap/activity/create_spec.rb @@ -426,5 +426,241 @@ end end end + + context "with a Note with attachments" do + let!(:delivered_to) { category.activity_pub_actor.ap_id } + let!(:follow) do + Fabricate( + :discourse_activity_pub_follow, + follower: category.activity_pub_actor, + followed: actor, + ) + end + + before { stub_object_request(actor) } + + context "with supported attachment types" do + let!(:attachment_url_1) { "https://example.com/files/cats.png" } + let!(:attachment_url_2) { "https://example.com/files/dogs.jpeg" } + let!(:attachment_url_3) { "https://example.com/files/autodesk-dog.3ds" } + let!(:attachment_name_1) { "A cool cat" } + let!(:attachments_with_supported_mediatypes) do + [ + { + type: "Document", + mediaType: "image/png", + url: attachment_url_1, + name: attachment_name_1, + }, + { type: "Image", mediaType: "image/jpeg", url: attachment_url_2 }, + ] + end + let!(:attachments_with_unsupported_mediatypes) do + [ + { + type: "Document", + mediaType: "image/png", + url: attachment_url_1, + name: attachment_name_1, + }, + { type: "Image", mediaType: "image/x-3ds", url: attachment_url_3 }, + ] + end + let!(:attachments_with_invalid_mediatypes) do + [ + { + type: "Document", + mediaType: "image/png", + url: attachment_url_1, + name: attachment_name_1, + }, + { type: "Image", mediaType: "invalid-type", url: attachment_url_3 }, + ] + end + let!(:attachments_with_link_objects) do + [ + { + type: "Document", + url: { + type: "Link", + href: attachment_url_1, + mediaType: "image/png", + name: attachment_name_1, + }, + }, + { type: "Image", mediaType: "image/jpeg", url: attachment_url_2 }, + ] + end + + context "with supported media types" do + let!(:new_post_json) do + build_activity_json( + actor: actor, + object: + build_object_json( + name: "My cool topic title", + attributed_to: actor, + attachments: attachments_with_supported_mediatypes, + ), + type: "Create", + ) + end + + it "creates records for all attachments" do + perform_process(new_post_json, delivered_to) + object = + DiscourseActivityPubObject.find_by(ap_type: "Note", attributed_to_id: actor.ap_id) + expect(object.attachments.size).to eq(2) + expect(object.attachments.first.ap_type).to eq("Document") + expect(object.attachments.first.url).to eq(attachment_url_1) + expect(object.attachments.first.media_type).to eq("image/png") + expect(object.attachments.second.ap_type).to eq("Image") + expect(object.attachments.second.url).to eq(attachment_url_2) + expect(object.attachments.second.media_type).to eq("image/jpeg") + end + + it "adds the attachment urls the post" do + perform_process(new_post_json, delivered_to) + post = + Post.find_by( + raw: + "#{new_post_json[:object][:content]}\n\"#{attachment_name_1}\"/\n\"\"/", + ) + expect(post.present?).to be(true) + end + end + + context "with supported and unsupported media types" do + let!(:new_post_json) do + build_activity_json( + actor: actor, + object: + build_object_json( + name: "My cool topic title", + attributed_to: actor, + attachments: attachments_with_unsupported_mediatypes, + ), + type: "Create", + ) + end + + it "creates records for all attachments" do + perform_process(new_post_json, delivered_to) + object = + DiscourseActivityPubObject.find_by(ap_type: "Note", attributed_to_id: actor.ap_id) + expect(object.attachments.size).to eq(2) + expect(object.attachments.first.ap_type).to eq("Document") + expect(object.attachments.first.url).to eq(attachment_url_1) + expect(object.attachments.first.media_type).to eq("image/png") + expect(object.attachments.second.ap_type).to eq("Image") + expect(object.attachments.second.url).to eq(attachment_url_3) + expect(object.attachments.second.media_type).to eq("image/x-3ds") + end + + it "adds urls for attachments with supported media types to the post" do + perform_process(new_post_json, delivered_to) + post = + Post.find_by( + raw: + "#{new_post_json[:object][:content]}\n\"#{attachment_name_1}\"/", + ) + expect(post.present?).to be(true) + end + end + + context "with supported and invalid media types" do + let!(:new_post_json) do + build_activity_json( + actor: actor, + object: + build_object_json( + name: "My cool topic title", + attributed_to: actor, + attachments: attachments_with_invalid_mediatypes, + ), + type: "Create", + ) + end + + it "creates records for attachments with valid media types" do + perform_process(new_post_json, delivered_to) + object = + DiscourseActivityPubObject.find_by(ap_type: "Note", attributed_to_id: actor.ap_id) + expect(object.attachments.size).to eq(1) + expect(object.attachments.first.ap_type).to eq("Document") + expect(object.attachments.first.url).to eq(attachment_url_1) + expect(object.attachments.first.media_type).to eq("image/png") + end + + it "adds urls for attachments with supported media types to the post" do + perform_process(new_post_json, delivered_to) + post = + Post.find_by( + raw: + "#{new_post_json[:object][:content]}\n\"#{attachment_name_1}\"/", + ) + expect(post.present?).to be(true) + end + end + + context "with Link objects" do + let!(:new_post_json) do + build_activity_json( + actor: actor, + object: + build_object_json( + name: "My cool topic title", + attributed_to: actor, + attachments: attachments_with_link_objects, + ), + type: "Create", + ) + end + + it "creates records for all attachments" do + perform_process(new_post_json, delivered_to) + object = + DiscourseActivityPubObject.find_by(ap_type: "Note", attributed_to_id: actor.ap_id) + expect(object.attachments.size).to eq(2) + expect(object.attachments.first.ap_type).to eq("Document") + expect(object.attachments.first.url).to eq(attachment_url_1) + expect(object.attachments.first.media_type).to eq("image/png") + expect(object.attachments.second.ap_type).to eq("Image") + expect(object.attachments.second.url).to eq(attachment_url_2) + expect(object.attachments.second.media_type).to eq("image/jpeg") + end + end + end + + context "with unsupported attachment types" do + let!(:object_with_unsupported_attachment_json) do + build_object_json( + name: "My cool topic title", + attributed_to: actor, + attachments: [ + { type: "PropertyValue", name: "Homepage", value: "https://myhomepage.com" }, + ], + ) + end + let!(:new_post_json) do + build_activity_json( + actor: actor, + object: object_with_unsupported_attachment_json, + type: "Create", + ) + end + + it "does not create attachments" do + expect { perform_process(new_post_json, delivered_to) }.not_to change { + DiscourseActivityPubAttachment.count + } + end + + it "does not add anything to the post" do + perform_process(new_post_json, delivered_to) + expect(Post.exists?(raw: new_post_json[:object][:content])).to be(true) + end + end + end end end diff --git a/spec/models/post_spec.rb b/spec/models/post_spec.rb index 230966fd..3153767b 100644 --- a/spec/models/post_spec.rb +++ b/spec/models/post_spec.rb @@ -2817,5 +2817,56 @@ def perform_delete end end end + + context "with uploads" do + let!(:topic) { Fabricate(:topic, category: category) } + let!(:user) { Fabricate(:user) } + + before do + toggle_activity_pub(category, publication_type: "full_topic") + topic.create_activity_pub_collection! + end + + def perform_create + post.perform_activity_pub_activity(:create) + post.reload + end + + context "with a supported media type" do + let!(:post) { Fabricate(:post_with_uploaded_image, topic: topic) } + + before do + DiscourseActivityPub::ActorHandler.update_or_create_actor(post.user) + post.link_post_uploads + end + + it "creates attachments" do + perform_create + expect(post.activity_pub_object.attachments.size).to eq(1) + expect(post.activity_pub_object.attachments.first.ap_type).to eq("Image") + expect(post.activity_pub_object.attachments.first.name).to eq( + post.uploads.first.original_filename, + ) + expect(post.activity_pub_object.attachments.first.media_type).to eq("image/png") + expect(post.activity_pub_object.attachments.first.url).to eq( + UrlHelper.absolute(post.uploads.first.url), + ) + end + end + + context "with an unsupported media type" do + let!(:post) { Fabricate(:post_with_an_attachment, topic: topic) } + + before do + DiscourseActivityPub::ActorHandler.update_or_create_actor(post.user) + post.link_post_uploads + end + + it "does not create attachments" do + perform_create + expect(post.activity_pub_object.attachments.size).to eq(0) + end + end + end end end diff --git a/spec/multisite/activity_pub_multisite_spec.rb b/spec/multisite/activity_pub_multisite_spec.rb index ca1cbfc9..a86c5c28 100644 --- a/spec/multisite/activity_pub_multisite_spec.rb +++ b/spec/multisite/activity_pub_multisite_spec.rb @@ -21,6 +21,10 @@ def process_case(actor) stub_object_request(read_integration_json("case_2", "actor_2")) stub_object_request(read_integration_json("case_2", "actor_3")) stub_object_request(read_integration_json("case_2", "context_1")) + stub_request( + :get, + "https://community.nodebb.org/assets/uploads/files/1738535378096-1000007220.jpg", + ).to_return(status: 200) 6.times do |index| process_json(read_integration_json("case_2", "received_#{index + 1}"), actor) diff --git a/spec/plugin_helper.rb b/spec/plugin_helper.rb index 05469f9d..d85f361b 100644 --- a/spec/plugin_helper.rb +++ b/spec/plugin_helper.rb @@ -150,7 +150,8 @@ def build_object_json( cc: nil, audience: nil, attributed_to: nil, - context: nil + context: nil, + attachments: [] ) _json = { "@context": "https://www.w3.org/ns/activitystreams", @@ -173,6 +174,7 @@ def build_object_json( attributed_to end _json[:context] = context if context + _json[:attachment] = attachments if attachments.present? _json.with_indifferent_access end