Skip to content
Snippets Groups Projects
Commit 8c45cd0e authored by Eugen Rochko's avatar Eugen Rochko Committed by GitHub
Browse files

Improve ActivityPub representations (#3844)

* Improve webfinger templates and make tests more flexible

* Clean up AS2 representation of actor

* Refactor outbox

* Create activities representation

* Add representations of followers/following collections, do not redirect /users/:username route if format is empty

* Remove unused translations

* ActivityPub endpoint for single statuses, add ActivityPub::TagManager for better
URL/URI generation

* Add ActivityPub::TagManager#to

* Represent all attachments as Document instead of Image/Video specifically
(Because for remote ones we may not know for sure)

Add mentions and hashtags representation to AP notes

* Add AP-resolvable hashtag URIs

* Use ActiveModelSerializers for ActivityPub

* Clean up unused translations

* Separate route for object and activity

* Adjust cc/to matrices

* Add to/cc to activities, ensure announce activity embeds target status and
not the wrapper status, add "id" to all collections
parent 3fbf1bf3
No related branches found
No related tags found
No related merge requests found
Showing
with 406 additions and 141 deletions
......@@ -16,7 +16,9 @@ class AccountsController < ApplicationController
render xml: AtomSerializer.render(AtomSerializer.new.feed(@account, @entries.to_a))
end
format.activitystreams2
format.json do
render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter
end
end
end
......
# frozen_string_literal: true
class ActivityPub::OutboxesController < Api::BaseController
before_action :set_account
def show
@statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
@statuses = cache_collection(@statuses, Status)
render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
end
private
def set_account
@account = Account.find_local!(params[:account_username])
end
def outbox_presenter
ActivityPub::CollectionPresenter.new(
id: account_outbox_url(@account),
type: :ordered,
current: account_outbox_url(@account),
size: @account.statuses_count,
items: @statuses
)
end
end
# frozen_string_literal: true
class Api::ActivityPub::ActivitiesController < Api::BaseController
include Authorization
# before_action :set_follow, only: [:show_follow]
before_action :set_status, only: [:show_status]
respond_to :activitystreams2
# Show a status in AS2 format, as either an Announce (reblog) or a Create (post) activity.
def show_status
authorize @status, :show?
if @status.reblog?
render :show_status_announce
else
render :show_status_create
end
end
private
def set_status
@status = Status.find(params[:id])
end
end
# frozen_string_literal: true
class Api::ActivityPub::NotesController < Api::BaseController
include Authorization
before_action :set_status
respond_to :activitystreams2
def show
authorize @status, :show?
end
private
def set_status
@status = Status.find(params[:id])
end
end
# frozen_string_literal: true
class Api::ActivityPub::OutboxController < Api::BaseController
before_action :set_account
respond_to :activitystreams2
def show
if params[:max_id] || params[:since_id]
show_outbox_page
else
show_base_outbox
end
end
private
def show_base_outbox
@statuses = Status.as_outbox_timeline(@account)
@statuses = cache_collection(@statuses)
set_maps(@statuses)
set_first_last_page(@statuses)
render :show
end
def show_outbox_page
all_statuses = Status.as_outbox_timeline(@account)
@statuses = all_statuses.paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
all_statuses = cache_collection(all_statuses)
@statuses = cache_collection(@statuses)
set_maps(@statuses)
set_first_last_page(all_statuses)
@next_page_url = api_activitypub_outbox_url(pagination_params(max_id: @statuses.last.id)) unless @statuses.empty?
@prev_page_url = api_activitypub_outbox_url(pagination_params(since_id: @statuses.first.id)) unless @statuses.empty?
@paginated = @next_page_url || @prev_page_url
@part_of_url = api_activitypub_outbox_url
set_pagination_headers(@next_page_url, @prev_page_url)
render :show_page
end
def cache_collection(raw)
super(raw, Status)
end
def set_account
@account = Account.find(params[:id])
end
def set_first_last_page(statuses) # rubocop:disable Style/AccessorMethodName
return if statuses.empty?
@first_page_url = api_activitypub_outbox_url(max_id: statuses.first.id + 1)
@last_page_url = api_activitypub_outbox_url(since_id: statuses.last.id - 1)
end
def pagination_params(core_params)
params.permit(:local, :limit).merge(core_params)
end
end
......@@ -5,5 +5,25 @@ class FollowerAccountsController < ApplicationController
def index
@follows = Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account)
respond_to do |format|
format.html
format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
end
end
end
private
def collection_presenter
ActivityPub::CollectionPresenter.new(
id: account_followers_url(@account),
type: :ordered,
current: account_followers_url(@account),
size: @account.followers_count,
items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }
)
end
end
......@@ -5,5 +5,25 @@ class FollowingAccountsController < ApplicationController
def index
@follows = Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account)
respond_to do |format|
format.html
format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
end
end
end
private
def collection_presenter
ActivityPub::CollectionPresenter.new(
id: account_following_index_url(@account),
type: :ordered,
current: account_following_index_url(@account),
size: @account.following_count,
items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }
)
end
end
......@@ -11,10 +11,22 @@ class StatusesController < ApplicationController
before_action :check_account_suspension
def show
@ancestors = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : []
@descendants = cache_collection(@status.descendants(current_account), Status)
respond_to do |format|
format.html do
@ancestors = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : []
@descendants = cache_collection(@status.descendants(current_account), Status)
render 'stream_entries/show'
end
format.json do
render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
end
end
end
render 'stream_entries/show'
def activity
render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
end
private
......
......@@ -5,7 +5,27 @@ class TagsController < ApplicationController
def show
@tag = Tag.find_by!(name: params[:id].downcase)
@statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
@statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
@statuses = cache_collection(@statuses, Status)
respond_to do |format|
format.html
format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
end
end
end
private
def collection_presenter
ActivityPub::CollectionPresenter.new(
id: tag_url(@tag),
type: :ordered,
current: tag_url(@tag),
size: @tag.statuses.count,
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
)
end
end
# frozen_string_literal: true
module Activitystreams2BuilderHelper
# Gets a usable name for an account, using display name or username.
def account_name(account)
account.display_name.presence || account.username
end
end
# frozen_string_literal: true
class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
def self.default_key_transform
:camel_lower
end
def serializable_hash(options = nil)
options = serialization_options(options)
serialized_hash = { '@context': 'https://www.w3.org/ns/activitystreams' }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options))
self.class.transform_key_casing!(serialized_hash, instance_options)
end
end
# frozen_string_literal: true
require 'singleton'
class ActivityPub::TagManager
include Singleton
include RoutingHelper
COLLECTIONS = {
public: 'https://www.w3.org/ns/activitystreams#Public',
}.freeze
def url_for(target)
return target.url if target.respond_to?(:local?) && !target.local?
case target.object_type
when :person
short_account_url(target)
when :note, :comment, :activity
short_account_status_url(target.account, target)
end
end
def uri_for(target)
return target.uri if target.respond_to?(:local?) && !target.local?
case target.object_type
when :person
account_url(target)
when :note, :comment, :activity
account_status_url(target.account, target)
end
end
# Primary audience of a status
# Public statuses go out to primarily the public collection
# Unlisted and private statuses go out primarily to the followers collection
# Others go out only to the people they mention
def to(status)
case status.visibility
when 'public'
[COLLECTIONS[:public]]
when 'unlisted', 'private'
[account_followers_url(status.account)]
when 'direct'
status.mentions.map { |mention| uri_for(mention.account) }
end
end
# Secondary audience of a status
# Public statuses go out to followers as well
# Unlisted statuses go to the public as well
# Both of those and private statuses also go to the people mentioned in them
# Direct ones don't have a secondary audience
def cc(status)
cc = []
case status.visibility
when 'public'
cc << account_followers_url(status.account)
when 'unlisted'
cc << COLLECTIONS[:public]
end
cc.concat(status.mentions.map { |mention| uri_for(mention.account) }) unless status.direct_visibility?
cc
end
end
# frozen_string_literal: true
class ActivityPub::CollectionPresenter < ActiveModelSerializers::Model
attributes :id, :type, :current, :size, :items
end
# frozen_string_literal: true
class ActivityPub::ActivitySerializer < ActiveModel::Serializer
attributes :id, :type, :actor, :to, :cc
has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer
def id
[ActivityPub::TagManager.instance.uri_for(object), '/activity'].join
end
def type
object.reblog? ? 'Announce' : 'Create'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def to
ActivityPub::TagManager.instance.to(object)
end
def cc
ActivityPub::TagManager.instance.cc(object)
end
end
# frozen_string_literal: true
class ActivityPub::ActorSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :id, :type, :following, :followers,
:inbox, :outbox, :preferred_username,
:name, :summary, :icon, :image
def id
account_url(object)
end
def type
'Person'
end
def following
account_following_index_url(object)
end
def followers
account_followers_url(object)
end
def inbox
nil
end
def outbox
account_outbox_url(object)
end
def preferred_username
object.username
end
def name
object.display_name
end
def summary
Formatter.instance.simplified_format(object)
end
def icon
full_asset_url(object.avatar.url(:original))
end
def image
full_asset_url(object.header.url(:original))
end
end
# frozen_string_literal: true
class ActivityPub::CollectionSerializer < ActiveModel::Serializer
def self.serializer_for(model, options)
return ActivityPub::ActivitySerializer if model.class.name == 'Status'
super
end
attributes :id, :type, :total_items,
:current
has_many :items, key: :ordered_items
def type
case object.type
when :ordered
'OrderedCollection'
else
'Collection'
end
end
def total_items
object.size
end
end
# frozen_string_literal: true
class ActivityPub::NoteSerializer < ActiveModel::Serializer
attributes :id, :type, :summary, :content,
:in_reply_to, :published, :url,
:actor, :to, :cc, :sensitive
has_many :media_attachments, key: :attachment
has_many :virtual_tags, key: :tag
def id
ActivityPub::TagManager.instance.uri_for(object)
end
def type
'Note'
end
def summary
object.spoiler_text.presence
end
def content
Formatter.instance.format(object)
end
def in_reply_to
ActivityPub::TagManager.instance.uri_for(object.thread) if object.reply?
end
def published
object.created_at.iso8601
end
def url
ActivityPub::TagManager.instance.url_for(object)
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def to
ActivityPub::TagManager.instance.to(object)
end
def cc
ActivityPub::TagManager.instance.cc(object)
end
def virtual_tags
object.mentions + object.tags
end
class MediaAttachmentSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :type, :media_type, :url
def type
'Document'
end
def media_type
object.file_content_type
end
def url
object.local? ? full_asset_url(object.file.url(:original, false)) : object.remote_url
end
end
class MentionSerializer < ActiveModel::Serializer
attributes :type, :href, :name
def type
'Mention'
end
def href
ActivityPub::TagManager.instance.uri_for(object.account)
end
def name
"@#{object.account.acct}"
end
end
class TagSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :type, :href, :name
def type
'Hashtag'
end
def href
tag_url(object)
end
def name
"##{object.name}"
end
end
end
extends 'activitypub/types/person.activitystreams2.rabl'
object @account
attributes display_name: :name, username: :preferredUsername, note: :summary
node(:icon) { |account| full_asset_url(account.avatar.url(:original)) }
node(:image) { |account| full_asset_url(account.header.url(:original)) }
node(:outbox) { |account| api_activitypub_outbox_url(account.id) }
node(:'@context') { 'https://www.w3.org/ns/activitystreams' }
extends 'activitypub/base.activitystreams2.rabl'
node(:id) { request.original_url }
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment