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_statuses, only: [:index]
before_action :set_status, only: [:show, :context] before_action :set_status, only: [:show, :context]
before_action :set_thread, only: [:create] before_action :set_thread, only: [:create]
before_action :set_quoted_status, only: [:create]
before_action :check_statuses_limit, only: [:index] before_action :check_statuses_limit, only: [:index]
override_rate_limit_headers :create, family: :statuses override_rate_limit_headers :create, family: :statuses
@ -67,6 +68,7 @@ class Api::V1::StatusesController < Api::BaseController
current_user.account, current_user.account,
text: status_params[:status], text: status_params[:status],
thread: @thread, thread: @thread,
quoted_status: @quoted_status,
media_ids: status_params[:media_ids], media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive], sensitive: status_params[:sensitive],
spoiler_text: status_params[:spoiler_text], 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 render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404
end 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 def check_statuses_limit
raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT raise(Mastodon::ValidationError) if status_ids.size > DEFAULT_STATUSES_LIMIT
end end
@ -154,6 +165,7 @@ class Api::V1::StatusesController < Api::BaseController
params.permit( params.permit(
:status, :status,
:in_reply_to_id, :in_reply_to_id,
:quoted_status_id,
:sensitive, :sensitive,
:spoiler_text, :spoiler_text,
:visibility, :visibility,

View File

@ -26,6 +26,12 @@ module ContextHelper
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } }, attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' }, 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: { interaction_policies: {
'gts' => 'https://gotosocial.org/ns#', 'gts' => 'https://gotosocial.org/ns#',
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' }, '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? @follow_request_from_object ||= FollowRequest.find_by(target_account: @account, uri: object_uri) unless object_uri.nil?
end 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 def follow_from_object
@follow_from_object ||= ::Follow.find_by(target_account: @account, uri: object_uri) unless object_uri.nil? @follow_from_object ||= ::Follow.find_by(target_account: @account, uri: object_uri) unless object_uri.nil?
end end

View File

@ -4,10 +4,13 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity
def perform def perform
return accept_follow_for_relay if relay_follow? return accept_follow_for_relay if relay_follow?
return accept_follow!(follow_request_from_object) unless follow_request_from_object.nil? 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'] case @object['type']
when 'Follow' when 'Follow'
accept_embedded_follow accept_embedded_follow
when 'QuoteRequest'
accept_embedded_quote_request
end end
end end
@ -31,6 +34,32 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity
RemoteAccountRefreshWorker.perform_async(request.target_account_id) if is_first_follow RemoteAccountRefreshWorker.perform_async(request.target_account_id) if is_first_follow
end 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 def accept_follow_for_relay
relay.update!(state: :accepted) relay.update!(state: :accepted)
end end

View File

@ -5,10 +5,13 @@ class ActivityPub::Activity::Reject < ActivityPub::Activity
return reject_follow_for_relay if relay_follow? return reject_follow_for_relay if relay_follow?
return follow_request_from_object.reject! unless follow_request_from_object.nil? 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 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'] case @object['type']
when 'Follow' when 'Follow'
reject_embedded_follow reject_embedded_follow
when 'QuoteRequest'
reject_embedded_quote
end end
end end
@ -29,6 +32,28 @@ class ActivityPub::Activity::Reject < ActivityPub::Activity
relay.update!(state: :rejected) relay.update!(state: :rejected)
end 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 def relay
@relay ||= Relay.find_by(follow_activity_id: object_uri) unless object_uri.nil? @relay ||= Relay.find_by(follow_activity_id: object_uri) unless object_uri.nil?
end end

View File

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

View File

@ -19,6 +19,11 @@ class StatusPolicy < ApplicationPolicy
end end
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? def reblog?
!requires_mention? && (!private? || owned?) && show? && !blocking_author? !requires_mention? && (!private? || owned?) && show? && !blocking_author?
end end
@ -39,6 +44,14 @@ class StatusPolicy < ApplicationPolicy
private 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? def requires_mention?
record.direct_visibility? || record.limited_visibility? record.direct_visibility? || record.limited_visibility?
end end
@ -61,6 +74,16 @@ class StatusPolicy < ApplicationPolicy
end end
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? def author_blocking_domain?
return false if current_account.nil? || current_account.domain.nil? return false if current_account.nil? || current_account.domain.nil?

View File

@ -3,7 +3,7 @@
class ActivityPub::NoteSerializer < ActivityPub::Serializer class ActivityPub::NoteSerializer < ActivityPub::Serializer
include FormattingHelper include FormattingHelper
context_extensions :atom_uri, :conversation, :sensitive, :voters_count context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :quotes
attributes :id, :type, :summary, attributes :id, :type, :summary,
:in_reply_to, :published, :url, :in_reply_to, :published, :url,
@ -30,6 +30,11 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
attribute :voters_count, if: :poll_and_voters_count? 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 def id
ActivityPub::TagManager.instance.uri_for(object) ActivityPub::TagManager.instance.uri_for(object)
end end
@ -194,6 +199,24 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
object.preloadable_poll&.voters_count object.preloadable_poll&.voters_count
end 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 class MediaAttachmentSerializer < ActivityPub::Serializer
context_extensions :blurhash, :focal_point context_extensions :blurhash, :focal_point

View File

@ -97,12 +97,10 @@ class PostStatusService < BaseService
# we only support incoming quotes so far # we only support incoming quotes so far
status.quote = Quote.new(quoted_status: @quoted_status) 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 }) if @quoted_status.local? && StatusPolicy.new(@status.account, @quoted_status).quote?
# TODO: produce a QuoteAuthorization
# TODO: the following has yet to be implemented: status.quote.accept!
# - handle approval of local users (requires the interactionPolicy PR) end
# - produce a QuoteAuthorization for quotes of local users
# - send a QuoteRequest for quotes of remote users
end end
def safeguard_mentions!(status) def safeguard_mentions!(status)
@ -146,6 +144,7 @@ class PostStatusService < BaseService
DistributionWorker.perform_async(@status.id) DistributionWorker.perform_async(@status.id)
ActivityPub::DistributionWorker.perform_async(@status.id) ActivityPub::DistributionWorker.perform_async(@status.id)
PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll 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 end
def validate_media! 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' require 'rails_helper'
RSpec.describe ActivityPub::Activity::Accept do RSpec.describe ActivityPub::Activity::Accept do
let(:sender) { Fabricate(:account) } let(:sender) { Fabricate(:account, domain: 'example.com') }
let(:recipient) { Fabricate(:account) } 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 describe '#perform' do
subject { described_class.new(json, sender) } subject { described_class.new(json, sender) }
before do context 'with a Follow request' do
allow(RemoteAccountRefreshWorker).to receive(:perform_async) let(:json) do
Fabricate(:follow_request, account: recipient, target_account: sender) {
subject.perform '@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 end
it 'creates a follow relationship' do context 'with a QuoteRequest' do
expect(recipient.following?(sender)).to be true let(:status) { Fabricate(:status, account: recipient) }
end 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 let(:json) do
expect(recipient.requested?(sender)).to be false {
end '@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 it 'marks the quote as approved and distribute an update' do
expect(RemoteAccountRefreshWorker).to have_received(:perform_async).with(sender.id) expect { subject.perform }
end .to change { quote.reload.accepted? }.from(false).to(true)
end .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 context 'when the quoted status is not from the sender of the Accept' do
subject { described_class.new(json, sender) } 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 'when the quoting status is from an unrelated user' do
{ let(:status) { Fabricate(:status, account: Fabricate(:account, domain: 'foobar.com')) }
'@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
it 'marks the relay as accepted' do it 'does not mark the quote as approved and does not distribute an update' do
subject.perform expect { subject.perform }
expect(relay.reload.accepted?).to be true .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 end
end end

View File

@ -5,14 +5,6 @@ require 'rails_helper'
RSpec.describe ActivityPub::Activity::Reject do RSpec.describe ActivityPub::Activity::Reject do
let(:sender) { Fabricate(:account) } let(:sender) { Fabricate(:account) }
let(:recipient) { 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 let(:json) do
{ {
@ -27,124 +19,133 @@ RSpec.describe ActivityPub::Activity::Reject do
describe '#perform' do describe '#perform' do
subject { described_class.new(json, sender) } subject { described_class.new(json, sender) }
context 'when rejecting a pending follow request by target' do context 'when rejecting a Follow' do
before do let(:object_json) do
Fabricate(:follow_request, account: recipient, target_account: sender) {
subject.perform id: 'bar',
type: 'Follow',
actor: ActivityPub::TagManager.instance.uri_for(recipient),
object: ActivityPub::TagManager.instance.uri_for(sender),
}
end end
it 'does not create a follow relationship' do context 'when rejecting a pending follow request by target' do
expect(recipient.following?(sender)).to be false 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 end
it 'removes the follow request' do context 'when rejecting a pending follow request by uri' do
expect(recipient.requested?(sender)).to be false 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
end end
context 'when rejecting a pending follow request by uri' do context 'when given a relay' do
before do subject { described_class.new(json, sender) }
Fabricate(:follow_request, account: recipient, target_account: sender, uri: 'bar')
subject.perform
end
it 'does not create a follow relationship' do let!(:relay) { Fabricate(:relay, state: :pending, follow_activity_id: 'https://abc-123/456') }
expect(recipient.following?(sender)).to be false
end
it 'removes the follow request' do let(:object_json) 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: {
id: 'https://abc-123/456', id: 'https://abc-123/456',
type: 'Follow', type: 'Follow',
actor: ActivityPub::TagManager.instance.uri_for(recipient), actor: ActivityPub::TagManager.instance.uri_for(recipient),
object: ActivityPub::TagManager.instance.uri_for(sender), 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 end
it 'marks the relay as rejected' do context 'with a QuoteRequest' do
subject.perform let(:status) { Fabricate(:status, account: recipient) }
expect(relay.reload.rejected?).to be true 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 end
end end

View File

@ -86,6 +86,92 @@ RSpec.describe StatusPolicy, type: :model do
end end
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 context 'with the permission of reblog?' do
permissions :reblog? do permissions :reblog? do
it 'denies access when private' do it 'denies access when private' do

View File

@ -158,6 +158,27 @@ RSpec.describe '/api/v1/statuses' do
end end
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 context 'with a safeguard' do
let!(:alice) { Fabricate(:account, username: 'alice') } let!(:alice) { Fabricate(:account, username: 'alice') }
let!(:bob) { Fabricate(:account, username: 'bob') } 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_other_first.uri)) # Replies from others
.and(not_include(reply_by_account_visibility_direct.uri)) # Replies with direct visibility .and(not_include(reply_by_account_visibility_direct.uri)) # Replies with direct visibility
end 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 end

View File

@ -291,6 +291,14 @@ RSpec.describe PostStatusService do
) )
end 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 it 'returns existing status when used twice with idempotency key' do
account = Fabricate(:account) account = Fabricate(:account)
status1 = subject.call(account, text: 'test', idempotency: 'meepmeep') 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