Add support for local quote stamps (#35626)
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
|
||||||
|
include Authorization
|
||||||
|
|
||||||
|
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||||
|
|
||||||
|
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||||
|
before_action :set_quote_authorization
|
||||||
|
|
||||||
|
def show
|
||||||
|
expires_in 0, public: @quote.status.distributable? && public_fetch_mode?
|
||||||
|
render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def pundit_user
|
||||||
|
signed_request_account
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_quote_authorization
|
||||||
|
@quote = Quote.accepted.where(quoted_account: @account).find(params[:id])
|
||||||
|
authorize @quote.status, :show?
|
||||||
|
rescue Mastodon::NotPermittedError
|
||||||
|
not_found
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -39,6 +39,12 @@ module ContextHelper
|
|||||||
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
|
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
|
||||||
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
|
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
|
||||||
},
|
},
|
||||||
|
quote_authorizations: {
|
||||||
|
'gts' => 'https://gotosocial.org/ns#',
|
||||||
|
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
|
||||||
|
'interactingObject' => { '@id' => 'gts:interactingObject' },
|
||||||
|
'interactionTarget' => { '@id' => 'gts:interactionTarget' },
|
||||||
|
},
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def full_context
|
def full_context
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||||||
return if @quote_uri.blank?
|
return if @quote_uri.blank?
|
||||||
|
|
||||||
approval_uri = @status_parser.quote_approval_uri
|
approval_uri = @status_parser.quote_approval_uri
|
||||||
approval_uri = nil if unsupported_uri_scheme?(approval_uri)
|
approval_uri = nil if unsupported_uri_scheme?(approval_uri) || TagManager.instance.local_url?(approval_uri)
|
||||||
@quote = Quote.new(account: @account, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?)
|
@quote = Quote.new(account: @account, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ class ActivityPub::TagManager
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def approval_uri_for(quote, check_approval: true)
|
||||||
|
return quote.approval_uri unless quote.quoted_account&.local?
|
||||||
|
return if check_approval && !quote.accepted?
|
||||||
|
|
||||||
|
account_quote_authorization_url(quote.quoted_account, quote)
|
||||||
|
end
|
||||||
|
|
||||||
def key_uri_for(target)
|
def key_uri_for(target)
|
||||||
[uri_for(target), '#main-key'].join
|
[uri_for(target), '#main-key'].join
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class Quote < ApplicationRecord
|
|||||||
before_validation :set_accounts
|
before_validation :set_accounts
|
||||||
before_validation :set_activity_uri, only: :create, if: -> { account.local? && quoted_account&.remote? }
|
before_validation :set_activity_uri, only: :create, if: -> { account.local? && quoted_account&.remote? }
|
||||||
validates :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? }
|
validates :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? }
|
||||||
|
validates :approval_uri, absence: true, if: -> { quoted_account&.local? }
|
||||||
validate :validate_visibility
|
validate :validate_visibility
|
||||||
|
|
||||||
def accept!
|
def accept!
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ class ActivityPub::DeleteQuoteAuthorizationSerializer < ActivityPub::Serializer
|
|||||||
attribute :virtual_object, key: :object
|
attribute :virtual_object, key: :object
|
||||||
|
|
||||||
def id
|
def id
|
||||||
[object.approval_uri, '#delete'].join
|
[ActivityPub::TagManager.instance.approval_uri_for(object, check_approval: false), '#delete'].join
|
||||||
end
|
end
|
||||||
|
|
||||||
def virtual_object
|
def virtual_object
|
||||||
object.approval_uri
|
ActivityPub::TagManager.instance.approval_uri_for(object, check_approval: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
def type
|
def type
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
|||||||
end
|
end
|
||||||
|
|
||||||
def quote_authorization?
|
def quote_authorization?
|
||||||
object.quote&.approval_uri.present?
|
object.quote.present? && ActivityPub::TagManager.instance.approval_uri_for(object.quote).present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def quote
|
def quote
|
||||||
@@ -213,8 +213,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
|||||||
end
|
end
|
||||||
|
|
||||||
def quote_authorization
|
def quote_authorization
|
||||||
# TODO: approval of local quotes may work differently, perhaps?
|
ActivityPub::TagManager.instance.approval_uri_for(object.quote)
|
||||||
object.quote.approval_uri
|
|
||||||
end
|
end
|
||||||
|
|
||||||
class MediaAttachmentSerializer < ActivityPub::Serializer
|
class MediaAttachmentSerializer < ActivityPub::Serializer
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::QuoteAuthorizationSerializer < ActivityPub::Serializer
|
||||||
|
include RoutingHelper
|
||||||
|
|
||||||
|
context_extensions :quote_authorizations
|
||||||
|
|
||||||
|
attributes :id, :type, :attributed_to, :interacting_object, :interaction_target
|
||||||
|
|
||||||
|
def id
|
||||||
|
ActivityPub::TagManager.instance.approval_uri_for(object)
|
||||||
|
end
|
||||||
|
|
||||||
|
def type
|
||||||
|
'QuoteAuthorization'
|
||||||
|
end
|
||||||
|
|
||||||
|
def attributed_to
|
||||||
|
ActivityPub::TagManager.instance.uri_for(object.quoted_account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def interaction_target
|
||||||
|
ActivityPub::TagManager.instance.uri_for(object.quoted_status)
|
||||||
|
end
|
||||||
|
|
||||||
|
def interacting_object
|
||||||
|
ActivityPub::TagManager.instance.uri_for(object.status)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -278,10 +278,10 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
|
|||||||
return unless quote_uri.present? && @status.quote.present?
|
return unless quote_uri.present? && @status.quote.present?
|
||||||
|
|
||||||
quote = @status.quote
|
quote = @status.quote
|
||||||
return if quote.quoted_status.present? && ActivityPub::TagManager.instance.uri_for(quote.quoted_status) != quote_uri
|
return if quote.quoted_status.present? && (ActivityPub::TagManager.instance.uri_for(quote.quoted_status) != quote_uri || quote.quoted_status.local?)
|
||||||
|
|
||||||
approval_uri = @status_parser.quote_approval_uri
|
approval_uri = @status_parser.quote_approval_uri
|
||||||
approval_uri = nil if unsupported_uri_scheme?(approval_uri)
|
approval_uri = nil if unsupported_uri_scheme?(approval_uri) || TagManager.instance.local_url?(approval_uri)
|
||||||
|
|
||||||
quote.update(approval_uri: approval_uri, state: :pending, legacy: @status_parser.legacy_quote?) if quote.approval_uri != @status_parser.quote_approval_uri
|
quote.update(approval_uri: approval_uri, state: :pending, legacy: @status_parser.legacy_quote?) if quote.approval_uri != @status_parser.quote_approval_uri
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
|
|||||||
|
|
||||||
if quote_uri.present?
|
if quote_uri.present?
|
||||||
approval_uri = @status_parser.quote_approval_uri
|
approval_uri = @status_parser.quote_approval_uri
|
||||||
approval_uri = nil if unsupported_uri_scheme?(approval_uri)
|
approval_uri = nil if unsupported_uri_scheme?(approval_uri) || TagManager.instance.local_url?(approval_uri)
|
||||||
|
|
||||||
if @status.quote.present?
|
if @status.quote.present?
|
||||||
# If the quoted post has changed, discard the old object and create a new one
|
# If the quoted post has changed, discard the old object and create a new one
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class ActivityPub::VerifyQuoteService < BaseService
|
|||||||
@fetching_error = nil
|
@fetching_error = nil
|
||||||
|
|
||||||
fetch_quoted_post_if_needed!(fetchable_quoted_uri, prefetched_body: prefetched_quoted_object)
|
fetch_quoted_post_if_needed!(fetchable_quoted_uri, prefetched_body: prefetched_quoted_object)
|
||||||
|
return handle_local_quote! if quote.quoted_account&.local?
|
||||||
return if fast_track_approval! || quote.approval_uri.blank?
|
return if fast_track_approval! || quote.approval_uri.blank?
|
||||||
|
|
||||||
@json = fetch_approval_object(quote.approval_uri, prefetched_body: prefetched_approval)
|
@json = fetch_approval_object(quote.approval_uri, prefetched_body: prefetched_approval)
|
||||||
@@ -34,6 +35,15 @@ class ActivityPub::VerifyQuoteService < BaseService
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def handle_local_quote!
|
||||||
|
@quote.update!(approval_uri: nil)
|
||||||
|
if StatusPolicy.new(@quote.account, @quote.quoted_status).quote?
|
||||||
|
@quote.accept!
|
||||||
|
else
|
||||||
|
@quote.reject!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# FEP-044f defines rules that don't require the approval flow
|
# FEP-044f defines rules that don't require the approval flow
|
||||||
def fast_track_approval!
|
def fast_track_approval!
|
||||||
return false if @quote.quoted_status_id.blank?
|
return false if @quote.quoted_status_id.blank?
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ Rails.application.routes.draw do
|
|||||||
resource :inbox, only: [:create]
|
resource :inbox, only: [:create]
|
||||||
resources :collections, only: [:show]
|
resources :collections, only: [:show]
|
||||||
resource :followers_synchronization, only: [:show]
|
resource :followers_synchronization, only: [:show]
|
||||||
|
resources :quote_authorizations, only: [:show]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -888,7 +888,7 @@ RSpec.describe ActivityPub::Activity::Create do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'with an unverifiable quote of a known post' do
|
context 'with an unverifiable quote of a known post' do
|
||||||
let(:quoted_status) { Fabricate(:status) }
|
let(:quoted_status) { Fabricate(:status, account: Fabricate(:account, domain: 'example.com')) }
|
||||||
|
|
||||||
let(:object_json) do
|
let(:object_json) do
|
||||||
build_object(
|
build_object(
|
||||||
|
|||||||
49
spec/requests/activitypub/quote_authorizations_spec.rb
Normal file
49
spec/requests/activitypub/quote_authorizations_spec.rb
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'ActivityPub QuoteAuthorization endpoint' do
|
||||||
|
let(:account) { Fabricate(:account, domain: nil) }
|
||||||
|
let(:status) { Fabricate :status, account: account }
|
||||||
|
let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted) }
|
||||||
|
|
||||||
|
before { Fabricate :favourite, status: status }
|
||||||
|
|
||||||
|
describe 'GET /accounts/:account_username/quote_authorizations/:quote_id' do
|
||||||
|
context 'with an accepted quote' do
|
||||||
|
it 'returns http success and activity json' do
|
||||||
|
get account_quote_authorization_url(quote.quoted_account, quote)
|
||||||
|
|
||||||
|
expect(response)
|
||||||
|
.to have_http_status(200)
|
||||||
|
expect(response.media_type)
|
||||||
|
.to eq 'application/activity+json'
|
||||||
|
|
||||||
|
expect(response.parsed_body)
|
||||||
|
.to include(type: 'QuoteAuthorization')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an incorrect quote authorization URL' do
|
||||||
|
it 'returns http not found' do
|
||||||
|
get account_quote_authorization_url(quote.account, quote)
|
||||||
|
|
||||||
|
expect(response)
|
||||||
|
.to have_http_status(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a rejected quote' do
|
||||||
|
before do
|
||||||
|
quote.reject!
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http not found' do
|
||||||
|
get account_quote_authorization_url(quote.quoted_account, quote)
|
||||||
|
|
||||||
|
expect(response)
|
||||||
|
.to have_http_status(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,13 +7,13 @@ RSpec.describe ActivityPub::DeleteQuoteAuthorizationSerializer do
|
|||||||
|
|
||||||
describe 'serializing an object' do
|
describe 'serializing an object' do
|
||||||
let(:status) { Fabricate(:status) }
|
let(:status) { Fabricate(:status) }
|
||||||
let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted, approval_uri: "https://#{Rails.configuration.x.web_domain}/approvals/1234") }
|
let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted) }
|
||||||
|
|
||||||
it 'returns expected attributes' do
|
it 'returns expected attributes' do
|
||||||
expect(subject.deep_symbolize_keys)
|
expect(subject.deep_symbolize_keys)
|
||||||
.to include(
|
.to include(
|
||||||
actor: eq(ActivityPub::TagManager.instance.uri_for(status.account)),
|
actor: eq(ActivityPub::TagManager.instance.uri_for(status.account)),
|
||||||
object: quote.approval_uri,
|
object: ActivityPub::TagManager.instance.approval_uri_for(quote, check_approval: false),
|
||||||
type: 'Delete'
|
type: 'Delete'
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -44,8 +44,7 @@ RSpec.describe ActivityPub::NoteSerializer do
|
|||||||
|
|
||||||
context 'with a quote' do
|
context 'with a quote' do
|
||||||
let(:quoted_status) { Fabricate(:status) }
|
let(:quoted_status) { Fabricate(:status) }
|
||||||
let(:approval_uri) { 'https://example.com/foo/bar' }
|
let!(:quote) { Fabricate(:quote, status: parent, quoted_status: quoted_status, state: :accepted) }
|
||||||
let!(:quote) { Fabricate(:quote, status: parent, quoted_status: quoted_status, approval_uri: approval_uri) }
|
|
||||||
|
|
||||||
it 'has the expected shape' do
|
it 'has the expected shape' do
|
||||||
expect(subject).to include({
|
expect(subject).to include({
|
||||||
@@ -53,7 +52,7 @@ RSpec.describe ActivityPub::NoteSerializer do
|
|||||||
'quote' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status),
|
'quote' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status),
|
||||||
'quoteUri' => 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),
|
'_misskey_quote' => ActivityPub::TagManager.instance.uri_for(quote.quoted_status),
|
||||||
'quoteAuthorization' => approval_uri,
|
'quoteAuthorization' => ActivityPub::TagManager.instance.approval_uri_for(quote),
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ActivityPub::QuoteAuthorizationSerializer do
|
||||||
|
subject { serialized_record_json(quote, described_class, adapter: ActivityPub::Adapter) }
|
||||||
|
|
||||||
|
describe 'serializing an object' do
|
||||||
|
let(:status) { Fabricate(:status) }
|
||||||
|
let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted) }
|
||||||
|
|
||||||
|
it 'returns expected attributes' do
|
||||||
|
expect(subject.deep_symbolize_keys)
|
||||||
|
.to include(
|
||||||
|
attributedTo: eq(ActivityPub::TagManager.instance.uri_for(status.account)),
|
||||||
|
interactionTarget: ActivityPub::TagManager.instance.uri_for(status),
|
||||||
|
interactingObject: ActivityPub::TagManager.instance.uri_for(quote.status),
|
||||||
|
type: 'QuoteAuthorization'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -564,6 +564,80 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when an approved quote of a local post gets updated through an explicit update' do
|
||||||
|
let(:quoted_account) { Fabricate(:account) }
|
||||||
|
let(:quoted_status) { Fabricate(:status, account: quoted_account, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16) }
|
||||||
|
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, state: :accepted) }
|
||||||
|
let(:approval_uri) { ActivityPub::TagManager.instance.approval_uri_for(quote) }
|
||||||
|
|
||||||
|
let(:payload) do
|
||||||
|
{
|
||||||
|
'@context': [
|
||||||
|
'https://www.w3.org/ns/activitystreams',
|
||||||
|
{
|
||||||
|
'@id': 'https://w3id.org/fep/044f#quote',
|
||||||
|
'@type': '@id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@id': 'https://w3id.org/fep/044f#quoteAuthorization',
|
||||||
|
'@type': '@id',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 'foo',
|
||||||
|
type: 'Note',
|
||||||
|
summary: 'Show more',
|
||||||
|
content: 'Hello universe',
|
||||||
|
updated: '2021-09-08T22:39:25Z',
|
||||||
|
quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
|
||||||
|
quoteAuthorization: approval_uri,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates the quote post without changing the quote status' do
|
||||||
|
expect { subject.call(status, json, json) }
|
||||||
|
.to not_change(quote, :approval_uri)
|
||||||
|
.and not_change(quote, :state).from('accepted')
|
||||||
|
.and change(status, :text).from('Hello world').to('Hello universe')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when an unapproved quote of a local post gets updated through an explicit update and claims approval' do
|
||||||
|
let(:quoted_account) { Fabricate(:account) }
|
||||||
|
let(:quoted_status) { Fabricate(:status, account: quoted_account, quote_approval_policy: 0) }
|
||||||
|
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, state: :rejected) }
|
||||||
|
let(:approval_uri) { ActivityPub::TagManager.instance.approval_uri_for(quote) }
|
||||||
|
|
||||||
|
let(:payload) do
|
||||||
|
{
|
||||||
|
'@context': [
|
||||||
|
'https://www.w3.org/ns/activitystreams',
|
||||||
|
{
|
||||||
|
'@id': 'https://w3id.org/fep/044f#quote',
|
||||||
|
'@type': '@id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@id': 'https://w3id.org/fep/044f#quoteAuthorization',
|
||||||
|
'@type': '@id',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 'foo',
|
||||||
|
type: 'Note',
|
||||||
|
summary: 'Show more',
|
||||||
|
content: 'Hello universe',
|
||||||
|
updated: '2021-09-08T22:39:25Z',
|
||||||
|
quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
|
||||||
|
quoteAuthorization: approval_uri,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates the quote post without changing the quote status' do
|
||||||
|
expect { subject.call(status, json, json) }
|
||||||
|
.to not_change(quote, :approval_uri)
|
||||||
|
.and not_change(quote, :state).from('rejected')
|
||||||
|
.and change(status, :text).from('Hello world').to('Hello universe')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when the status has an existing verified quote and removes an approval link through an explicit update' do
|
context 'when the status has an existing verified quote and removes an approval link through an explicit update' do
|
||||||
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
|
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
|
||||||
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
|
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ RSpec.describe RevokeQuoteService do
|
|||||||
|
|
||||||
let(:status) { Fabricate(:status, account: alice) }
|
let(:status) { Fabricate(:status, account: alice) }
|
||||||
|
|
||||||
let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted, approval_uri: "https://#{Rails.configuration.x.web_domain}/approvals/1234") }
|
let(:quote) { Fabricate(:quote, quoted_status: status, state: :accepted) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
hank.follow!(alice)
|
hank.follow!(alice)
|
||||||
|
|||||||
Reference in New Issue
Block a user