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