Skip to content

FEATURE: Add support for image attachments #197

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions app/models/concerns/discourse_activity_pub/ap/model_callbacks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions app/models/concerns/discourse_activity_pub/ap/type_validations.rb
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions app/models/discourse_activity_pub_attachment.rb
Original file line number Diff line number Diff line change
@@ -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
#
26 changes: 15 additions & 11 deletions app/models/discourse_activity_pub_object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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?
Expand Down Expand Up @@ -159,6 +159,10 @@ def attributed_to
end
end

def attachment
self.attachments
end

def likes_collection
@likes_collection ||=
begin
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class DiscourseActivityPub::AP::Object::DocumentSerializer < DiscourseActivityPub::AP::ObjectSerializer
attributes :media_type
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class DiscourseActivityPub::AP::Object::ImageSerializer < DiscourseActivityPub::AP::ObjectSerializer
attributes :media_type
end
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -30,4 +30,12 @@ def include_content?
def deleted?
!object.stored.model || object.stored.model.trashed?
end

def attachment
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the spec only support one attachment per note/article?

Copy link
Contributor Author

@angusmcleod angusmcleod Apr 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It supports multiple. The spec (and implementers like mastodon) use the singular term attachment, which actually an array of attachments. Yeah, I found it confusing too.

object.attachment.map(&:json)
end

def include_attachment?
object.attachment.present?
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
4 changes: 4 additions & 0 deletions config/locales/server.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions lib/discourse_activity_pub/ap/link.rb
Original file line number Diff line number Diff line change
@@ -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
29 changes: 27 additions & 2 deletions lib/discourse_activity_pub/ap/object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading