Compare commits

...

9 Commits

17 changed files with 578 additions and 171 deletions

View File

@ -9,6 +9,7 @@ class Api::V1::StatusesController < Api::BaseController
before_action :set_statuses, only: [:index]
before_action :set_status, only: [:show, :context]
before_action :set_thread, only: [:create]
before_action :set_quoted_status, only: [:create]
before_action :check_statuses_limit, only: [:index]
override_rate_limit_headers :create, family: :statuses
@ -67,6 +68,7 @@ class Api::V1::StatusesController < Api::BaseController
current_user.account,
text: status_params[:status],
thread: @thread,
quoted_status: @quoted_status,
media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive],
spoiler_text: status_params[:spoiler_text],
@ -138,6 +140,15 @@ class Api::V1::StatusesController < Api::BaseController
render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404
end
def set_quoted_status
return unless Mastodon::Feature.outgoing_quotes_enabled?
@quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present?
authorize(@quoted_status, :quote?) if @quoted_status.present?
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
render json: { error: I18n.t('statuses.errors.quoted_status_not_found') }, status: 404
end
def check_statuses_limit
raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT
end
@ -154,6 +165,7 @@ class Api::V1::StatusesController < Api::BaseController
params.permit(
:status,
:in_reply_to_id,
:quoted_status_id,
:sensitive,
:spoiler_text,
:visibility,

View File

@ -26,6 +26,12 @@ module ContextHelper
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
quotes: {
'quote' => 'https://w3id.org/fep/044f#quote',
'quoteUri' => 'http://fedibird.com/ns#quoteUri',
'_misskey_quote' => 'https://misskey-hub.net/ns/#_misskey_quote',
'quoteAuthorization' => 'https://w3id.org/fep/044f#quoteAuthorization',
},
interaction_policies: {
'gts' => 'https://gotosocial.org/ns#',
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },

View File

@ -143,6 +143,10 @@ class ActivityPub::Activity
@follow_request_from_object ||= FollowRequest.find_by(target_account: @account, uri: object_uri) unless object_uri.nil?
end
def quote_request_from_object
@quote_request_from_object ||= Quote.find_by(quoted_account: @account, activity_uri: object_uri) unless object_uri.nil?
end
def follow_from_object
@follow_from_object ||= ::Follow.find_by(target_account: @account, uri: object_uri) unless object_uri.nil?
end

View File

@ -4,10 +4,13 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity
def perform
return accept_follow_for_relay if relay_follow?
return accept_follow!(follow_request_from_object) unless follow_request_from_object.nil?
return accept_quote!(quote_request_from_object) unless quote_request_from_object.nil?
case @object['type']
when 'Follow'
accept_embedded_follow
when 'QuoteRequest'
accept_embedded_quote_request
end
end
@ -31,6 +34,32 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity
RemoteAccountRefreshWorker.perform_async(request.target_account_id) if is_first_follow
end
def accept_embedded_quote_request
quoted_status_uri = value_or_id(@object['object'])
quoting_status_uri = value_or_id(@object['instrument'])
approval_uri = value_or_id(@json['result'])
return if quoted_status_uri.nil? || quoting_status_uri.nil? || approval_uri.nil?
quoting_status = status_from_uri(quoting_status_uri)
return unless quoting_status.local?
quoted_status = status_from_uri(quoted_status_uri)
return unless quoted_status.account == @account && quoting_status.quote.quoted_status == quoted_status
accept_quote!(quoting_status.quote)
end
def accept_quote!(quote)
approval_uri = value_or_id(@json['result'])
return if unsupported_uri_scheme?(approval_uri) || quote.quoted_account != @account || !quote.status.local?
# TODO: should this go through `ActivityPub::VerifyQuoteService`?
quote.update!(state: :accepted, approval_uri: approval_uri)
DistributionWorker.perform_async(quote.status_id, { 'update' => true })
ActivityPub::StatusUpdateDistributionWorker.perform_async(quote.status_id)
end
def accept_follow_for_relay
relay.update!(state: :accepted)
end

View File

@ -5,10 +5,13 @@ class ActivityPub::Activity::Reject < ActivityPub::Activity
return reject_follow_for_relay if relay_follow?
return follow_request_from_object.reject! unless follow_request_from_object.nil?
return UnfollowService.new.call(follow_from_object.account, @account) unless follow_from_object.nil?
return reject_quote!(quote_request_from_object) unless quote_request_from_object.nil?
case @object['type']
when 'Follow'
reject_embedded_follow
when 'QuoteRequest'
reject_embedded_quote
end
end
@ -29,6 +32,28 @@ class ActivityPub::Activity::Reject < ActivityPub::Activity
relay.update!(state: :rejected)
end
def reject_embedded_quote
quoted_status_uri = value_or_id(@object['object'])
quoting_status_uri = value_or_id(@object['instrument'])
approval_uri = value_or_id(@json['instrument'])
return if quoted_status_uri.nil? || quoted_uri.nil? || approval_uri.nil?
quoting_status = status_from_uri(quoting_status_uri)
return unless quoting_status.local?
quoted_status = status_from_uri(quoted_status_uri)
return unless quoted_status.account == @account && quoting_status.quote.quoted_status == quoted_status
reject_quote!(quoting_status.quote)
end
def reject_quote!(quote)
return unless quote.quoted_account == @account && quote.status.local?
# TODO: broadcast an update?
quote.reject!
end
def relay
@relay ||= Relay.find_by(follow_activity_id: object_uri) unless object_uri.nil?
end

View File

@ -12,9 +12,7 @@ module ActivityPub::CaseTransform
when Hash then value.deep_transform_keys! { |key| camel_lower(key) }
when Symbol then camel_lower(value.to_s).to_sym
when String
camel_lower_cache[value] ||= if value.start_with?('_:')
"_:#{value.delete_prefix('_:').underscore.camelize(:lower)}"
elsif LanguagesHelper::ISO_639_1_REGIONAL.key?(value.to_sym)
camel_lower_cache[value] ||= if value.start_with?('_') || LanguagesHelper::ISO_639_1_REGIONAL.key?(value.to_sym)
value
else
value.underscore.camelize(:lower)

View File

@ -19,6 +19,11 @@ class StatusPolicy < ApplicationPolicy
end
end
# This is about requesting a quote post, not validating it
def quote?
owned? || active_mention_exists? || quote_approved_by_policy?
end
def reblog?
!requires_mention? && (!private? || owned?) && show? && !blocking_author?
end
@ -39,6 +44,14 @@ class StatusPolicy < ApplicationPolicy
private
def quote_approved_by_policy?
flattened_policy = record.quote_approval_policy | (record.quote_approval_policy >> 16)
return true if flattened_policy & (Status::QUOTE_APPROVAL_POLICY_FLAGS[:unknown] | Status::QUOTE_APPROVAL_POLICY_FLAGS[:public]) != 0
# TODO: support `:followed`
(flattened_policy & Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] != 0) && following_author?
end
def requires_mention?
record.direct_visibility? || record.limited_visibility?
end
@ -61,6 +74,16 @@ class StatusPolicy < ApplicationPolicy
end
end
def active_mention_exists?
return false if current_account.nil?
if record.active_mentions.loaded?
record.active_mentions.any? { |mention| mention.account_id == current_account.id }
else
record.active_mentions.exists?(account: current_account)
end
end
def author_blocking_domain?
return false if current_account.nil? || current_account.domain.nil?

View File

@ -3,7 +3,7 @@
class ActivityPub::NoteSerializer < ActivityPub::Serializer
include FormattingHelper
context_extensions :atom_uri, :conversation, :sensitive, :voters_count
context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :quotes
attributes :id, :type, :summary,
:in_reply_to, :published, :url,
@ -30,6 +30,11 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
attribute :voters_count, if: :poll_and_voters_count?
attribute :quote, if: :quote?
attribute :quote, key: :_misskey_quote, if: :quote?
attribute :quote, key: :quote_uri, if: :quote?
attribute :quote_authorization, if: :quote_authorization?
def id
ActivityPub::TagManager.instance.uri_for(object)
end
@ -194,6 +199,24 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
object.preloadable_poll&.voters_count
end
def quote?
object.quote&.present?
end
def quote_authorization?
object.quote&.approval_uri.present?
end
def quote
# TODO: handle inlining self-quotes
ActivityPub::TagManager.instance.uri_for(object.quote.quoted_status)
end
def quote_authorization
# TODO: approval of local quotes may work differently, perhaps?
object.quote.approval_uri
end
class MediaAttachmentSerializer < ActivityPub::Serializer
context_extensions :blurhash, :focal_point

View File

@ -97,12 +97,10 @@ class PostStatusService < BaseService
# we only support incoming quotes so far
status.quote = Quote.new(quoted_status: @quoted_status)
status.quote.accept! if @status.account == @quoted_status.account || @quoted_status.active_mentions.exists?(mentions: { account_id: status.account_id })
# TODO: the following has yet to be implemented:
# - handle approval of local users (requires the interactionPolicy PR)
# - produce a QuoteAuthorization for quotes of local users
# - send a QuoteRequest for quotes of remote users
if @quoted_status.local? && StatusPolicy.new(@status.account, @quoted_status).quote?
# TODO: produce a QuoteAuthorization
status.quote.accept!
end
end
def safeguard_mentions!(status)
@ -146,6 +144,7 @@ class PostStatusService < BaseService
DistributionWorker.perform_async(@status.id)
ActivityPub::DistributionWorker.perform_async(@status.id)
PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
ActivityPub::QuoteRequestWorker.perform_async(@status.quote.id) if @status.quote&.quoted_status.present? && !@status.quote&.quoted_status&.local?
end
def validate_media!

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class ActivityPub::QuoteRequestWorker < ActivityPub::RawDistributionWorker
def perform(quote_id)
@quote = Quote.find(quote_id)
@account = @quote.account
distribute!
rescue ActiveRecord::RecordNotFound
true
end
protected
def inboxes
@inboxes ||= [@quote.quoted_account&.preferred_inbox_url].compact
end
def payload
@payload ||= Oj.dump(serialize_payload(@quote, ActivityPub::QuoteRequestSerializer, signer: @account))
end
end

View File

@ -3,69 +3,173 @@
require 'rails_helper'
RSpec.describe ActivityPub::Activity::Accept do
let(:sender) { Fabricate(:account) }
let(:sender) { Fabricate(:account, domain: 'example.com') }
let(:recipient) { Fabricate(:account) }
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Accept',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: {
id: 'bar',
type: 'Follow',
actor: ActivityPub::TagManager.instance.uri_for(recipient),
object: ActivityPub::TagManager.instance.uri_for(sender),
},
}.with_indifferent_access
end
describe '#perform' do
subject { described_class.new(json, sender) }
before do
allow(RemoteAccountRefreshWorker).to receive(:perform_async)
Fabricate(:follow_request, account: recipient, target_account: sender)
subject.perform
context 'with a Follow request' do
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Accept',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: {
id: 'https://abc-123/456',
type: 'Follow',
actor: ActivityPub::TagManager.instance.uri_for(recipient),
object: ActivityPub::TagManager.instance.uri_for(sender),
},
}.with_indifferent_access
end
context 'with a regular Follow' do
before do
Fabricate(:follow_request, account: recipient, target_account: sender)
end
it 'creates a follow relationship, removes the follow request, and queues a refresh' do
expect { subject.perform }
.to change { recipient.following?(sender) }.from(false).to(true)
.and change { recipient.requested?(sender) }.from(true).to(false)
expect(RemoteAccountRefreshWorker).to have_enqueued_sidekiq_job(sender.id)
end
end
context 'when given a relay' do
let!(:relay) { Fabricate(:relay, state: :pending, follow_activity_id: 'https://abc-123/456') }
it 'marks the relay as accepted' do
expect { subject.perform }
.to change { relay.reload.accepted? }.from(false).to(true)
end
end
end
it 'creates a follow relationship' do
expect(recipient.following?(sender)).to be true
end
context 'with a QuoteRequest' do
let(:status) { Fabricate(:status, account: recipient) }
let(:quoted_status) { Fabricate(:status, account: sender) }
let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, activity_uri: 'https://abc-123/456') }
let(:approval_uri) { "https://#{sender.domain}/approvals/1" }
it 'removes the follow request' do
expect(recipient.requested?(sender)).to be false
end
let(:json) do
{
'@context': [
'https://www.w3.org/ns/activitystreams',
{
QuoteRequest: 'https://w3id.org/fep/044f#QuoteRequest',
},
],
id: 'foo',
type: 'Accept',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: {
id: 'https://abc-123/456',
type: 'QuoteRequest',
actor: ActivityPub::TagManager.instance.uri_for(recipient),
object: ActivityPub::TagManager.instance.uri_for(quoted_status),
instrument: ActivityPub::TagManager.instance.uri_for(status),
},
result: approval_uri,
}.with_indifferent_access
end
it 'queues a refresh' do
expect(RemoteAccountRefreshWorker).to have_received(:perform_async).with(sender.id)
end
end
it 'marks the quote as approved and distribute an update' do
expect { subject.perform }
.to change { quote.reload.accepted? }.from(false).to(true)
.and change { quote.reload.approval_uri }.to(approval_uri)
expect(DistributionWorker)
.to have_enqueued_sidekiq_job(status.id, { 'update' => true })
expect(ActivityPub::StatusUpdateDistributionWorker)
.to have_enqueued_sidekiq_job(status.id)
end
context 'when given a relay' do
subject { described_class.new(json, sender) }
context 'when the quoted status is not from the sender of the Accept' do
let(:quoted_status) { Fabricate(:status, account: Fabricate(:account, domain: 'example.com')) }
let!(:relay) { Fabricate(:relay, state: :pending, follow_activity_id: 'https://abc-123/456') }
it 'does not mark the quote as approved and does not distribute an update' do
expect { subject.perform }
.to not_change { quote.reload.accepted? }.from(false)
.and not_change { quote.reload.approval_uri }.from(nil)
expect(DistributionWorker)
.to_not have_enqueued_sidekiq_job(status.id, { 'update' => true })
expect(ActivityPub::StatusUpdateDistributionWorker)
.to_not have_enqueued_sidekiq_job(status.id)
end
end
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Accept',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: {
id: 'https://abc-123/456',
type: 'Follow',
actor: ActivityPub::TagManager.instance.uri_for(recipient),
object: ActivityPub::TagManager.instance.uri_for(sender),
},
}.with_indifferent_access
end
context 'when the quoting status is from an unrelated user' do
let(:status) { Fabricate(:status, account: Fabricate(:account, domain: 'foobar.com')) }
it 'marks the relay as accepted' do
subject.perform
expect(relay.reload.accepted?).to be true
it 'does not mark the quote as approved and does not distribute an update' do
expect { subject.perform }
.to not_change { quote.reload.accepted? }.from(false)
.and not_change { quote.reload.approval_uri }.from(nil)
expect(DistributionWorker)
.to_not have_enqueued_sidekiq_job(status.id, { 'update' => true })
expect(ActivityPub::StatusUpdateDistributionWorker)
.to_not have_enqueued_sidekiq_job(status.id)
end
end
context 'when approval_uri is missing' do
let(:approval_uri) { nil }
it 'does not mark the quote as approved and does not distribute an update' do
expect { subject.perform }
.to not_change { quote.reload.accepted? }.from(false)
.and not_change { quote.reload.approval_uri }.from(nil)
expect(DistributionWorker)
.to_not have_enqueued_sidekiq_job(status.id, { 'update' => true })
expect(ActivityPub::StatusUpdateDistributionWorker)
.to_not have_enqueued_sidekiq_job(status.id)
end
end
context 'when the QuoteRequest is referenced by its identifier' do
let(:json) do
{
'@context': [
'https://www.w3.org/ns/activitystreams',
{
QuoteRequest: 'https://w3id.org/fep/044f#QuoteRequest',
},
],
id: 'foo',
type: 'Accept',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: 'https://abc-123/456',
result: approval_uri,
}.with_indifferent_access
end
it 'marks the quote as approved and distribute an update' do
expect { subject.perform }
.to change { quote.reload.accepted? }.from(false).to(true)
.and change { quote.reload.approval_uri }.to(approval_uri)
expect(DistributionWorker)
.to have_enqueued_sidekiq_job(status.id, { 'update' => true })
expect(ActivityPub::StatusUpdateDistributionWorker)
.to have_enqueued_sidekiq_job(status.id)
end
context 'when approval_uri is missing' do
let(:approval_uri) { nil }
it 'does not mark the quote as approved and does not distribute an update' do
expect { subject.perform }
.to not_change { quote.reload.accepted? }.from(false)
.and not_change { quote.reload.approval_uri }.from(nil)
expect(DistributionWorker)
.to_not have_enqueued_sidekiq_job(status.id, { 'update' => true })
expect(ActivityPub::StatusUpdateDistributionWorker)
.to_not have_enqueued_sidekiq_job(status.id)
end
end
end
end
end
end

View File

@ -5,14 +5,6 @@ require 'rails_helper'
RSpec.describe ActivityPub::Activity::Reject do
let(:sender) { Fabricate(:account) }
let(:recipient) { Fabricate(:account) }
let(:object_json) do
{
id: 'bar',
type: 'Follow',
actor: ActivityPub::TagManager.instance.uri_for(recipient),
object: ActivityPub::TagManager.instance.uri_for(sender),
}
end
let(:json) do
{
@ -27,124 +19,133 @@ RSpec.describe ActivityPub::Activity::Reject do
describe '#perform' do
subject { described_class.new(json, sender) }
context 'when rejecting a pending follow request by target' do
before do
Fabricate(:follow_request, account: recipient, target_account: sender)
subject.perform
context 'when rejecting a Follow' do
let(:object_json) do
{
id: 'bar',
type: 'Follow',
actor: ActivityPub::TagManager.instance.uri_for(recipient),
object: ActivityPub::TagManager.instance.uri_for(sender),
}
end
it 'does not create a follow relationship' do
expect(recipient.following?(sender)).to be false
context 'when rejecting a pending follow request by target' do
before do
Fabricate(:follow_request, account: recipient, target_account: sender)
end
it 'removes the follow request without creating a follow relationship' do
expect { subject.perform }
.to change { recipient.requested?(sender) }.from(true).to(false)
.and not_change { recipient.following?(sender) }.from(false)
end
end
it 'removes the follow request' do
expect(recipient.requested?(sender)).to be false
context 'when rejecting a pending follow request by uri' do
before do
Fabricate(:follow_request, account: recipient, target_account: sender, uri: 'bar')
end
it 'removes the follow request without creating a follow relationship' do
expect { subject.perform }
.to change { recipient.requested?(sender) }.from(true).to(false)
.and not_change { recipient.following?(sender) }.from(false)
end
end
context 'when rejecting a pending follow request by uri only' do
let(:object_json) { 'bar' }
before do
Fabricate(:follow_request, account: recipient, target_account: sender, uri: 'bar')
end
it 'removes the follow request without creating a follow relationship' do
expect { subject.perform }
.to change { recipient.requested?(sender) }.from(true).to(false)
.and not_change { recipient.following?(sender) }.from(false)
end
end
context 'when rejecting an existing follow relationship by target' do
before do
Fabricate(:follow, account: recipient, target_account: sender)
end
it 'removes the follow relationship without creating a request' do
expect { subject.perform }
.to change { recipient.following?(sender) }.from(true).to(false)
.and not_change { recipient.requested?(sender) }.from(false)
end
end
context 'when rejecting an existing follow relationship by uri' do
before do
Fabricate(:follow, account: recipient, target_account: sender, uri: 'bar')
end
it 'removes the follow relationship without creating a request' do
expect { subject.perform }
.to change { recipient.following?(sender) }.from(true).to(false)
.and not_change { recipient.requested?(sender) }.from(false)
end
end
context 'when rejecting an existing follow relationship by uri only' do
let(:object_json) { 'bar' }
before do
Fabricate(:follow, account: recipient, target_account: sender, uri: 'bar')
end
it 'removes the follow relationship without creating a request' do
expect { subject.perform }
.to change { recipient.following?(sender) }.from(true).to(false)
.and not_change { recipient.requested?(sender) }.from(false)
end
end
end
context 'when rejecting a pending follow request by uri' do
before do
Fabricate(:follow_request, account: recipient, target_account: sender, uri: 'bar')
subject.perform
end
context 'when given a relay' do
subject { described_class.new(json, sender) }
it 'does not create a follow relationship' do
expect(recipient.following?(sender)).to be false
end
let!(:relay) { Fabricate(:relay, state: :pending, follow_activity_id: 'https://abc-123/456') }
it 'removes the follow request' do
expect(recipient.requested?(sender)).to be false
end
end
context 'when rejecting a pending follow request by uri only' do
let(:object_json) { 'bar' }
before do
Fabricate(:follow_request, account: recipient, target_account: sender, uri: 'bar')
subject.perform
end
it 'does not create a follow relationship' do
expect(recipient.following?(sender)).to be false
end
it 'removes the follow request' do
expect(recipient.requested?(sender)).to be false
end
end
context 'when rejecting an existing follow relationship by target' do
before do
Fabricate(:follow, account: recipient, target_account: sender)
subject.perform
end
it 'removes the follow relationship' do
expect(recipient.following?(sender)).to be false
end
it 'does not create a follow request' do
expect(recipient.requested?(sender)).to be false
end
end
context 'when rejecting an existing follow relationship by uri' do
before do
Fabricate(:follow, account: recipient, target_account: sender, uri: 'bar')
subject.perform
end
it 'removes the follow relationship' do
expect(recipient.following?(sender)).to be false
end
it 'does not create a follow request' do
expect(recipient.requested?(sender)).to be false
end
end
context 'when rejecting an existing follow relationship by uri only' do
let(:object_json) { 'bar' }
before do
Fabricate(:follow, account: recipient, target_account: sender, uri: 'bar')
subject.perform
end
it 'removes the follow relationship' do
expect(recipient.following?(sender)).to be false
end
it 'does not create a follow request' do
expect(recipient.requested?(sender)).to be false
end
end
end
context 'when given a relay' do
subject { described_class.new(json, sender) }
let!(:relay) { Fabricate(:relay, state: :pending, follow_activity_id: 'https://abc-123/456') }
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Reject',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: {
let(:object_json) do
{
id: 'https://abc-123/456',
type: 'Follow',
actor: ActivityPub::TagManager.instance.uri_for(recipient),
object: ActivityPub::TagManager.instance.uri_for(sender),
},
}.with_indifferent_access
}.with_indifferent_access
end
it 'marks the relay as rejected' do
subject.perform
expect(relay.reload.rejected?).to be true
end
end
it 'marks the relay as rejected' do
subject.perform
expect(relay.reload.rejected?).to be true
context 'with a QuoteRequest' do
let(:status) { Fabricate(:status, account: recipient) }
let(:quoted_status) { Fabricate(:status, account: sender) }
let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, activity_uri: 'https://abc-123/456') }
let(:approval_uri) { "https://#{sender.domain}/approvals/1" }
let(:object_json) do
{
id: 'https://abc-123/456',
type: 'QuoteRequest',
actor: ActivityPub::TagManager.instance.uri_for(recipient),
object: ActivityPub::TagManager.instance.uri_for(quoted_status),
instrument: ActivityPub::TagManager.instance.uri_for(status),
}.with_indifferent_access
end
it 'marks the quote as rejected' do
expect { subject.perform }
.to change { quote.reload.rejected? }.from(false).to(true)
end
end
end
end

View File

@ -86,6 +86,92 @@ RSpec.describe StatusPolicy, type: :model do
end
end
context 'with the permission of quote?' do
permissions :quote? do
it 'grants access when direct and account is viewer' do
status.visibility = :direct
expect(subject).to permit(status.account, status)
end
it 'grants access when direct and viewer is mentioned' do
status.visibility = :direct
status.mentions = [Fabricate(:mention, account: alice)]
expect(subject).to permit(alice, status)
end
it 'grants access when direct and non-owner viewer is mentioned and mentions are loaded' do
status.visibility = :direct
status.mentions = [Fabricate(:mention, account: bob)]
status.active_mentions.load
expect(subject).to permit(bob, status)
end
it 'denies access when direct and viewer is not mentioned' do
viewer = Fabricate(:account)
status.visibility = :direct
expect(subject).to_not permit(viewer, status)
end
it 'denies access when private and viewer is not mentioned' do
viewer = Fabricate(:account)
status.visibility = :private
expect(subject).to_not permit(viewer, status)
end
it 'grants access when private and viewer is mentioned' do
status.visibility = :private
status.mentions = [Fabricate(:mention, account: bob)]
expect(subject).to permit(bob, status)
end
it 'denies access when private and non-viewer is mentioned' do
viewer = Fabricate(:account)
status.visibility = :private
status.mentions = [Fabricate(:mention, account: bob)]
expect(subject).to_not permit(viewer, status)
end
it 'denies access when private and account is following viewer' do
follow = Fabricate(:follow)
status.visibility = :private
status.account = follow.target_account
expect(subject).to_not permit(follow.account, status)
end
it 'denies access when public but policy does not allow anyone' do
viewer = Fabricate(:account)
expect(subject).to_not permit(viewer, status)
end
it 'grants access when public and policy allows everyone' do
status.quote_approval_policy = Status::QUOTE_APPROVAL_POLICY_FLAGS[:public]
viewer = Fabricate(:account)
expect(subject).to permit(viewer, status)
end
it 'denies access when public and policy allows followers but viewer is not one' do
status.quote_approval_policy = Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers]
viewer = Fabricate(:account)
expect(subject).to_not permit(viewer, status)
end
it 'grants access when public and policy allows followers and viewer is one' do
status.quote_approval_policy = Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers]
viewer = Fabricate(:account)
viewer.follow!(status.account)
expect(subject).to permit(viewer, status)
end
end
end
context 'with the permission of reblog?' do
permissions :reblog? do
it 'denies access when private' do

View File

@ -158,6 +158,27 @@ RSpec.describe '/api/v1/statuses' do
end
end
context 'with a self-quote post', feature: :outgoing_quotes do
let(:quoted_status) { Fabricate(:status, account: user.account) }
let(:params) do
{
status: 'Hello world, this is a self-quote',
quoted_status_id: quoted_status.id,
}
end
it 'returns a quote post, as well as rate limit headers', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(response.content_type)
.to start_with('application/json')
expect(response.parsed_body[:quote]).to be_present
expect(response.headers['X-RateLimit-Limit']).to eq RateLimiter::FAMILIES[:statuses][:limit].to_s
expect(response.headers['X-RateLimit-Remaining']).to eq (RateLimiter::FAMILIES[:statuses][:limit] - 1).to_s
end
end
context 'with a safeguard' do
let!(:alice) { Fabricate(:account, username: 'alice') }
let!(:bob) { Fabricate(:account, username: 'bob') }

View File

@ -41,4 +41,20 @@ RSpec.describe ActivityPub::NoteSerializer do
.and(not_include(reply_by_other_first.uri)) # Replies from others
.and(not_include(reply_by_account_visibility_direct.uri)) # Replies with direct visibility
end
context 'with a quote' do
let(:quoted_status) { Fabricate(:status) }
let(:approval_uri) { 'https://example.com/foo/bar' }
let!(:quote) { Fabricate(:quote, status: parent, quoted_status: quoted_status, approval_uri: approval_uri) }
it 'has the expected shape' do
expect(subject).to include({
'type' => 'Note',
'quote' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status),
'quoteUri' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status),
'_misskey_quote' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status),
'quoteAuthorization' => approval_uri,
})
end
end
end

View File

@ -291,6 +291,14 @@ RSpec.describe PostStatusService do
)
end
it 'correctly requests a quote for remote posts' do
account = Fabricate(:account)
quoted_status = Fabricate(:status, account: Fabricate(:account, domain: 'example.com'))
expect { subject.call(account, text: 'test', quoted_status: quoted_status) }
.to enqueue_sidekiq_job(ActivityPub::QuoteRequestWorker)
end
it 'returns existing status when used twice with idempotency key' do
account = Fabricate(:account)
status1 = subject.call(account, text: 'test', idempotency: 'meepmeep')

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::QuoteRequestWorker do
subject { described_class.new }
let(:quoted_account) { Fabricate(:account, inbox_url: 'http://example.com', domain: 'example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let(:status) { Fabricate(:status, text: 'foo') }
let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, activity_uri: 'TODO') } # TODO: activity URI
describe '#perform' do
it 'sends the expected QuoteRequest activity' do
subject.perform(quote.id)
expect(ActivityPub::DeliveryWorker)
.to have_enqueued_sidekiq_job(match_object_shape, quote.account_id, 'http://example.com', {})
end
def match_object_shape
match_json_values(
type: 'QuoteRequest',
actor: ActivityPub::TagManager.instance.uri_for(quote.account),
object: ActivityPub::TagManager.instance.uri_for(quoted_status),
instrument: anything # TODO: inline post in request?
)
end
end
end