Skip to content

Commit 66b8396

Browse files
Gargronnoellabo
authored andcommitted
Add conversation-based forwarding for limited visibility statuses through bearcaps
1 parent 561abc6 commit 66b8396

26 files changed

+430
-78
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
class ActivityPub::ContextsController < ActivityPub::BaseController
4+
before_action :set_conversation
5+
6+
def show
7+
expires_in 3.minutes, public: public_fetch_mode?
8+
render_with_cache json: @conversation, serializer: ActivityPub::ContextSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
9+
end
10+
11+
private
12+
13+
def set_conversation
14+
@conversation = Conversation.local.find(params[:id])
15+
end
16+
end

app/controllers/concerns/cache_concern.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def render_with_cache(**options)
2525
end
2626

2727
def set_cache_headers
28-
response.headers['Vary'] = public_fetch_mode? ? 'Accept' : 'Accept, Signature'
28+
response.headers['Vary'] = public_fetch_mode? ? 'Accept, Authorization' : 'Accept, Signature, Authorization'
2929
end
3030

3131
def cache_collection(raw, klass)

app/controllers/statuses_controller.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,12 @@ def set_link_headers
6666

6767
def set_status
6868
@status = @account.statuses.find(params[:id])
69-
authorize @status, :show?
69+
70+
if request.authorization.present? && request.authorization.match(/^Bearer /i)
71+
raise Mastodon::NotPermittedError unless @status.capability_tokens.find_by(token: request.authorization.gsub(/^Bearer /i, ''))
72+
else
73+
authorize @status, :show?
74+
end
7075
rescue Mastodon::NotPermittedError
7176
not_found
7277
end

app/helpers/jsonld_helper.rb

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,12 @@ def unsupported_uri_scheme?(uri)
4949
!uri.start_with?('http://', 'https://')
5050
end
5151

52-
def invalid_origin?(url)
53-
return true if unsupported_uri_scheme?(url)
54-
55-
needle = Addressable::URI.parse(url).host
56-
haystack = Addressable::URI.parse(@account.uri).host
52+
def same_origin?(url_a, url_b)
53+
Addressable::URI.parse(url_a).host.casecmp(Addressable::URI.parse(url_b).host).zero?
54+
end
5755

58-
!haystack.casecmp(needle).zero?
56+
def invalid_origin?(url)
57+
unsupported_uri_scheme?(url) || !same_origin?(url, @account.uri)
5958
end
6059

6160
def canonicalize(json)

app/lib/activitypub/activity/create.rb

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ def process_status
9090
fetch_replies(@status)
9191
check_for_spam
9292
distribute(@status)
93+
forward_for_conversation
9394
forward_for_reply
9495
end
9596

@@ -114,16 +115,18 @@ def process_status_params
114115
sensitive: @object['sensitive'] || false,
115116
visibility: visibility_from_audience,
116117
thread: replied_to_status,
117-
conversation: conversation_from_uri(@object['conversation']),
118+
conversation: conversation_from_context,
118119
media_attachment_ids: process_attachments.take(4).map(&:id),
119120
poll: process_poll,
120121
}
121122
end
122123
end
123124

124125
def process_audience
126+
conversation_uri = value_or_id(@object['context'])
127+
125128
(audience_to + audience_cc).uniq.each do |audience|
126-
next if audience == ActivityPub::TagManager::COLLECTIONS[:public]
129+
next if audience == ActivityPub::TagManager::COLLECTIONS[:public] || audience == conversation_uri
127130

128131
# Unlike with tags, there is no point in resolving accounts we don't already
129132
# know here, because silent mentions would only be used for local access
@@ -340,15 +343,45 @@ def fetch_replies(status)
340343
ActivityPub::FetchRepliesWorker.perform_async(status.id, uri) unless uri.nil?
341344
end
342345

343-
def conversation_from_uri(uri)
344-
return nil if uri.nil?
345-
return Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if OStatus::TagManager.instance.local_id?(uri)
346+
def conversation_from_context
347+
atom_uri = @object['conversation']
346348

347-
begin
348-
Conversation.find_or_create_by!(uri: uri)
349-
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
350-
retry
349+
conversation = begin
350+
if atom_uri.present? && OStatus::TagManager.instance.local_id?(atom_uri)
351+
Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(atom_uri, 'Conversation'))
352+
elsif atom_uri.present? && @object['context'].present?
353+
Conversation.find_by(uri: atom_uri)
354+
elsif atom_uri.present?
355+
begin
356+
Conversation.find_or_create_by!(uri: atom_uri)
357+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
358+
retry
359+
end
360+
end
351361
end
362+
363+
return conversation if @object['context'].nil?
364+
365+
uri = value_or_id(@object['context'])
366+
conversation ||= ActivityPub::TagManager.instance.uri_to_resource(uri, Conversation)
367+
368+
return conversation if (conversation.present? && conversation.uri == uri) || !uri.start_with?('https://')
369+
370+
conversation_json = begin
371+
if @object['context'].is_a?(Hash) && !invalid_origin?(uri)
372+
@object['context']
373+
else
374+
fetch_resource(uri, true)
375+
end
376+
end
377+
378+
return conversation if conversation_json.blank?
379+
380+
conversation ||= Conversation.new
381+
conversation.uri = uri
382+
conversation.inbox_url = conversation_json['inbox']
383+
conversation.save! if conversation.changed?
384+
conversation
352385
end
353386

354387
def visibility_from_audience
@@ -492,6 +525,12 @@ def check_for_spam
492525
SpamCheck.perform(@status)
493526
end
494527

528+
def forward_for_conversation
529+
return unless audience_to.include?(value_or_id(@object['context'])) && @json['signature'].present? && @status.conversation.local?
530+
531+
ActivityPub::ForwardDistributionWorker.perform_async(@status.conversation_id, Oj.dump(@json))
532+
end
533+
495534
def forward_for_reply
496535
return unless @status.distributable? && @json['signature'].present? && reply_to_local?
497536

app/lib/activitypub/tag_manager.rb

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ def url_for(target)
2121
when :person
2222
target.instance_actor? ? about_more_url(instance_actor: true) : short_account_url(target)
2323
when :note, :comment, :activity
24-
return activity_account_status_url(target.account, target) if target.reblog?
25-
short_account_status_url(target.account, target)
24+
if target.reblog?
25+
activity_account_status_url(target.account, target)
26+
else
27+
short_account_status_url(target.account, target)
28+
end
2629
end
2730
end
2831

@@ -33,10 +36,15 @@ def uri_for(target)
3336
when :person
3437
target.instance_actor? ? instance_actor_url : account_url(target)
3538
when :note, :comment, :activity
36-
return activity_account_status_url(target.account, target) if target.reblog?
37-
account_status_url(target.account, target)
39+
if target.reblog?
40+
activity_account_status_url(target.account, target)
41+
else
42+
account_status_url(target.account, target)
43+
end
3844
when :emoji
3945
emoji_url(target)
46+
when :conversation
47+
context_url(target)
4048
end
4149
end
4250

@@ -66,7 +74,9 @@ def to(status)
6674
[COLLECTIONS[:public]]
6775
when 'unlisted', 'private'
6876
[account_followers_url(status.account)]
69-
when 'direct', 'limited'
77+
when 'limited'
78+
status.conversation_id.present? ? [uri_for(status.conversation)] : []
79+
when 'direct'
7080
if status.account.silenced?
7181
# Only notify followers if the account is locally silenced
7282
account_ids = status.active_mentions.pluck(:account_id)
@@ -104,7 +114,7 @@ def cc(status)
104114
cc << COLLECTIONS[:public]
105115
end
106116

107-
unless status.direct_visibility? || status.limited_visibility?
117+
unless status.direct_visibility?
108118
if status.account.silenced?
109119
# Only notify followers if the account is locally silenced
110120
account_ids = status.active_mentions.pluck(:account_id)

app/models/conversation.rb

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,44 @@
33
#
44
# Table name: conversations
55
#
6-
# id :bigint(8) not null, primary key
7-
# uri :string
8-
# created_at :datetime not null
9-
# updated_at :datetime not null
6+
# id :bigint(8) not null, primary key
7+
# uri :string
8+
# created_at :datetime not null
9+
# updated_at :datetime not null
10+
# parent_status_id :bigint(8)
11+
# parent_account_id :bigint(8)
12+
# inbox_url :string
1013
#
1114

1215
class Conversation < ApplicationRecord
1316
validates :uri, uniqueness: true, if: :uri?
1417

15-
has_many :statuses
18+
belongs_to :parent_status, class_name: 'Status', optional: true, inverse_of: :conversation
19+
belongs_to :parent_account, class_name: 'Account', optional: true
20+
21+
has_many :statuses, inverse_of: :conversation
22+
23+
scope :local, -> { where(uri: nil) }
24+
25+
before_validation :set_parent_account, on: :create
26+
27+
after_create :set_conversation_on_parent_status
1628

1729
def local?
1830
uri.nil?
1931
end
32+
33+
def object_type
34+
:conversation
35+
end
36+
37+
private
38+
39+
def set_parent_account
40+
self.parent_account = parent_status.account if parent_status.present?
41+
end
42+
43+
def set_conversation_on_parent_status
44+
parent_status.update_column(:conversation_id, id) if parent_status.present?
45+
end
2046
end

app/models/status.rb

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,11 @@ class Status < ApplicationRecord
5050

5151
belongs_to :account, inverse_of: :statuses
5252
belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account', optional: true
53-
belongs_to :conversation, optional: true
53+
belongs_to :conversation, optional: true, inverse_of: :statuses
5454
belongs_to :preloadable_poll, class_name: 'Poll', foreign_key: 'poll_id', optional: true
5555

56+
has_one :owned_conversation, class_name: 'Conversation', foreign_key: 'parent_status_id', inverse_of: :parent_status
57+
5658
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true
5759
belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true
5860

@@ -63,6 +65,7 @@ class Status < ApplicationRecord
6365
has_many :mentions, dependent: :destroy, inverse_of: :status
6466
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
6567
has_many :media_attachments, dependent: :nullify
68+
has_many :capability_tokens, class_name: 'StatusCapabilityToken', inverse_of: :status, dependent: :destroy
6669

6770
has_and_belongs_to_many :tags
6871
has_and_belongs_to_many :preview_cards
@@ -205,7 +208,9 @@ def distributable?
205208
public_visibility? || unlisted_visibility?
206209
end
207210

208-
alias sign? distributable?
211+
def sign?
212+
distributable? || limited_visibility?
213+
end
209214

210215
def with_media?
211216
media_attachments.any?
@@ -264,11 +269,11 @@ def decrement_count!(key)
264269

265270
around_create Mastodon::Snowflake::Callbacks
266271

267-
before_validation :prepare_contents, if: :local?
268-
before_validation :set_reblog
269-
before_validation :set_visibility
270-
before_validation :set_conversation
271-
before_validation :set_local
272+
before_validation :prepare_contents, on: :create, if: :local?
273+
before_validation :set_reblog, on: :create
274+
before_validation :set_visibility, on: :create
275+
before_validation :set_conversation, on: :create
276+
before_validation :set_local, on: :create
272277

273278
after_create :set_poll_id
274279

@@ -464,7 +469,7 @@ def set_conversation
464469
self.in_reply_to_account_id = carried_over_reply_to_account_id
465470
self.conversation_id = thread.conversation_id if conversation_id.nil?
466471
elsif conversation_id.nil?
467-
self.conversation = Conversation.new
472+
build_owned_conversation
468473
end
469474
end
470475

app/models/status_capability_token.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# frozen_string_literal: true
2+
3+
# == Schema Information
4+
#
5+
# Table name: status_capability_tokens
6+
#
7+
# id :bigint(8) not null, primary key
8+
# status_id :bigint(8)
9+
# token :string
10+
# created_at :datetime not null
11+
# updated_at :datetime not null
12+
#
13+
class StatusCapabilityToken < ApplicationRecord
14+
belongs_to :status
15+
16+
validates :token, presence: true
17+
18+
before_validation :generate_token, on: :create
19+
20+
private
21+
22+
def generate_token
23+
self.token = Doorkeeper::OAuth::Helpers::UniqueToken.generate
24+
end
25+
end

app/presenters/activitypub/activity_presenter.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ def from_status(status)
2020
else
2121
ActivityPub::TagManager.instance.uri_for(status.proper)
2222
end
23+
elsif status.limited_visibility?
24+
"bear:?#{{ u: ActivityPub::TagManager.instance.uri_for(status.proper), t: status.capability_tokens.first.token }.to_query}"
2325
else
2426
status.proper
2527
end
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# frozen_string_literal: true
2+
3+
class ActivityPub::ContextSerializer < ActivityPub::Serializer
4+
include RoutingHelper
5+
6+
attributes :id, :type, :inbox
7+
8+
def id
9+
ActivityPub::TagManager.instance.uri_for(object)
10+
end
11+
12+
def type
13+
'Group'
14+
end
15+
16+
def inbox
17+
account_inbox_url(object.parent_account)
18+
end
19+
end

app/serializers/activitypub/note_serializer.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
77
:in_reply_to, :published, :url,
88
:attributed_to, :to, :cc, :sensitive,
99
:atom_uri, :in_reply_to_atom_uri,
10-
:conversation
10+
:conversation, :context
1111

1212
attribute :content
1313
attribute :content_map, if: :language?
@@ -121,6 +121,12 @@ def conversation
121121
end
122122
end
123123

124+
def context
125+
return if object.conversation.nil?
126+
127+
ActivityPub::TagManager.instance.uri_for(object.conversation)
128+
end
129+
124130
def local?
125131
object.account.local?
126132
end

0 commit comments

Comments
 (0)