From 79062bfc2fcf9053c8f859615dfcc27b2da65ee3 Mon Sep 17 00:00:00 2001 From: KMY Date: Sat, 12 Aug 2023 18:22:22 +0900 Subject: [PATCH] Add mutual visibility support --- app/controllers/statuses_controller.rb | 7 +++++- .../compose/components/privacy_dropdown.jsx | 5 +++- app/lib/status_reach_finder.rb | 4 +-- app/models/concerns/account_interactions.rb | 4 +++ app/models/status.rb | 1 + app/models/status_capability_token.rb | 25 +++++++++++++++++++ .../activitypub/activity_presenter.rb | 4 ++- app/serializers/rest/instance_serializer.rb | 1 + .../rest/v1/instance_serializer.rb | 1 + app/services/backup_service.rb | 2 +- app/services/post_status_service.rb | 4 ++- app/services/process_mentions_service.rb | 13 +++++++++- ...12083752_create_status_capability_token.rb | 12 +++++++++ db/schema.rb | 11 +++++++- 14 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 app/models/status_capability_token.rb create mode 100644 db/migrate/20230812083752_create_status_capability_token.rb diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index effaba3630..50a8763b72 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -62,7 +62,12 @@ class StatusesController < ApplicationController def set_status @status = @account.statuses.find(params[:id]) - authorize @status, :show? + + if request.authorization.present? && request.authorization.match(/^Bearer /i) + raise Mastodon::NotPermittedError unless @status.capability_tokens.find_by(token: request.authorization.gsub(/^Bearer /i, '')) + else + authorize @status, :show? + end rescue Mastodon::NotPermittedError not_found end diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx index 2fe7d6e1f6..22f6a44c2b 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx @@ -24,6 +24,8 @@ const messages = defineMessages({ login_long: { id: 'privacy.login.long', defaultMessage: 'Login user only' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' }, + mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual' }, + mutual_long: { id: 'privacy.mutual.long', defaultMessage: 'Mutual follows only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' }, change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, @@ -119,7 +121,7 @@ class PrivacyDropdownMenu extends PureComponent { setFocusRef = c => { this.focusedItem = c; }; - + render () { const { style, items, value } = this.props; @@ -231,6 +233,7 @@ class PrivacyDropdown extends PureComponent { { icon: 'cloud', value: 'public_unlisted', text: formatMessage(messages.public_unlisted_short), meta: formatMessage(messages.public_unlisted_long) }, { icon: 'key', value: 'login', text: formatMessage(messages.login_short), meta: formatMessage(messages.login_long) }, { icon: 'unlock', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) }, + { icon: 'exchange', value: 'mutual', text: formatMessage(messages.mutual_short), meta: formatMessage(messages.mutual_long) }, { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, { icon: 'at', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, ]; diff --git a/app/lib/status_reach_finder.rb b/app/lib/status_reach_finder.rb index 6cce4e04fb..6117949f9f 100644 --- a/app/lib/status_reach_finder.rb +++ b/app/lib/status_reach_finder.rb @@ -29,7 +29,7 @@ class StatusReachFinder if @status.reblog? [] - else + elsif !@status.limited_visibility? Account.where(id: reached_account_ids).where.not(domain: banned_domains).inboxes end end @@ -37,7 +37,7 @@ class StatusReachFinder def reached_account_inboxes_for_misskey if @status.reblog? [] - else + elsif !@status.limited_visibility? Account.where(id: reached_account_ids).where(domain: banned_domains_for_misskey).inboxes end end diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 649e64e398..42a42e8f9f 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -303,6 +303,10 @@ module AccountInteractions end end + def mutuals + followers.merge(Account.where(id: following)) + end + def relations_map(account_ids, domains = nil, **options) relations = { blocked_by: Account.blocked_by_map(account_ids, id), diff --git a/app/models/status.rb b/app/models/status.rb index 348a1e697b..99bc0290f0 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -79,6 +79,7 @@ class Status < ApplicationRecord has_many :references, through: :reference_objects, class_name: 'Status', source: :target_status has_many :referenced_by_status_objects, foreign_key: 'target_status_id', class_name: 'StatusReference', inverse_of: :target_status, dependent: :destroy has_many :referenced_by_statuses, through: :referenced_by_status_objects, class_name: 'Status', source: :status + has_many :capability_tokens, class_name: 'StatusCapabilityToken', inverse_of: :status, dependent: :destroy has_and_belongs_to_many :tags has_and_belongs_to_many :preview_cards diff --git a/app/models/status_capability_token.rb b/app/models/status_capability_token.rb new file mode 100644 index 0000000000..6bd7916497 --- /dev/null +++ b/app/models/status_capability_token.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: status_capability_tokens +# +# id :bigint(8) not null, primary key +# status_id :bigint(8) not null +# token :string +# created_at :datetime not null +# updated_at :datetime not null +# +class StatusCapabilityToken < ApplicationRecord + belongs_to :status + + validates :token, presence: true + + before_validation :generate_token, on: :create + + private + + def generate_token + self.token = Doorkeeper::OAuth::Helpers::UniqueToken.generate + end +end diff --git a/app/presenters/activitypub/activity_presenter.rb b/app/presenters/activitypub/activity_presenter.rb index 9524e64179..fe04396cad 100644 --- a/app/presenters/activitypub/activity_presenter.rb +++ b/app/presenters/activitypub/activity_presenter.rb @@ -4,7 +4,7 @@ class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model attributes :id, :type, :actor, :published, :to, :cc, :virtual_object class << self - def from_status(status, allow_inlining: true, for_misskey: false) + def from_status(status, use_bearcap: true, allow_inlining: true, for_misskey: false) new.tap do |presenter| presenter.id = ActivityPub::TagManager.instance.activity_uri_for(status) presenter.type = status.reblog? ? 'Announce' : 'Create' @@ -20,6 +20,8 @@ class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model else ActivityPub::TagManager.instance.uri_for(status.proper) end + elsif status.limited_visibility? && use_bearcap + "bear:?#{{ u: ActivityPub::TagManager.instance.uri_for(status.proper), t: status.capability_tokens.first.token }.to_query}" else status.proper end diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 2dd360ab35..3ed35d9c32 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -118,6 +118,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer :kmyblue_reaction_deck, :kmyblue_visibility_login, :status_reference, + :visibility_mutual, ] capabilities << :profile_search unless Chewy.enabled? diff --git a/app/serializers/rest/v1/instance_serializer.rb b/app/serializers/rest/v1/instance_serializer.rb index c0c30b5a03..e6d7f35e10 100644 --- a/app/serializers/rest/v1/instance_serializer.rb +++ b/app/serializers/rest/v1/instance_serializer.rb @@ -127,6 +127,7 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer :kmyblue_reaction_deck, :kmyblue_visibility_login, :status_reference, + :visibility_mutual, ] capabilities << :profile_search unless Chewy.enabled? diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb index 3ef0366c3c..5de64a8a39 100644 --- a/app/services/backup_service.rb +++ b/app/services/backup_service.rb @@ -32,7 +32,7 @@ class BackupService < BaseService add_comma = true file.write(statuses.map do |status| - item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status), ActivityPub::ActivitySerializer) + item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status, use_bearcap: false), ActivityPub::ActivitySerializer) item.delete('@context') unless item[:type] == 'Announce' || item[:object][:attachment].blank? diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index eb5038b332..030dc8441e 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -74,6 +74,7 @@ class PostStatusService < BaseService end) || @options[:spoiler_text].present? @text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present? @visibility = @options[:visibility] || @account.user&.setting_default_privacy + @visibility = :limited if @options[:visibility] == 'mutual' @visibility = :unlisted if (@visibility&.to_sym == :public || @visibility&.to_sym == :public_unlisted || @visibility&.to_sym == :login) && @account.silenced? @visibility = :public_unlisted if @visibility&.to_sym == :public && !@options[:force_visibility] && !@options[:application]&.superapp && @account.user&.setting_public_post_to_unlisted @searchability = searchability @@ -113,7 +114,7 @@ class PostStatusService < BaseService def process_status! @status = @account.statuses.new(status_attributes) - process_mentions_service.call(@status, save_records: false) + process_mentions_service.call(@status, limited_type: @status.limited_visibility? ? 'mutual' : '', save_records: false) safeguard_mentions!(@status) UpdateStatusExpirationService.new.call(@status) @@ -122,6 +123,7 @@ class PostStatusService < BaseService # the media attachments when the status is created ApplicationRecord.transaction do @status.save! + @status.capability_tokens.create! if @status.limited_visibility? end end diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index f3fbb80210..16cbad1f75 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -7,8 +7,9 @@ class ProcessMentionsService < BaseService # and create local mention pointers # @param [Status] status # @param [Boolean] save_records Whether to save records in database - def call(status, save_records: true) + def call(status, limited_type: '', save_records: true) @status = status + @limited_type = limited_type @save_records = save_records return unless @status.local? @@ -62,6 +63,8 @@ class ProcessMentionsService < BaseService "@#{mentioned_account.acct}" end + process_mutual! if @limited_type == 'mutual' + @status.save! if @save_records end @@ -92,4 +95,12 @@ class ProcessMentionsService < BaseService def mention_undeliverable?(mentioned_account) mentioned_account.nil? || (!mentioned_account.local? && !mentioned_account.activitypub?) end + + def process_mutual! + mentioned_account_ids = @current_mentions.map(&:account_id) + + @status.account.mutuals.find_each do |target_account| + @current_mentions << @status.mentions.new(silent: true, account: target_account) unless mentioned_account_ids.include?(target_account.id) + end + end end diff --git a/db/migrate/20230812083752_create_status_capability_token.rb b/db/migrate/20230812083752_create_status_capability_token.rb new file mode 100644 index 0000000000..f4deaa9c9e --- /dev/null +++ b/db/migrate/20230812083752_create_status_capability_token.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateStatusCapabilityToken < ActiveRecord::Migration[7.0] + def change + create_table :status_capability_tokens do |t| + t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade } + t.string :token + t.datetime :created_at, null: false + t.datetime :updated_at, null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 75d7264df6..593f282fab 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[7.0].define(version: 2023_08_04_222017) do +ActiveRecord::Schema[7.0].define(version: 2023_08_12_083752) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -1029,6 +1029,14 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_04_222017) do t.index ["var"], name: "index_site_uploads_on_var", unique: true end + create_table "status_capability_tokens", force: :cascade do |t| + t.bigint "status_id", null: false + t.string "token" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["status_id"], name: "index_status_capability_tokens_on_status_id" + end + create_table "status_edits", force: :cascade do |t| t.bigint "status_id", null: false t.bigint "account_id" @@ -1395,6 +1403,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_04_222017) do add_foreign_key "scheduled_statuses", "accounts", on_delete: :cascade add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade + add_foreign_key "status_capability_tokens", "statuses", on_delete: :cascade add_foreign_key "status_edits", "accounts", on_delete: :nullify add_foreign_key "status_edits", "statuses", on_delete: :cascade add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade