diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 23afda32cd..1710fc80b4 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -83,7 +83,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def process_status_params - @status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: @account.followers_url, object: @object) + @status_parser = ActivityPub::Parser::StatusParser.new( + @json, + followers_collection: @account.followers_url, + actor_uri: ActivityPub::TagManager.instance.uri_for(@account), + object: @object + ) attachment_ids = process_attachments.take(Status::MEDIA_ATTACHMENTS_LIMIT).map(&:id) @@ -105,6 +110,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity media_attachment_ids: attachment_ids, ordered_media_attachment_ids: attachment_ids, poll: process_poll, + quote_approval_policy: @status_parser.quote_policy, } end diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb index c13ed49635..f66cd4aab0 100644 --- a/app/lib/activitypub/parser/status_parser.rb +++ b/app/lib/activitypub/parser/status_parser.rb @@ -8,6 +8,7 @@ class ActivityPub::Parser::StatusParser # @param [Hash] json # @param [Hash] options # @option options [String] :followers_collection + # @option options [String] :actor_uri # @option options [Hash] :object def initialize(json, **options) @json = json @@ -101,6 +102,18 @@ class ActivityPub::Parser::StatusParser @object.dig(:shares, :totalItems) end + def quote_policy + flags = 0 + policy = @object.dig('interactionPolicy', 'canQuote') + return flags if policy.blank? + + flags |= quote_subpolicy(policy['automaticApproval']) + flags <<= 16 + flags |= quote_subpolicy(policy['manualApproval']) + + flags + end + def quote_uri %w(quote _misskey_quote quoteUrl quoteUri).filter_map do |key| value_or_id(as_array(@object[key]).first) @@ -113,6 +126,29 @@ class ActivityPub::Parser::StatusParser private + def quote_subpolicy(subpolicy) + flags = 0 + + allowed_actors = as_array(subpolicy) + allowed_actors.uniq! + + flags |= Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] if allowed_actors.delete('as:Public') || allowed_actors.delete('Public') || allowed_actors.delete('https://www.w3.org/ns/activitystreams#Public') + flags |= Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] if allowed_actors.delete(@options[:followers_collection]) + # TODO: we don't actually store that collection URI + # flags |= Status::QUOTE_APPROVAL_POLICY_FLAGS[:followed] + + # Remove the special-meaning actor URI + allowed_actors.delete(@options[:actor_uri]) + + # Tagged users are always allowed, so remove them + allowed_actors -= as_array(@object['tag']).filter_map { |tag| tag['href'] if equals_or_includes?(tag['type'], 'Mention') } + + # Any unrecognized actor is marked as unknown + flags |= Status::QUOTE_APPROVAL_POLICY_FLAGS[:unknown] unless allowed_actors.empty? + + flags + end + def raw_language_code if content_language_map? @object['contentMap'].keys.first diff --git a/app/models/status.rb b/app/models/status.rb index f9afdcca8b..5e89fc3531 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -28,6 +28,7 @@ # trendable :boolean # ordered_media_attachment_ids :bigint(8) is an Array # fetched_replies_at :datetime +# quote_approval_policy :integer default(0), not null # class Status < ApplicationRecord @@ -44,6 +45,13 @@ class Status < ApplicationRecord 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 diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 6a1066a05d..e02cc0914b 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -10,7 +10,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService @activity_json = activity_json @json = object_json - @status_parser = ActivityPub::Parser::StatusParser.new(@json) + @status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: status.account.followers_url, actor_uri: ActivityPub::TagManager.instance.uri_for(status.account)) @uri = @status_parser.uri @status = status @account = status.account @@ -41,6 +41,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService Status.transaction do record_previous_edit! update_media_attachments! + update_interaction_policies! update_poll! update_immediate_attributes! update_metadata! @@ -62,12 +63,17 @@ class ActivityPub::ProcessStatusUpdateService < BaseService def handle_implicit_update! with_redis_lock("create:#{@uri}") do + update_interaction_policies! update_poll!(allow_significant_changes: false) queue_poll_notifications! update_counts! end end + def update_interaction_policies! + @status.quote_approval_policy = @status_parser.quote_policy + end + def update_media_attachments! previous_media_attachments = @status.media_attachments.to_a previous_media_attachments_ids = @status.ordered_media_attachment_ids || previous_media_attachments.map(&:id) diff --git a/db/migrate/20250428095029_add_quote_approval_policy_to_statuses.rb b/db/migrate/20250428095029_add_quote_approval_policy_to_statuses.rb new file mode 100644 index 0000000000..5fbf82f02c --- /dev/null +++ b/db/migrate/20250428095029_add_quote_approval_policy_to_statuses.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddQuoteApprovalPolicyToStatuses < ActiveRecord::Migration[8.0] + def change + add_column :statuses, :quote_approval_policy, :integer, null: false, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 194ffc178f..db1687ba99 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_04_25_134654) do +ActiveRecord::Schema[8.0].define(version: 2025_04_28_095029) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -1086,6 +1086,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_25_134654) do t.boolean "trendable" t.bigint "ordered_media_attachment_ids", array: true t.datetime "fetched_replies_at" + t.integer "quote_approval_policy", default: 0, null: false t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" t.index ["account_id"], name: "index_statuses_on_account_id" t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)" diff --git a/spec/lib/activitypub/parser/status_parser_spec.rb b/spec/lib/activitypub/parser/status_parser_spec.rb index 5d9f008db1..dddeae31e0 100644 --- a/spec/lib/activitypub/parser/status_parser_spec.rb +++ b/spec/lib/activitypub/parser/status_parser_spec.rb @@ -7,10 +7,11 @@ RSpec.describe ActivityPub::Parser::StatusParser do let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers', domain: 'example.com', uri: 'https://example.com/actor') } let(:follower) { Fabricate(:account, username: 'bob') } + let(:context) { 'https://www.w3.org/ns/activitystreams' } let(:json) do { - '@context': 'https://www.w3.org/ns/activitystreams', + '@context': context, id: [ActivityPub::TagManager.instance.uri_for(sender), '#foo'].join, type: 'Create', actor: ActivityPub::TagManager.instance.uri_for(sender), @@ -47,4 +48,116 @@ RSpec.describe ActivityPub::Parser::StatusParser do language: :en ) end + + describe '#quote_policy' do + subject do + described_class + .new( + json, + actor_uri: ActivityPub::TagManager.instance.uri_for(sender), + followers_collection: sender.followers_url + ).quote_policy + end + + let(:context) do + [ + 'https://www.w3.org/ns/activitystreams', + { + gts: 'https://gotosocial.org/ns#', + interactionPolicy: { + '@id': 'gts:interactionPolicy', + '@type': '@id', + }, + canQuote: { + '@id': 'gts:canQuote', + '@type': '@id', + }, + automaticApproval: { + '@id': 'gts:automaticApproval', + '@type': '@id', + }, + manualApproval: { + '@id': 'gts:manualApproval', + '@type': '@id', + }, + }, + ] + end + + context 'when nobody is allowed to quote' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), 'post1'].join('/'), + type: 'Note', + to: [ + 'https://www.w3.org/ns/activitystreams#Public', + ActivityPub::TagManager.instance.uri_for(follower), + ], + interactionPolicy: { + canQuote: { + automaticApproval: ActivityPub::TagManager.instance.uri_for(sender), + }, + }, + content: 'bleh', + published: 1.hour.ago.utc.iso8601, + updated: 1.hour.ago.utc.iso8601, + } + end + + it 'returns a policy not allowing anyone to quote' do + expect(subject).to eq 0 + end + end + + context 'when everybody is allowed to quote' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), 'post1'].join('/'), + type: 'Note', + to: [ + 'https://www.w3.org/ns/activitystreams#Public', + ActivityPub::TagManager.instance.uri_for(follower), + ], + interactionPolicy: { + canQuote: { + automaticApproval: 'https://www.w3.org/ns/activitystreams#Public', + }, + }, + content: 'bleh', + published: 1.hour.ago.utc.iso8601, + updated: 1.hour.ago.utc.iso8601, + } + end + + it 'returns a policy not allowing anyone to quote' do + expect(subject).to eq(Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16) + end + end + + context 'when everybody is allowed to quote but only followers are automatically approved' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), 'post1'].join('/'), + type: 'Note', + to: [ + 'https://www.w3.org/ns/activitystreams#Public', + ActivityPub::TagManager.instance.uri_for(follower), + ], + interactionPolicy: { + canQuote: { + automaticApproval: sender.followers_url, + manualApproval: 'https://www.w3.org/ns/activitystreams#Public', + }, + }, + content: 'bleh', + published: 1.hour.ago.utc.iso8601, + updated: 1.hour.ago.utc.iso8601, + } + end + + it 'returns a policy allowing everyone including followers' do + expect(subject).to eq Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] | (Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16) + end + end + end end