2
0

Add experimental basic quote post authoring (#35355)

This commit is contained in:
Claire
2025-07-25 14:35:24 +02:00
committed by GitHub
parent 81da377d8e
commit 5a88b7f683
25 changed files with 619 additions and 41 deletions

View File

@@ -10,6 +10,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
@@ -76,6 +77,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],
@@ -147,6 +149,16 @@ 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
# TODO: distinguish between non-existing and non-quotable posts
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
@@ -163,6 +175,7 @@ class Api::V1::StatusesController < Api::BaseController
params.permit(
:status,
:in_reply_to_id,
:quoted_status_id,
:sensitive,
:spoiler_text,
:visibility,

View File

@@ -116,6 +116,20 @@ class ActivityPub::Activity
fetch_remote_original_status
end
def quote_from_request_json(json)
quoted_status_uri = value_or_id(json['object'])
quoting_status_uri = value_or_id(json['instrument'])
return if quoting_status_uri.nil? || quoted_status_uri.nil?
quoting_status = status_from_uri(quoting_status_uri)
return unless quoting_status.present? && quoting_status.quote.present?
quoted_status = status_from_uri(quoted_status_uri)
return unless quoted_status.present? && quoted_status.account == @account && quoting_status.quote.quoted_status == quoted_status
quoting_status.quote
end
def dereference_object!
return unless @object.is_a?(String)
@@ -143,6 +157,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,29 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity
RemoteAccountRefreshWorker.perform_async(request.target_account_id) if is_first_follow
end
def accept_embedded_quote_request
approval_uri = value_or_id(first_of_value(@json['result']))
return if approval_uri.nil?
quote = quote_from_request_json(@object)
return unless quote.present? && quote.status.local?
accept_quote!(quote)
end
def accept_quote!(quote)
approval_uri = value_or_id(first_of_value(@json['result']))
return if unsupported_uri_scheme?(approval_uri) || quote.quoted_account != @account || !quote.status.local?
# NOTE: we are not going through `ActivityPub::VerifyQuoteService` as the `Accept` is as authoritative
# as the stamp, but this means we are not checking the stamp, which may lead to inconsistencies
# in case of an implementation bug
quote.update!(state: :accepted, approval_uri: approval_uri)
DistributionWorker.perform_async(quote.status_id, { 'update' => true })
ActivityPub::StatusUpdateDistributionWorker.perform_async(quote.status_id, { 'updated_at' => Time.now.utc.iso8601 })
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_request
end
end
@@ -29,6 +32,20 @@ class ActivityPub::Activity::Reject < ActivityPub::Activity
relay.update!(state: :rejected)
end
def reject_embedded_quote_request
quote = quote_from_request_json(@object)
return unless quote.present? && quote.status.local?
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?('_misskey') || LanguagesHelper::ISO_639_1_REGIONAL.key?(value.to_sym)
value
else
value.underscore.camelize(:lower)

View File

@@ -71,6 +71,8 @@ class StatusCacheHydrator
payload[:bookmarked] = Bookmark.exists?(account_id: account_id, status_id: status.id)
payload[:pinned] = StatusPin.exists?(account_id: account_id, status_id: status.id) if status.account_id == account_id
payload[:filtered] = mapped_applied_custom_filter(account_id, status)
# TODO: performance optimization by not loading `Account` twice
payload[:quote_approval][:current_user] = status.quote_policy_for_account(Account.find_by(id: account_id)) if payload[:quote_approval]
payload[:quote] = hydrate_quote_payload(payload[:quote], status.quote, account_id, nested:) if payload[:quote]
end

View File

@@ -0,0 +1,63 @@
# frozen_string_literal: true
module Status::InteractionPolicyConcern
extend ActiveSupport::Concern
QUOTE_APPROVAL_POLICY_FLAGS = {
unknown: (1 << 0),
public: (1 << 1),
followers: (1 << 2),
followed: (1 << 3),
}.freeze
def quote_policy_as_keys(kind)
case kind
when :automatic
policy = quote_approval_policy >> 16
when :manual
policy = quote_approval_policy & 0xFFFF
end
QUOTE_APPROVAL_POLICY_FLAGS.keys.select { |key| policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[key]) }.map(&:to_s)
end
# Returns `:automatic`, `:manual`, `:unknown` or `:denied`
def quote_policy_for_account(other_account, preloaded_relations: {})
return :denied if other_account.nil?
following_author = nil
# Post author is always allowed to quote themselves
return :automatic if account_id == other_account.id
automatic_policy = quote_approval_policy >> 16
manual_policy = quote_approval_policy & 0xFFFF
# Checking for public policy first because it's less expensive than looking at mentions
return :automatic if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public])
# Mentioned users are always allowed to quote
if active_mentions.loaded?
return :automatic if active_mentions.any? { |mention| mention.account_id == other_account.id }
elsif active_mentions.exists?(account: other_account)
return :automatic
end
if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:followers])
following_author = preloaded_relations[:following] ? preloaded_relations[:following][account_id] : other_account.following?(account) if following_author.nil?
return :automatic if following_author
end
# We don't know we are allowed by the automatic policy, considering the manual one
return :manual if manual_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public])
if manual_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:followers])
following_author = preloaded_relations[:following] ? preloaded_relations[:following][account_id] : other_account.following?(account) if following_author.nil?
return :manual if following_author
end
return :unknown if (automatic_policy | manual_policy).anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:unknown])
:denied
end
end

View File

@@ -43,16 +43,10 @@ class Status < ApplicationRecord
include Status::SnapshotConcern
include Status::ThreadingConcern
include Status::Visibility
include Status::InteractionPolicyConcern
MEDIA_ATTACHMENTS_LIMIT = 4
QUOTE_APPROVAL_POLICY_FLAGS = {
unknown: (1 << 0),
public: (1 << 1),
followers: (1 << 2),
followed: (1 << 3),
}.freeze
rate_limit by: :account, family: :statuses
self.discard_column = :deleted_at

View File

@@ -19,6 +19,11 @@ class StatusPolicy < ApplicationPolicy
end
end
# This is about requesting a quote post, not validating it
def quote?
record.quote_policy_for_account(current_account, preloaded_relations: @preloaded_relations) != :denied
end
def reblog?
!requires_mention? && (!private? || owned?) && show? && !blocking_author?
end

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

@@ -32,6 +32,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
has_one :quote, key: :quote, serializer: REST::QuoteSerializer
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
has_one :quote_approval, if: -> { Mastodon::Feature.outgoing_quotes_enabled? }
def quote
object.quote if object.quote&.acceptable?
@@ -159,6 +160,14 @@ class REST::StatusSerializer < ActiveModel::Serializer
object.active_mentions.to_a.sort_by(&:id)
end
def quote_approval
{
automatic: object.quote_policy_as_keys(:automatic),
manual: object.quote_policy_as_keys(:manual),
current_user: object.quote_policy_for_account(current_user&.account),
}
end
private
def relationships

View File

@@ -96,13 +96,11 @@ class PostStatusService < BaseService
# NOTE: for now this is only for convenience in testing, as we don't support the request flow nor serialize quotes in ActivityPub
# 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
status.quote = Quote.create(quoted_status: @quoted_status, status: status)
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&.inbox_url].compact
end
def payload
@payload ||= Oj.dump(serialize_payload(@quote, ActivityPub::QuoteRequestSerializer, signer: @account))
end
end

View File

@@ -17,10 +17,10 @@ class ActivityPub::StatusUpdateDistributionWorker < ActivityPub::DistributionWor
def activity
ActivityPub::ActivityPresenter.new(
id: [ActivityPub::TagManager.instance.uri_for(@status), '#updates/', @status.edited_at.to_i].join,
id: [ActivityPub::TagManager.instance.uri_for(@status), '#updates/', @options[:updated_at]&.to_datetime&.to_i || @status.edited_at.to_i].join,
type: 'Update',
actor: ActivityPub::TagManager.instance.uri_for(@status.account),
published: @status.edited_at,
published: @options[:updated_at]&.to_datetime || @status.edited_at,
to: ActivityPub::TagManager.instance.to(@status),
cc: ActivityPub::TagManager.instance.cc(@status),
virtual_object: @status