* Federate pinned statuses over ActivityPub * Display pinned toots in web UI Fix #6117 * Fix migration * Fix tests * Update outbox_serializer.rb * Update remove_serializer.rb * Update add_serializer.rb * Update fetch_featured_collection_service.rbpull/4/head
@ -0,0 +1,57 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::CollectionsController < Api::BaseController | |||
include SignatureVerification | |||
before_action :set_account | |||
before_action :set_size | |||
before_action :set_statuses | |||
def show | |||
render json: collection_presenter, | |||
serializer: ActivityPub::CollectionSerializer, | |||
adapter: ActivityPub::Adapter, | |||
content_type: 'application/activity+json', | |||
skip_activities: true | |||
end | |||
private | |||
def set_account | |||
@account = Account.find_local!(params[:account_username]) | |||
end | |||
def set_statuses | |||
@statuses = scope_for_collection.paginate_by_max_id(20, params[:max_id], params[:since_id]) | |||
@statuses = cache_collection(@statuses, Status) | |||
end | |||
def set_size | |||
case params[:id] | |||
when 'featured' | |||
@account.pinned_statuses.count | |||
else | |||
raise ActiveRecord::NotFound | |||
end | |||
end | |||
def scope_for_collection | |||
case params[:id] | |||
when 'featured' | |||
@account.statuses.permitted_for(@account, signed_request_account).tap do |scope| | |||
scope.merge!(@account.pinned_statuses) | |||
end | |||
else | |||
raise ActiveRecord::NotFound | |||
end | |||
end | |||
def collection_presenter | |||
ActivityPub::CollectionPresenter.new( | |||
id: account_collection_url(@account, params[:id]), | |||
type: :ordered, | |||
size: @size, | |||
items: @statuses | |||
) | |||
end | |||
end |
@ -0,0 +1,13 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::Activity::Add < ActivityPub::Activity | |||
def perform | |||
return unless @json['target'].present? && value_or_id(@json['target']) == @account.featured_collection_url | |||
status = status_from_uri(object_uri) | |||
return unless status.account_id == @account.id && !@account.pinned?(status) | |||
StatusPin.create!(account: @account, status: status) | |||
end | |||
end |
@ -0,0 +1,14 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::Activity::Remove < ActivityPub::Activity | |||
def perform | |||
return unless @json['origin'].present? && value_or_id(@json['origin']) == @account.featured_collection_url | |||
status = status_from_uri(object_uri) | |||
return unless status.account_id == @account.id | |||
pin = StatusPin.find_by(account: @account, status: status) | |||
pin&.destroy! | |||
end | |||
end |
@ -0,0 +1,24 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::AddSerializer < ActiveModel::Serializer | |||
include RoutingHelper | |||
attributes :type, :actor, :target | |||
attribute :proper_object, key: :object | |||
def type | |||
'Add' | |||
end | |||
def actor | |||
ActivityPub::TagManager.instance.uri_for(object.account) | |||
end | |||
def proper_object | |||
ActivityPub::TagManager.instance.uri_for(object) | |||
end | |||
def target | |||
account_collection_url(object, :featured) | |||
end | |||
end |
@ -0,0 +1,8 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::OutboxSerializer < ActivityPub::CollectionSerializer | |||
def self.serializer_for(model, options) | |||
return ActivityPub::ActivitySerializer if model.is_a?(Status) | |||
super | |||
end | |||
end |
@ -0,0 +1,24 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::RemoveSerializer < ActiveModel::Serializer | |||
include RoutingHelper | |||
attributes :type, :actor, :origin | |||
attribute :proper_object, key: :object | |||
def type | |||
'Remove' | |||
end | |||
def actor | |||
ActivityPub::TagManager.instance.uri_for(object.account) | |||
end | |||
def proper_object | |||
ActivityPub::TagManager.instance.uri_for(object) | |||
end | |||
def origin | |||
account_collection_url(object, :featured) | |||
end | |||
end |
@ -0,0 +1,52 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::FetchFeaturedCollectionService < BaseService | |||
include JsonLdHelper | |||
def call(account) | |||
@account = account | |||
@json = fetch_resource(@account.featured_collection_url, true) | |||
return unless supported_context? | |||
return if @account.suspended? || @account.local? | |||
case @json['type'] | |||
when 'Collection', 'CollectionPage' | |||
process_items @json['items'] | |||
when 'OrderedCollection', 'OrderedCollectionPage' | |||
process_items @json['orderedItems'] | |||
end | |||
end | |||
private | |||
def process_items(items) | |||
status_ids = items.map { |item| value_or_id(item) } | |||
.reject { |uri| ActivityPub::TagManager.instance.local_uri?(uri) } | |||
.map { |uri| ActivityPub::FetchRemoteStatusService.new.call(uri) } | |||
.compact | |||
.select { |status| status.account_id == @account.id } | |||
.map(&:id) | |||
to_remove = [] | |||
to_add = status_ids | |||
StatusPin.where(account: @account).pluck(:status_id).each do |status_id| | |||
if status_ids.include?(status_id) | |||
to_add.delete(status_id) | |||
else | |||
to_remove << status_id | |||
end | |||
end | |||
StatusPin.where(account: @account, status_id: to_remove).delete_all unless to_remove.empty? | |||
to_add.each do |status_id| | |||
StatusPin.create!(account: @account, status_id: status_id) | |||
end | |||
end | |||
def supported_context? | |||
super(@json) | |||
end | |||
end |
@ -0,0 +1,13 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::SynchronizeFeaturedCollectionWorker | |||
include Sidekiq::Worker | |||
sidekiq_options queue: 'pull' | |||
def perform(account_id) | |||
ActivityPub::FetchFeaturedCollectionService.new.call(Account.find(account_id)) | |||
rescue ActiveRecord::RecordNotFound | |||
true | |||
end | |||
end |
@ -0,0 +1,5 @@ | |||
class AddFeaturedCollectionUrlToAccounts < ActiveRecord::Migration[5.1] | |||
def change | |||
add_column :accounts, :featured_collection_url, :string | |||
end | |||
end |
@ -0,0 +1,29 @@ | |||
require 'rails_helper' | |||
RSpec.describe ActivityPub::Activity::Add do | |||
let(:sender) { Fabricate(:account, featured_collection_url: 'https://example.com/featured') } | |||
let(:status) { Fabricate(:status, account: sender) } | |||
let(:json) do | |||
{ | |||
'@context': 'https://www.w3.org/ns/activitystreams', | |||
id: 'foo', | |||
type: 'Add', | |||
actor: ActivityPub::TagManager.instance.uri_for(sender), | |||
object: ActivityPub::TagManager.instance.uri_for(status), | |||
target: sender.featured_collection_url, | |||
}.with_indifferent_access | |||
end | |||
describe '#perform' do | |||
subject { described_class.new(json, sender) } | |||
before do | |||
subject.perform | |||
end | |||
it 'creates a pin' do | |||
expect(sender.pinned?(status)).to be true | |||
end | |||
end | |||
end |
@ -0,0 +1,30 @@ | |||
require 'rails_helper' | |||
RSpec.describe ActivityPub::Activity::Remove do | |||
let(:sender) { Fabricate(:account, featured_collection_url: 'https://example.com/featured') } | |||
let(:status) { Fabricate(:status, account: sender) } | |||
let(:json) do | |||
{ | |||
'@context': 'https://www.w3.org/ns/activitystreams', | |||
id: 'foo', | |||
type: 'Add', | |||
actor: ActivityPub::TagManager.instance.uri_for(sender), | |||
object: ActivityPub::TagManager.instance.uri_for(status), | |||
origin: sender.featured_collection_url, | |||
}.with_indifferent_access | |||
end | |||
describe '#perform' do | |||
subject { described_class.new(json, sender) } | |||
before do | |||
StatusPin.create!(account: sender, status: status) | |||
subject.perform | |||
end | |||
it 'removes a pin' do | |||
expect(sender.pinned?(status)).to be false | |||
end | |||
end | |||
end |