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/helpers/context_helper.rb b/app/helpers/context_helper.rb index 51338688f4..ffa8e479e6 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -24,6 +24,7 @@ module ContextHelper emoji_reactions: { 'fedibird' => 'http://fedibird.com/ns#', 'emojiReactions' => { '@id' => 'fedibird:emojiReactions', '@type' => '@id' } }, searchable_by: { 'fedibird' => 'http://fedibird.com/ns#', 'searchableBy' => { '@id' => 'fedibird:searchableBy', '@type' => '@id' } }, subscribable_by: { 'kmyblue' => 'http://kmy.blue/ns#', 'subscribableBy' => { '@id' => 'kmyblue:subscribableBy', '@type' => '@id' } }, + limited_scope: { 'kmyblue' => 'http://kmy.blue/ns#', 'limitedScope' => { '@id' => 'kmyblue:limitedScope', '@type' => '@id' } }, other_setting: { 'fedibird' => 'http://fedibird.com/ns#', 'otherSetting' => 'fedibird:otherSetting' }, references: { 'fedibird' => 'http://fedibird.com/ns#', 'references' => { '@id' => 'fedibird:references', '@type' => '@id' } }, olm: { diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index 6037839a24..01102645de 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -77,6 +77,8 @@ module StatusesHelper fa_icon 'key fw' when 'private' fa_icon 'lock fw' + when 'limited' + fa_icon 'get-pocket fw' when 'direct' fa_icon 'at fw' end diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index c8fb36d199..3aa021ba14 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -69,6 +69,8 @@ const messages = defineMessages({ public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' }, login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, + limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, + mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, }); @@ -398,10 +400,12 @@ class Status extends ImmutablePureComponent { 'public_unlisted': { icon: 'cloud', text: intl.formatMessage(messages.public_unlisted_short) }, 'login': { icon: 'key', text: intl.formatMessage(messages.login_short) }, 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) }, + 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, + 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, }; - let visibilityIcon = visibilityIconInfo[status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')]; + let visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')]; if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) { const minHandlers = this.props.muted ? {} : { @@ -562,7 +566,7 @@ class Status extends ImmutablePureComponent { statusAvatar = ; } - visibilityIcon = visibilityIconInfo[status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')]; + visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')]; let emojiReactionsBar = null; if (!this.props.withoutEmojiReactions && status.get('emoji_reactions')) { diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx index 2fe7d6e1f6..89545f53ce 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; @@ -232,6 +234,7 @@ class PrivacyDropdown extends PureComponent { { 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: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, + { icon: 'exchange', value: 'mutual', text: formatMessage(messages.mutual_short), meta: formatMessage(messages.mutual_long) }, { icon: 'at', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, ]; this.selectableOptions = [...this.options]; diff --git a/app/javascript/mastodon/features/report/components/status_check_box.jsx b/app/javascript/mastodon/features/report/components/status_check_box.jsx index e16aa10865..6ce956e9cc 100644 --- a/app/javascript/mastodon/features/report/components/status_check_box.jsx +++ b/app/javascript/mastodon/features/report/components/status_check_box.jsx @@ -20,6 +20,8 @@ const messages = defineMessages({ public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' }, login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, + limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, + mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, }); @@ -51,10 +53,12 @@ class StatusCheckBox extends PureComponent { 'public_unlisted': { icon: 'cloud', text: intl.formatMessage(messages.public_unlisted_short) }, 'login': { icon: 'key', text: intl.formatMessage(messages.login_short) }, 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) }, + 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, + 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, }; - const visibilityIcon = visibilityIconInfo[status.get('visibility_ex')]; + const visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')]; const labelComponent = (
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx index f4fb303c17..83a9c23069 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.jsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx @@ -30,6 +30,8 @@ const messages = defineMessages({ public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' }, login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, + limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, + mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, searchability_public_short: { id: 'searchability.public.short', defaultMessage: 'Public' }, searchability_private_short: { id: 'searchability.unlisted.short', defaultMessage: 'Followers' }, @@ -249,10 +251,12 @@ class DetailedStatus extends ImmutablePureComponent { 'public_unlisted': { icon: 'cloud', text: intl.formatMessage(messages.public_unlisted_short) }, 'login': { icon: 'key', text: intl.formatMessage(messages.login_short) }, 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) }, + 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, + 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, }; - const visibilityIcon = visibilityIconInfo[status.get('visibility_ex')]; + const visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')]; const visibilityLink = <> · ; const searchabilityIconInfo = { diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.jsx b/app/javascript/mastodon/features/ui/components/boost_modal.jsx index d913340a54..49bb835163 100644 --- a/app/javascript/mastodon/features/ui/components/boost_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/boost_modal.jsx @@ -27,6 +27,8 @@ const messages = defineMessages({ public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' }, login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' }, + limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' }, + mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' }, }); @@ -94,10 +96,12 @@ class BoostModal extends ImmutablePureComponent { 'public_unlisted': { icon: 'cloud', text: intl.formatMessage(messages.public_unlisted_short) }, 'login': { icon: 'key', text: intl.formatMessage(messages.login_short) }, 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) }, + 'limited': { icon: 'get-pocket', text: intl.formatMessage(messages.limited_short) }, + 'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) }, 'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) }, }; - const visibilityIcon = visibilityIconInfo[status.get('visibility_ex')]; + const visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')]; return (
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 0d05c03161..a0d1e56536 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -520,8 +520,11 @@ "privacy.change": "Change post privacy", "privacy.direct.long": "Visible for mentioned users only", "privacy.direct.short": "Mentioned people only", + "privacy.limited.short": "Limited", "privacy.login.long": "Visible for login users only", "privacy.login.short": "Login only", + "privacy.mutual.long": "Mutual followers only", + "privacy.mutual.short": "Mutual", "privacy.private.long": "Visible for followers only", "privacy.private.short": "Followers only", "privacy.public.long": "Visible for all", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 7fb3ce4ca6..09bfe4c9f5 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -529,8 +529,11 @@ "privacy.change": "公開範囲を変更", "privacy.direct.long": "指定された相手のみ閲覧可", "privacy.direct.short": "指定された相手のみ", + "privacy.limited.short": "限定投稿", "privacy.login.long": "ログインユーザーのみ閲覧可、公開", "privacy.login.short": "ログインユーザーのみ", + "privacy.mutual.long": "相互フォローさんのみ閲覧可、限定投稿", + "privacy.mutual.short": "相互のみ", "privacy.private.long": "フォロワーのみ閲覧可", "privacy.private.short": "フォロワーのみ", "privacy.public.long": "誰でも閲覧可、ホーム+ローカル+連合TL", diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 45ce7252f4..55123f68a3 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -119,7 +119,10 @@ class ActivityPub::Activity dereferencer = ActivityPub::Dereferencer.new(@object, permitted_origin: @account.uri, signature_actor: signed_fetch_actor) - @object = dereferencer.object unless dereferencer.object.nil? + return if dereferencer.object.nil? + + @object = dereferencer.object + @json = @object end def signed_fetch_actor diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 4f5207addb..833597d240 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -133,6 +133,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity reply: @status_parser.reply, sensitive: @account.sensitized? || @status_parser.sensitive || false, visibility: @status_parser.visibility, + limited_scope: @status_parser.limited_scope, searchability: searchability, thread: replied_to_status, conversation: conversation_from_uri(@object['conversation']), diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb index 0e38861838..34d1497c1e 100644 --- a/app/lib/activitypub/parser/status_parser.rb +++ b/app/lib/activitypub/parser/status_parser.rb @@ -86,6 +86,14 @@ class ActivityPub::Parser::StatusParser end end + def limited_scope + if @object['limitedScope'] == 'Mutual' + :mutual + else + :none + end + end + def language if content_language_map? @object['contentMap'].keys.first diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index d971b44237..2977ddcec0 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -100,7 +100,7 @@ class ActivityPub::TagManager [account_followers_url(status.account)] when 'login' [account_followers_url(status.account), 'as:LoginOnly', 'LoginUser'] - when 'direct', 'limited' + when 'direct' if status.account.silenced? # Only notify followers if the account is locally silenced account_ids = status.active_mentions.pluck(:account_id) @@ -118,6 +118,11 @@ class ActivityPub::TagManager result << followers_uri_for(mention.account) if mention.account.group? end.compact end + when 'limited' + status.mentions.each_with_object([]) do |mention, result| + result << uri_for(mention.account) + result << followers_uri_for(mention.account) if mention.account.group? + end.compact end end @@ -216,6 +221,10 @@ class ActivityPub::TagManager nil end + def limited_scope(status) + status.mutual_limited? ? 'Mutual' : '' + end + def subscribable_by(account) account.dissubscribable ? [] : [COLLECTIONS[:public]] end diff --git a/app/lib/status_reach_finder.rb b/app/lib/status_reach_finder.rb index 6cce4e04fb..7bdc300e1c 100644 --- a/app/lib/status_reach_finder.rb +++ b/app/lib/status_reach_finder.rb @@ -29,6 +29,8 @@ class StatusReachFinder if @status.reblog? [] + elsif @status.limited_visibility? + Account.where(id: mentioned_account_ids).where.not(domain: banned_domains).inboxes else Account.where(id: reached_account_ids).where.not(domain: banned_domains).inboxes end @@ -37,6 +39,8 @@ class StatusReachFinder def reached_account_inboxes_for_misskey if @status.reblog? [] + elsif @status.limited_visibility? + Account.where(id: mentioned_account_ids).where(domain: banned_domains_for_misskey).inboxes else Account.where(id: reached_account_ids).where(domain: banned_domains_for_misskey).inboxes 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/concerns/has_user_settings.rb b/app/models/concerns/has_user_settings.rb index 475ab2c9d2..0fde83954b 100644 --- a/app/models/concerns/has_user_settings.rb +++ b/app/models/concerns/has_user_settings.rb @@ -55,6 +55,10 @@ module HasUserSettings settings['send_without_domain_blocks'] end + def setting_unsafe_limited_distribution + settings['unsafe_limited_distribution'] + end + def setting_stop_emoji_reaction_streaming settings['stop_emoji_reaction_streaming'] end diff --git a/app/models/status.rb b/app/models/status.rb index 348a1e697b..fd4c792f79 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -29,6 +29,7 @@ # ordered_media_attachment_ids :bigint(8) is an Array # searchability :integer # markdown :boolean default(FALSE) +# limited_scope :integer # require 'ostruct' @@ -54,6 +55,7 @@ class Status < ApplicationRecord enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, public_unlisted: 10, login: 11 }, _suffix: :visibility enum searchability: { public: 0, private: 1, direct: 2, limited: 3, unsupported: 4, public_unlisted: 10 }, _suffix: :searchability + enum limited_scope: { none: 0, mutual: 1 }, _suffix: :limited belongs_to :application, class_name: 'Doorkeeper::Application', optional: true @@ -79,6 +81,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/models/user_settings.rb b/app/models/user_settings.rb index 7162d847b1..da518d7904 100644 --- a/app/models/user_settings.rb +++ b/app/models/user_settings.rb @@ -31,6 +31,7 @@ class UserSettings setting :reaction_deck, default: nil setting :stop_emoji_reaction_streaming, default: false setting :emoji_reaction_streaming_notify_impl2, default: false + setting :unsafe_limited_distribution, default: false namespace :web do setting :advanced_layout, default: false diff --git a/app/presenters/activitypub/activity_presenter.rb b/app/presenters/activitypub/activity_presenter.rb index 9524e64179..5066a57f8c 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 && !status.account.user&.setting_unsafe_limited_distribution + "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/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index a1bc6742c0..ec16d3b567 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -3,13 +3,13 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer include FormattingHelper - context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :searchable_by, :references + context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :searchable_by, :references, :limited_scope attributes :id, :type, :summary, :in_reply_to, :published, :url, :attributed_to, :to, :cc, :sensitive, :atom_uri, :in_reply_to_atom_uri, - :conversation, :searchable_by + :conversation, :searchable_by, :limited_scope attribute :references, if: :not_private_post? @@ -148,12 +148,16 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer ActivityPub::TagManager.instance.searchable_by(object) end + def limited_scope + ActivityPub::TagManager.instance.limited_scope(object) + end + def local? object.account.local? end def not_private_post? - !object.private_visibility? + !object.private_visibility? && !object.direct_visibility? && !object.limited_visibility? end def poll_options 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/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 2a07baab84..f833d044f2 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -4,7 +4,7 @@ class REST::StatusSerializer < ActiveModel::Serializer include FormattingHelper attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, - :sensitive, :spoiler_text, :visibility, :visibility_ex, :language, + :sensitive, :spoiler_text, :visibility, :visibility_ex, :limited_scope, :language, :uri, :url, :replies_count, :reblogs_count, :searchability, :markdown, :status_reference_ids, :status_references_count, :status_referred_by_count, :favourites_count, :emoji_reactions, :emoji_reactions_count, :reactions, :edited_at @@ -66,11 +66,11 @@ class REST::StatusSerializer < ActiveModel::Serializer end def visibility_ex - if object.limited_visibility? - 'private' - else - object.visibility - end + object.visibility + end + + def limited_scope + !object.none_limited? && object.limited_visibility? ? object.limited_scope : nil end def searchability diff --git a/app/serializers/rest/v1/instance_serializer.rb b/app/serializers/rest/v1/instance_serializer.rb index c0c30b5a03..1c6c976216 100644 --- a/app/serializers/rest/v1/instance_serializer.rb +++ b/app/serializers/rest/v1/instance_serializer.rb @@ -127,6 +127,8 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer :kmyblue_reaction_deck, :kmyblue_visibility_login, :status_reference, + :visibility_mutual, + :kmyblue_limited_scope, ] capabilities << :profile_search unless Chewy.enabled? diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 563fa5aa07..4149fd9c4e 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -209,7 +209,7 @@ class ActivityPub::ProcessAccountService < BaseService end def fetch_instance_info - FetchInstanceInfoWorker.perform_async(@account.domain) unless InstanceInfo.exists?(domain: @account.domain) + ActivityPub::FetchInstanceInfoWorker.perform_async(@account.domain) unless InstanceInfo.exists?(domain: @account.domain) end def actor_type 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..0d184a76d1 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -74,6 +74,8 @@ 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 = :direct if @in_reply_to&.limited_visibility? + @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 +115,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 +124,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 @@ -245,6 +248,7 @@ class PostStatusService < BaseService spoiler_text: @options[:spoiler_text] || '', markdown: @markdown, visibility: @visibility, + limited_scope: @visibility == :limited ? :mutual : :none, searchability: @searchability, language: valid_locale_cascade(@options[:language], @account.user&.preferred_posting_language, I18n.default_locale), application: @options[:application], 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/app/workers/fetch_instance_info_worker.rb b/app/workers/activitypub/fetch_instance_info_worker.rb similarity index 88% rename from app/workers/fetch_instance_info_worker.rb rename to app/workers/activitypub/fetch_instance_info_worker.rb index 2700f88e13..e982d4c086 100644 --- a/app/workers/fetch_instance_info_worker.rb +++ b/app/workers/activitypub/fetch_instance_info_worker.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class FetchInstanceInfoWorker +class ActivityPub::FetchInstanceInfoWorker include Sidekiq::Worker include JsonLdHelper include Redisable @@ -64,9 +64,9 @@ class FetchInstanceInfoWorker body_to_json(response.body_with_limit) elsif response.code == 410 - raise FetchInstanceInfoWorker::GoneError, "#{domain} is gone from the server" + raise ActivityPub::FetchInstanceInfoWorker::GoneError, "#{@instance.domain} is gone from the server" else - raise FetchInstanceInfoWorker::RequestError, "Request for #{domain} returned HTTP #{response.code}" + raise ActivityPub::FetchInstanceInfoWorker::RequestError, "Request for #{@instance.domain} returned HTTP #{response.code}" end end end diff --git a/app/workers/scheduler/update_instance_info_scheduler.rb b/app/workers/scheduler/update_instance_info_scheduler.rb index 0587146b32..f5b2852859 100644 --- a/app/workers/scheduler/update_instance_info_scheduler.rb +++ b/app/workers/scheduler/update_instance_info_scheduler.rb @@ -7,7 +7,7 @@ class Scheduler::UpdateInstanceInfoScheduler def perform Instance.select(:domain).reorder(nil).find_in_batches do |instances| - FetchInstanceInfoWorker.push_bulk(instances) do |instance| + ActivityPub::FetchInstanceInfoWorker.push_bulk(instances) do |instance| [instance.domain] end end diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 17517fc6a2..7cd69e196d 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -247,6 +247,7 @@ en: setting_theme: Site theme setting_trends: Show today's trends setting_unfollow_modal: Show confirmation dialog before unfollowing someone + setting_unsafe_limited_distribution: Send limit posts with unsafe way to other servers setting_use_blurhash: Show colorful gradients for hidden media setting_use_pending_items: Slow mode severity: Severity diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index 5650d7f454..d98662e4ac 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -73,6 +73,7 @@ ja: setting_reject_unlisted_subscription: Misskeyやそのフォーク(Calckeyなど)は、フォローしていないアカウントの「未収載」投稿を **購読・検索** することができます。これはkmyblueの挙動と異なります。そのようなサーバーのうち管理人が指定したものに、指定した公開範囲の投稿を「フォロワーのみ」として配送します。ただし構造上、完璧な対応は困難でたまに未収載として配信されること、ご理解ください setting_show_application: 投稿するのに使用したアプリが投稿の詳細ビューに表示されるようになります setting_stop_emoji_reaction_streaming: 通信容量の節約に役立ちます + setting_unsafe_limited_distribution: Mastodon 3.5、4.0、4.1のサーバーにも限定投稿(相互のみ)が届くようになりますが、安全でない方法で送信します setting_use_blurhash: ぼかしはメディアの色を元に生成されますが、細部は見えにくくなっています setting_use_pending_items: 新着があってもタイムラインを自動的にスクロールしないようにします username: アルファベット大文字と小文字、数字、アンダーバー「_」が使えます @@ -255,6 +256,7 @@ ja: setting_theme: サイトテーマ setting_trends: 本日のトレンドタグを表示する setting_unfollow_modal: フォローを解除する前に確認ダイアログを表示する + setting_unsafe_limited_distribution: 安全でない方法で限定投稿を他サーバーに配信する (非推奨) setting_use_blurhash: 非表示のメディアを色付きのぼかしで表示する setting_use_pending_items: 手動更新モード severity: 重大性 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/migrate/20230812130612_add_limited_scope_to_statuses.rb b/db/migrate/20230812130612_add_limited_scope_to_statuses.rb new file mode 100644 index 0000000000..b11e0862be --- /dev/null +++ b/db/migrate/20230812130612_add_limited_scope_to_statuses.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddLimitedScopeToStatuses < ActiveRecord::Migration[7.0] + def change + add_column :statuses, :limited_scope, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 75d7264df6..40cb404813 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_130612) 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" @@ -1114,6 +1122,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_04_222017) do t.bigint "ordered_media_attachment_ids", array: true t.integer "searchability" t.boolean "markdown", default: false + t.integer "limited_scope" 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)" @@ -1395,6 +1404,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 diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 151da1c4e5..de07a490bb 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -290,6 +290,7 @@ RSpec.describe ActivityPub::Activity::Create do expect(status).to_not be_nil expect(status.visibility).to eq 'limited' + expect(status.limited_scope).to eq 'none' end it 'creates silent mention' do @@ -298,6 +299,50 @@ RSpec.describe ActivityPub::Activity::Create do end end + context 'when limited_scope' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: ActivityPub::TagManager.instance.uri_for(recipient), + limitedScope: 'Mutual', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'limited' + expect(status.limited_scope).to eq 'mutual' + end + end + + context 'when invalid limited_scope' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: ActivityPub::TagManager.instance.uri_for(recipient), + limitedScope: 'IdosdsazsF', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'limited' + expect(status.limited_scope).to eq 'none' + end + end + context 'when direct' do let(:recipient) { Fabricate(:account) } diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index 8626ab919e..62a67c393e 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -168,7 +168,17 @@ RSpec.describe PostStatusService, type: :service do status = subject.call(account, text: 'test status update') expect(ProcessMentionsService).to have_received(:new) - expect(mention_service).to have_received(:call).with(status, save_records: false) + expect(mention_service).to have_received(:call).with(status, limited_type: '', save_records: false) + end + + it 'mutual visibility' do + account = Fabricate(:account) + text = 'This is an English text.' + + status = subject.call(account, text: text, visibility: 'mutual') + + expect(status.visibility).to eq 'limited' + expect(status.limited_scope).to eq 'mutual' end it 'safeguards mentions' do diff --git a/spec/workers/activitypub/fetch_instance_info_worker_spec.rb b/spec/workers/activitypub/fetch_instance_info_worker_spec.rb new file mode 100644 index 0000000000..f75f8bc024 --- /dev/null +++ b/spec/workers/activitypub/fetch_instance_info_worker_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ActivityPub::FetchInstanceInfoWorker do + subject { described_class.new } + + let(:wellknown_nodeinfo) do + { + links: [ + { + rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', + href: 'https://example.com/nodeinfo/2.0', + }, + ], + } + end + + let(:nodeinfo) do + { + version: '2.0', + software: { + name: 'mastodon', + version: '4.2.0-beta1', + }, + protocols: ['activitypub'], + } + end + + let(:wellknown_nodeinfo_json) { Oj.dump(wellknown_nodeinfo) } + let(:nodeinfo_json) { Oj.dump(nodeinfo) } + + context 'when success' do + before do + stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(status: 200, body: wellknown_nodeinfo_json) + stub_request(:get, 'https://example.com/nodeinfo/2.0').to_return(status: 200, body: nodeinfo_json) + Fabricate(:account, domain: 'example.com') + Instance.refresh + end + + it 'performs a mastodon instance' do + subject.perform('example.com') + + info = InstanceInfo.find_by(domain: 'example.com') + expect(info).to_not be_nil + expect(info.software).to eq 'mastodon' + expect(info.version).to eq '4.2.0-beta1' + end + end + + context 'when update' do + let(:new_nodeinfo) do + { + version: '2.0', + software: { + name: 'mastodon', + version: '4.2.0-beta3', + }, + protocols: ['activitypub'], + } + end + let(:new_nodeinfo_json) { Oj.dump(new_nodeinfo) } + + before do + stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(status: 200, body: wellknown_nodeinfo_json) + Fabricate(:account, domain: 'example.com') + Instance.refresh + end + + it 'performs a mastodon instance' do + stub_request(:get, 'https://example.com/nodeinfo/2.0').to_return(status: 200, body: nodeinfo_json) + subject.perform('example.com') + stub_request(:get, 'https://example.com/nodeinfo/2.0').to_return(status: 200, body: new_nodeinfo_json) + subject.perform('example.com') + + info = InstanceInfo.find_by(domain: 'example.com') + expect(info).to_not be_nil + expect(info.software).to eq 'mastodon' + expect(info.version).to eq '4.2.0-beta3' + end + end + + context 'when failed' do + before do + stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(status: 404) + Fabricate(:account, domain: 'example.com') + Instance.refresh + end + + it 'performs a mastodon instance' do + expect { subject.perform('example.com') }.to raise_error(ActivityPub::FetchInstanceInfoWorker::RequestError, 'Request for example.com returned HTTP 404') + + info = InstanceInfo.find_by(domain: 'example.com') + expect(info).to be_nil + end + end +end diff --git a/spec/workers/scheduler/update_instance_info_scheduler_spec.rb b/spec/workers/scheduler/update_instance_info_scheduler_spec.rb index 17c2ee0ca6..f3a190417f 100644 --- a/spec/workers/scheduler/update_instance_info_scheduler_spec.rb +++ b/spec/workers/scheduler/update_instance_info_scheduler_spec.rb @@ -5,6 +5,12 @@ require 'rails_helper' describe Scheduler::UpdateInstanceInfoScheduler do let(:worker) { described_class.new } + before do + stub_request(:get, 'https://example.com/.well-known/nodeinfo').to_return(status: 200, body: '{}') + Fabricate(:account, domain: 'example.com') + Instance.refresh + end + describe 'perform' do it 'runs without error' do expect { worker.perform }.to_not raise_error