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
"
+ 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
\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
",
+ )
+ 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
",
+ )
+ 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