Merge branch 'origin/master' into sync/upstream
Conflicts: app/javascript/mastodon/components/status_list.js app/javascript/mastodon/features/notifications/index.js app/javascript/mastodon/features/ui/components/modal_root.js app/javascript/mastodon/features/ui/components/onboarding_modal.js app/javascript/mastodon/features/ui/index.js app/javascript/styles/about.scss app/javascript/styles/accounts.scss app/javascript/styles/components.scss app/presenters/instance_presenter.rb app/services/post_status_service.rb app/services/reblog_service.rb app/views/about/more.html.haml app/views/about/show.html.haml app/views/accounts/_header.html.haml config/webpack/loaders/babel.js spec/controllers/api/v1/accounts/credentials_controller_spec.rb
This commit is contained in:
106
app/lib/activitypub/activity.rb
Normal file
106
app/lib/activitypub/activity.rb
Normal file
@@ -0,0 +1,106 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity
|
||||
include JsonLdHelper
|
||||
|
||||
def initialize(json, account)
|
||||
@json = json
|
||||
@account = account
|
||||
@object = @json['object']
|
||||
end
|
||||
|
||||
def perform
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
class << self
|
||||
def factory(json, account)
|
||||
@json = json
|
||||
klass&.new(json, account)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def klass
|
||||
case @json['type']
|
||||
when 'Create'
|
||||
ActivityPub::Activity::Create
|
||||
when 'Announce'
|
||||
ActivityPub::Activity::Announce
|
||||
when 'Delete'
|
||||
ActivityPub::Activity::Delete
|
||||
when 'Follow'
|
||||
ActivityPub::Activity::Follow
|
||||
when 'Like'
|
||||
ActivityPub::Activity::Like
|
||||
when 'Block'
|
||||
ActivityPub::Activity::Block
|
||||
when 'Update'
|
||||
ActivityPub::Activity::Update
|
||||
when 'Undo'
|
||||
ActivityPub::Activity::Undo
|
||||
when 'Accept'
|
||||
ActivityPub::Activity::Accept
|
||||
when 'Reject'
|
||||
ActivityPub::Activity::Reject
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def status_from_uri(uri)
|
||||
ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
|
||||
end
|
||||
|
||||
def account_from_uri(uri)
|
||||
ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
|
||||
end
|
||||
|
||||
def object_uri
|
||||
@object_uri ||= value_or_id(@object)
|
||||
end
|
||||
|
||||
def redis
|
||||
Redis.current
|
||||
end
|
||||
|
||||
def distribute(status)
|
||||
notify_about_reblog(status) if reblog_of_local_account?(status)
|
||||
notify_about_mentions(status)
|
||||
crawl_links(status)
|
||||
distribute_to_followers(status)
|
||||
end
|
||||
|
||||
def reblog_of_local_account?(status)
|
||||
status.reblog? && status.reblog.account.local?
|
||||
end
|
||||
|
||||
def notify_about_reblog(status)
|
||||
NotifyService.new.call(status.reblog.account, status)
|
||||
end
|
||||
|
||||
def notify_about_mentions(status)
|
||||
status.mentions.includes(:account).each do |mention|
|
||||
next unless mention.account.local? && audience_includes?(mention.account)
|
||||
NotifyService.new.call(mention.account, mention)
|
||||
end
|
||||
end
|
||||
|
||||
def crawl_links(status)
|
||||
return if status.spoiler_text?
|
||||
LinkCrawlWorker.perform_async(status.id)
|
||||
end
|
||||
|
||||
def distribute_to_followers(status)
|
||||
::DistributionWorker.perform_async(status.id)
|
||||
end
|
||||
|
||||
def delete_arrived_first?(uri)
|
||||
redis.exists("delete_upon_arrival:#{@account.id}:#{uri}")
|
||||
end
|
||||
|
||||
def delete_later!(uri)
|
||||
redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri)
|
||||
end
|
||||
end
|
||||
25
app/lib/activitypub/activity/accept.rb
Normal file
25
app/lib/activitypub/activity/accept.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Accept < ActivityPub::Activity
|
||||
def perform
|
||||
case @object['type']
|
||||
when 'Follow'
|
||||
accept_follow
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def accept_follow
|
||||
target_account = account_from_uri(target_uri)
|
||||
|
||||
return if target_account.nil? || !target_account.local?
|
||||
|
||||
follow_request = FollowRequest.find_by(account: target_account, target_account: @account)
|
||||
follow_request&.authorize!
|
||||
end
|
||||
|
||||
def target_uri
|
||||
@target_uri ||= value_or_id(@object['actor'])
|
||||
end
|
||||
end
|
||||
28
app/lib/activitypub/activity/announce.rb
Normal file
28
app/lib/activitypub/activity/announce.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Announce < ActivityPub::Activity
|
||||
def perform
|
||||
original_status = status_from_uri(object_uri)
|
||||
original_status ||= fetch_remote_original_status
|
||||
|
||||
return if original_status.nil? || delete_arrived_first?(@json['id'])
|
||||
|
||||
status = Status.find_by(account: @account, reblog: original_status)
|
||||
|
||||
return status unless status.nil?
|
||||
|
||||
status = Status.create!(account: @account, reblog: original_status, uri: @json['id'])
|
||||
distribute(status)
|
||||
status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_remote_original_status
|
||||
if object_uri.start_with?('http')
|
||||
ActivityPub::FetchRemoteStatusService.new.call(object_uri)
|
||||
elsif @object['url'].present?
|
||||
::FetchRemoteStatusService.new.call(@object['url'])
|
||||
end
|
||||
end
|
||||
end
|
||||
12
app/lib/activitypub/activity/block.rb
Normal file
12
app/lib/activitypub/activity/block.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Block < ActivityPub::Activity
|
||||
def perform
|
||||
target_account = account_from_uri(object_uri)
|
||||
|
||||
return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.blocking?(target_account)
|
||||
|
||||
UnfollowService.new.call(target_account, @account) if target_account.following?(@account)
|
||||
@account.block!(target_account)
|
||||
end
|
||||
end
|
||||
175
app/lib/activitypub/activity/create.rb
Normal file
175
app/lib/activitypub/activity/create.rb
Normal file
@@ -0,0 +1,175 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
def perform
|
||||
return if delete_arrived_first?(object_uri) || unsupported_object_type?
|
||||
|
||||
status = find_existing_status
|
||||
|
||||
return status unless status.nil?
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
status = Status.create!(status_params)
|
||||
|
||||
process_tags(status)
|
||||
process_attachments(status)
|
||||
end
|
||||
|
||||
resolve_thread(status)
|
||||
distribute(status)
|
||||
forward_for_reply if status.public_visibility? || status.unlisted_visibility?
|
||||
|
||||
status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_existing_status
|
||||
status = status_from_uri(object_uri)
|
||||
status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present?
|
||||
status
|
||||
end
|
||||
|
||||
def status_params
|
||||
{
|
||||
uri: @object['id'],
|
||||
url: @object['url'] || @object['id'],
|
||||
account: @account,
|
||||
text: text_from_content || '',
|
||||
language: language_from_content,
|
||||
spoiler_text: @object['summary'] || '',
|
||||
created_at: @object['published'] || Time.now.utc,
|
||||
reply: @object['inReplyTo'].present?,
|
||||
sensitive: @object['sensitive'] || false,
|
||||
visibility: visibility_from_audience,
|
||||
thread: replied_to_status,
|
||||
conversation: conversation_from_uri(@object['conversation']),
|
||||
}
|
||||
end
|
||||
|
||||
def process_tags(status)
|
||||
return unless @object['tag'].is_a?(Array)
|
||||
|
||||
@object['tag'].each do |tag|
|
||||
case tag['type']
|
||||
when 'Hashtag'
|
||||
process_hashtag tag, status
|
||||
when 'Mention'
|
||||
process_mention tag, status
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def process_hashtag(tag, status)
|
||||
hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
|
||||
hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag)
|
||||
|
||||
status.tags << hashtag
|
||||
end
|
||||
|
||||
def process_mention(tag, status)
|
||||
account = account_from_uri(tag['href'])
|
||||
account = FetchRemoteAccountService.new.call(tag['href']) if account.nil?
|
||||
return if account.nil?
|
||||
account.mentions.create(status: status)
|
||||
end
|
||||
|
||||
def process_attachments(status)
|
||||
return unless @object['attachment'].is_a?(Array)
|
||||
|
||||
@object['attachment'].each do |attachment|
|
||||
next if unsupported_media_type?(attachment['mediaType'])
|
||||
|
||||
href = Addressable::URI.parse(attachment['url']).normalize.to_s
|
||||
media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href)
|
||||
|
||||
next if skip_download?
|
||||
|
||||
media_attachment.file_remote_url = href
|
||||
media_attachment.save
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_thread(status)
|
||||
return unless status.reply? && status.thread.nil?
|
||||
ThreadResolveWorker.perform_async(status.id, in_reply_to_uri)
|
||||
end
|
||||
|
||||
def conversation_from_uri(uri)
|
||||
return nil if uri.nil?
|
||||
return Conversation.find_by(id: TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if TagManager.instance.local_id?(uri)
|
||||
Conversation.find_by(uri: uri) || Conversation.create!(uri: uri)
|
||||
end
|
||||
|
||||
def visibility_from_audience
|
||||
if equals_or_includes?(@object['to'], ActivityPub::TagManager::COLLECTIONS[:public])
|
||||
:public
|
||||
elsif equals_or_includes?(@object['cc'], ActivityPub::TagManager::COLLECTIONS[:public])
|
||||
:unlisted
|
||||
elsif equals_or_includes?(@object['to'], @account.followers_url)
|
||||
:private
|
||||
else
|
||||
:direct
|
||||
end
|
||||
end
|
||||
|
||||
def audience_includes?(account)
|
||||
uri = ActivityPub::TagManager.instance.uri_for(account)
|
||||
equals_or_includes?(@object['to'], uri) || equals_or_includes?(@object['cc'], uri)
|
||||
end
|
||||
|
||||
def replied_to_status
|
||||
return @replied_to_status if defined?(@replied_to_status)
|
||||
|
||||
if in_reply_to_uri.blank?
|
||||
@replied_to_status = nil
|
||||
else
|
||||
@replied_to_status = status_from_uri(in_reply_to_uri)
|
||||
@replied_to_status ||= status_from_uri(@object['inReplyToAtomUri']) if @object['inReplyToAtomUri'].present?
|
||||
@replied_to_status
|
||||
end
|
||||
end
|
||||
|
||||
def in_reply_to_uri
|
||||
value_or_id(@object['inReplyTo'])
|
||||
end
|
||||
|
||||
def text_from_content
|
||||
if @object['content'].present?
|
||||
@object['content']
|
||||
elsif language_map?
|
||||
@object['contentMap'].values.first
|
||||
end
|
||||
end
|
||||
|
||||
def language_from_content
|
||||
return nil unless language_map?
|
||||
@object['contentMap'].keys.first
|
||||
end
|
||||
|
||||
def language_map?
|
||||
@object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
|
||||
end
|
||||
|
||||
def unsupported_object_type?
|
||||
@object.is_a?(String) || !%w(Article Note).include?(@object['type'])
|
||||
end
|
||||
|
||||
def unsupported_media_type?(mime_type)
|
||||
mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
|
||||
end
|
||||
|
||||
def skip_download?
|
||||
return @skip_download if defined?(@skip_download)
|
||||
@skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
|
||||
end
|
||||
|
||||
def reply_to_local?
|
||||
!replied_to_status.nil? && replied_to_status.account.local?
|
||||
end
|
||||
|
||||
def forward_for_reply
|
||||
return unless @json['signature'].present? && reply_to_local?
|
||||
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id)
|
||||
end
|
||||
end
|
||||
45
app/lib/activitypub/activity/delete.rb
Normal file
45
app/lib/activitypub/activity/delete.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Delete < ActivityPub::Activity
|
||||
def perform
|
||||
if @account.uri == object_uri
|
||||
delete_person
|
||||
else
|
||||
delete_note
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def delete_person
|
||||
SuspendAccountService.new.call(@account)
|
||||
end
|
||||
|
||||
def delete_note
|
||||
status = Status.find_by(uri: object_uri, account: @account)
|
||||
status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
|
||||
|
||||
delete_later!(object_uri)
|
||||
|
||||
return if status.nil?
|
||||
|
||||
forward_for_reblogs(status)
|
||||
delete_now!(status)
|
||||
end
|
||||
|
||||
def forward_for_reblogs(status)
|
||||
return if @json['signature'].blank?
|
||||
|
||||
ActivityPub::RawDistributionWorker.push_bulk(status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)) do |account_id|
|
||||
[payload, account_id]
|
||||
end
|
||||
end
|
||||
|
||||
def delete_now!(status)
|
||||
RemoveStatusService.new.call(status)
|
||||
end
|
||||
|
||||
def payload
|
||||
@payload ||= Oj.dump(@json)
|
||||
end
|
||||
end
|
||||
24
app/lib/activitypub/activity/follow.rb
Normal file
24
app/lib/activitypub/activity/follow.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Follow < ActivityPub::Activity
|
||||
def perform
|
||||
target_account = account_from_uri(object_uri)
|
||||
|
||||
return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.requested?(target_account)
|
||||
|
||||
# Fast-forward repeat follow requests
|
||||
if @account.following?(target_account)
|
||||
AuthorizeFollowService.new.call(@account, target_account, skip_follow_request: true)
|
||||
return
|
||||
end
|
||||
|
||||
follow_request = FollowRequest.create!(account: @account, target_account: target_account)
|
||||
|
||||
if target_account.locked?
|
||||
NotifyService.new.call(target_account, follow_request)
|
||||
else
|
||||
AuthorizeFollowService.new.call(@account, target_account)
|
||||
NotifyService.new.call(target_account, ::Follow.find_by(account: @account, target_account: target_account))
|
||||
end
|
||||
end
|
||||
end
|
||||
12
app/lib/activitypub/activity/like.rb
Normal file
12
app/lib/activitypub/activity/like.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Like < ActivityPub::Activity
|
||||
def perform
|
||||
original_status = status_from_uri(object_uri)
|
||||
|
||||
return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
|
||||
|
||||
favourite = original_status.favourites.create!(account: @account)
|
||||
NotifyService.new.call(original_status.account, favourite)
|
||||
end
|
||||
end
|
||||
25
app/lib/activitypub/activity/reject.rb
Normal file
25
app/lib/activitypub/activity/reject.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Reject < ActivityPub::Activity
|
||||
def perform
|
||||
case @object['type']
|
||||
when 'Follow'
|
||||
reject_follow
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reject_follow
|
||||
target_account = account_from_uri(target_uri)
|
||||
|
||||
return if target_account.nil? || !target_account.local?
|
||||
|
||||
follow_request = FollowRequest.find_by(account: target_account, target_account: @account)
|
||||
follow_request&.reject!
|
||||
end
|
||||
|
||||
def target_uri
|
||||
@target_uri ||= value_or_id(@object['actor'])
|
||||
end
|
||||
end
|
||||
71
app/lib/activitypub/activity/undo.rb
Normal file
71
app/lib/activitypub/activity/undo.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Undo < ActivityPub::Activity
|
||||
def perform
|
||||
case @object['type']
|
||||
when 'Announce'
|
||||
undo_announce
|
||||
when 'Follow'
|
||||
undo_follow
|
||||
when 'Like'
|
||||
undo_like
|
||||
when 'Block'
|
||||
undo_block
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def undo_announce
|
||||
status = Status.find_by(uri: object_uri, account: @account)
|
||||
|
||||
if status.nil?
|
||||
delete_later!(object_uri)
|
||||
else
|
||||
RemoveStatusService.new.call(status)
|
||||
end
|
||||
end
|
||||
|
||||
def undo_follow
|
||||
target_account = account_from_uri(target_uri)
|
||||
|
||||
return if target_account.nil? || !target_account.local?
|
||||
|
||||
if @account.following?(target_account)
|
||||
@account.unfollow!(target_account)
|
||||
elsif @account.requested?(target_account)
|
||||
FollowRequest.find_by(account: @account, target_account: target_account)&.destroy
|
||||
else
|
||||
delete_later!(object_uri)
|
||||
end
|
||||
end
|
||||
|
||||
def undo_like
|
||||
status = status_from_uri(target_uri)
|
||||
|
||||
return if status.nil? || !status.account.local?
|
||||
|
||||
if @account.favourited?(status)
|
||||
favourite = status.favourites.where(account: @account).first
|
||||
favourite&.destroy
|
||||
else
|
||||
delete_later!(object_uri)
|
||||
end
|
||||
end
|
||||
|
||||
def undo_block
|
||||
target_account = account_from_uri(target_uri)
|
||||
|
||||
return if target_account.nil? || !target_account.local?
|
||||
|
||||
if @account.blocking?(target_account)
|
||||
UnblockService.new.call(@account, target_account)
|
||||
else
|
||||
delete_later!(object_uri)
|
||||
end
|
||||
end
|
||||
|
||||
def target_uri
|
||||
@target_uri ||= value_or_id(@object['object'])
|
||||
end
|
||||
end
|
||||
17
app/lib/activitypub/activity/update.rb
Normal file
17
app/lib/activitypub/activity/update.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Activity::Update < ActivityPub::Activity
|
||||
def perform
|
||||
case @object['type']
|
||||
when 'Person'
|
||||
update_account
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_account
|
||||
return if @account.uri != object_uri
|
||||
ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object)
|
||||
end
|
||||
end
|
||||
@@ -1,13 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
||||
CONTEXT = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
|
||||
{
|
||||
'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
|
||||
'sensitive' => 'as:sensitive',
|
||||
'Hashtag' => 'as:Hashtag',
|
||||
'ostatus' => 'http://ostatus.org#',
|
||||
'atomUri' => 'ostatus:atomUri',
|
||||
'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri',
|
||||
'conversation' => 'ostatus:conversation',
|
||||
},
|
||||
],
|
||||
}.freeze
|
||||
|
||||
def self.default_key_transform
|
||||
:camel_lower
|
||||
end
|
||||
|
||||
def self.transform_key_casing!(value, _options)
|
||||
ActivityPub::CaseTransform.camel_lower(value)
|
||||
end
|
||||
|
||||
def serializable_hash(options = nil)
|
||||
options = serialization_options(options)
|
||||
serialized_hash = { '@context': 'https://www.w3.org/ns/activitystreams' }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options))
|
||||
serialized_hash = CONTEXT.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options))
|
||||
self.class.transform_key_casing!(serialized_hash, instance_options)
|
||||
end
|
||||
end
|
||||
|
||||
24
app/lib/activitypub/case_transform.rb
Normal file
24
app/lib/activitypub/case_transform.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module ActivityPub::CaseTransform
|
||||
class << self
|
||||
def camel_lower_cache
|
||||
@camel_lower_cache ||= {}
|
||||
end
|
||||
|
||||
def camel_lower(value)
|
||||
case value
|
||||
when Array then value.map { |item| camel_lower(item) }
|
||||
when Hash then value.deep_transform_keys! { |key| camel_lower(key) }
|
||||
when Symbol then camel_lower(value.to_s).to_sym
|
||||
when String
|
||||
camel_lower_cache[value] ||= if value.start_with?('_:')
|
||||
'_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower)
|
||||
else
|
||||
value.underscore.camelize(:lower)
|
||||
end
|
||||
else value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
56
app/lib/activitypub/linked_data_signature.rb
Normal file
56
app/lib/activitypub/linked_data_signature.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::LinkedDataSignature
|
||||
include JsonLdHelper
|
||||
|
||||
CONTEXT = 'https://w3id.org/identity/v1'
|
||||
|
||||
def initialize(json)
|
||||
@json = json.with_indifferent_access
|
||||
end
|
||||
|
||||
def verify_account!
|
||||
return unless @json['signature'].is_a?(Hash)
|
||||
|
||||
type = @json['signature']['type']
|
||||
creator_uri = @json['signature']['creator']
|
||||
signature = @json['signature']['signatureValue']
|
||||
|
||||
return unless type == 'RsaSignature2017'
|
||||
|
||||
creator = ActivityPub::TagManager.instance.uri_to_resource(creator_uri, Account)
|
||||
creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri)
|
||||
|
||||
return if creator.nil?
|
||||
|
||||
options_hash = hash(@json['signature'].without('type', 'id', 'signatureValue').merge('@context' => CONTEXT))
|
||||
document_hash = hash(@json.without('signature'))
|
||||
to_be_verified = options_hash + document_hash
|
||||
|
||||
if creator.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, Base64.decode64(signature), to_be_verified)
|
||||
creator
|
||||
end
|
||||
end
|
||||
|
||||
def sign!(creator)
|
||||
options = {
|
||||
'type' => 'RsaSignature2017',
|
||||
'creator' => [ActivityPub::TagManager.instance.uri_for(creator), '#main-key'].join,
|
||||
'created' => Time.now.utc.iso8601,
|
||||
}
|
||||
|
||||
options_hash = hash(options.without('type', 'id', 'signatureValue').merge('@context' => CONTEXT))
|
||||
document_hash = hash(@json.without('signature'))
|
||||
to_be_signed = options_hash + document_hash
|
||||
|
||||
signature = Base64.strict_encode64(creator.keypair.sign(OpenSSL::Digest::SHA256.new, to_be_signed))
|
||||
|
||||
@json.merge('signature' => options.merge('signatureValue' => signature))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def hash(obj)
|
||||
Digest::SHA256.hexdigest(canonicalize(obj))
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,8 @@ class ActivityPub::TagManager
|
||||
include Singleton
|
||||
include RoutingHelper
|
||||
|
||||
CONTEXT = 'https://www.w3.org/ns/activitystreams'
|
||||
|
||||
COLLECTIONS = {
|
||||
public: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
}.freeze
|
||||
@@ -17,6 +19,7 @@ class ActivityPub::TagManager
|
||||
when :person
|
||||
short_account_url(target)
|
||||
when :note, :comment, :activity
|
||||
return activity_account_status_url(target.account, target) if target.reblog?
|
||||
short_account_status_url(target.account, target)
|
||||
end
|
||||
end
|
||||
@@ -28,10 +31,17 @@ class ActivityPub::TagManager
|
||||
when :person
|
||||
account_url(target)
|
||||
when :note, :comment, :activity
|
||||
return activity_account_status_url(target.account, target) if target.reblog?
|
||||
account_status_url(target.account, target)
|
||||
end
|
||||
end
|
||||
|
||||
def activity_uri_for(target)
|
||||
return nil unless %i(note comment activity).include?(target.object_type) && target.local?
|
||||
|
||||
activity_account_status_url(target.account, target)
|
||||
end
|
||||
|
||||
# Primary audience of a status
|
||||
# Public statuses go out to primarily the public collection
|
||||
# Unlisted and private statuses go out primarily to the followers collection
|
||||
@@ -66,4 +76,32 @@ class ActivityPub::TagManager
|
||||
|
||||
cc
|
||||
end
|
||||
|
||||
def local_uri?(uri)
|
||||
uri = Addressable::URI.parse(uri)
|
||||
host = uri.normalized_host
|
||||
host = "#{host}:#{uri.port}" if uri.port
|
||||
|
||||
!host.nil? && (::TagManager.instance.local_domain?(host) || ::TagManager.instance.web_domain?(host))
|
||||
end
|
||||
|
||||
def uri_to_local_id(uri, param = :id)
|
||||
path_params = Rails.application.routes.recognize_path(uri)
|
||||
path_params[param]
|
||||
end
|
||||
|
||||
def uri_to_resource(uri, klass)
|
||||
if local_uri?(uri)
|
||||
case klass.name
|
||||
when 'Account'
|
||||
klass.find_local(uri_to_local_id(uri, :username))
|
||||
else
|
||||
klass.find_by(id: uri_to_local_id(uri))
|
||||
end
|
||||
elsif ::TagManager.instance.local_id?(uri)
|
||||
klass.find_by(id: ::TagManager.instance.unique_tag_to_local_id(uri, klass.to_s))
|
||||
else
|
||||
klass.find_by(uri: uri.split('#').first)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,21 +29,43 @@ class OStatus::Activity::Base
|
||||
end
|
||||
|
||||
def url
|
||||
link = @xml.at_xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS)
|
||||
link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS).find { |link_candidate| link_candidate['type'] == 'text/html' }
|
||||
link.nil? ? nil : link['href']
|
||||
end
|
||||
|
||||
def activitypub_uri
|
||||
link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS).find { |link_candidate| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link_candidate['type']) }
|
||||
link.nil? ? nil : link['href']
|
||||
end
|
||||
|
||||
def activitypub_uri?
|
||||
activitypub_uri.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_status(uri)
|
||||
if TagManager.instance.local_id?(uri)
|
||||
local_id = TagManager.instance.unique_tag_to_local_id(uri, 'Status')
|
||||
return Status.find_by(id: local_id)
|
||||
elsif ActivityPub::TagManager.instance.local_uri?(uri)
|
||||
local_id = ActivityPub::TagManager.instance.uri_to_local_id(uri)
|
||||
return Status.find_by(id: local_id)
|
||||
end
|
||||
|
||||
Status.find_by(uri: uri)
|
||||
end
|
||||
|
||||
def find_activitypub_status(uri, href)
|
||||
tag_matches = /tag:([^,:]+)[^:]*:objectId=([\d]+)/.match(uri)
|
||||
href_matches = %r{/users/([^/]+)}.match(href)
|
||||
|
||||
unless tag_matches.nil? || href_matches.nil?
|
||||
uri = "https://#{tag_matches[1]}/users/#{href_matches[1]}/statuses/#{tag_matches[2]}"
|
||||
Status.find_by(uri: uri)
|
||||
end
|
||||
end
|
||||
|
||||
def redis
|
||||
Redis.current
|
||||
end
|
||||
|
||||
@@ -9,6 +9,11 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
|
||||
|
||||
return [nil, false] if @account.suspended?
|
||||
|
||||
if activitypub_uri? && [:public, :unlisted].include?(visibility_scope)
|
||||
result = perform_via_activitypub
|
||||
return result if result.first.present?
|
||||
end
|
||||
|
||||
Rails.logger.debug "Creating remote status #{id}"
|
||||
|
||||
# Return early if status already exists in db
|
||||
@@ -16,24 +21,28 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
|
||||
|
||||
return [status, false] unless status.nil?
|
||||
|
||||
status = Status.create!(
|
||||
uri: id,
|
||||
url: url,
|
||||
account: @account,
|
||||
reblog: reblog,
|
||||
text: content,
|
||||
spoiler_text: content_warning,
|
||||
created_at: published,
|
||||
reply: thread?,
|
||||
language: content_language,
|
||||
visibility: visibility_scope,
|
||||
conversation: find_or_create_conversation,
|
||||
thread: thread? ? find_status(thread.first) : nil
|
||||
)
|
||||
cached_reblog = reblog
|
||||
|
||||
save_mentions(status)
|
||||
save_hashtags(status)
|
||||
save_media(status)
|
||||
ApplicationRecord.transaction do
|
||||
status = Status.create!(
|
||||
uri: id,
|
||||
url: url,
|
||||
account: @account,
|
||||
reblog: cached_reblog,
|
||||
text: content,
|
||||
spoiler_text: content_warning,
|
||||
created_at: published,
|
||||
reply: thread?,
|
||||
language: content_language,
|
||||
visibility: visibility_scope,
|
||||
conversation: find_or_create_conversation,
|
||||
thread: thread? ? find_status(thread.first) || find_activitypub_status(thread.first, thread.second) : nil
|
||||
)
|
||||
|
||||
save_mentions(status)
|
||||
save_hashtags(status)
|
||||
save_media(status)
|
||||
end
|
||||
|
||||
if thread? && status.thread.nil?
|
||||
Rails.logger.debug "Trying to attach #{status.id} (#{id}) to #{thread.first}"
|
||||
@@ -48,6 +57,10 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
|
||||
[status, true]
|
||||
end
|
||||
|
||||
def perform_via_activitypub
|
||||
[find_status(activitypub_uri) || ActivityPub::FetchRemoteStatusService.new.call(activitypub_uri), false]
|
||||
end
|
||||
|
||||
def content
|
||||
@xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS).content
|
||||
end
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
class OStatus::Activity::Deletion < OStatus::Activity::Base
|
||||
def perform
|
||||
Rails.logger.debug "Deleting remote status #{id}"
|
||||
status = Status.find_by(uri: id, account: @account)
|
||||
|
||||
status = Status.find_by(uri: id, account: @account)
|
||||
status ||= Status.find_by(uri: activitypub_uri, account: @account) if activitypub_uri?
|
||||
|
||||
if status.nil?
|
||||
redis.setex("delete_upon_arrival:#{@account.id}:#{id}", 6 * 3_600, id)
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
class OStatus::Activity::Remote < OStatus::Activity::Base
|
||||
def perform
|
||||
find_status(id) || FetchRemoteStatusService.new.call(url)
|
||||
if activitypub_uri?
|
||||
find_status(activitypub_uri) || FetchRemoteStatusService.new.call(url)
|
||||
else
|
||||
find_status(id) || FetchRemoteStatusService.new.call(url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -79,6 +79,9 @@ class OStatus::AtomSerializer
|
||||
|
||||
if stream_entry.status.nil?
|
||||
append_element(entry, 'content', 'Deleted status')
|
||||
elsif stream_entry.status.destroyed?
|
||||
append_element(entry, 'content', 'Deleted status')
|
||||
append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(stream_entry.status)) if stream_entry.account.local?
|
||||
else
|
||||
serialize_status_attributes(entry, stream_entry.status)
|
||||
end
|
||||
@@ -343,6 +346,8 @@ class OStatus::AtomSerializer
|
||||
end
|
||||
|
||||
def serialize_status_attributes(entry, status)
|
||||
append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(status)) if status.account.local?
|
||||
|
||||
append_element(entry, 'summary', status.spoiler_text, 'xml:lang': status.language) if status.spoiler_text?
|
||||
append_element(entry, 'content', Formatter.instance.format(status).to_str, type: 'html', 'xml:lang': status.language)
|
||||
|
||||
|
||||
@@ -12,15 +12,21 @@ class Request
|
||||
@headers = {}
|
||||
|
||||
set_common_headers!
|
||||
set_digest! if options.key?(:body)
|
||||
end
|
||||
|
||||
def on_behalf_of(account)
|
||||
def on_behalf_of(account, key_id_format = :acct)
|
||||
raise ArgumentError unless account.local?
|
||||
@account = account
|
||||
|
||||
@account = account
|
||||
@key_id_format = key_id_format
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def add_headers(new_headers)
|
||||
@headers.merge!(new_headers)
|
||||
self
|
||||
end
|
||||
|
||||
def perform
|
||||
@@ -40,8 +46,11 @@ class Request
|
||||
@headers['Date'] = Time.now.utc.httpdate
|
||||
end
|
||||
|
||||
def set_digest!
|
||||
@headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
|
||||
end
|
||||
|
||||
def signature
|
||||
key_id = @account.to_webfinger_s
|
||||
algorithm = 'rsa-sha256'
|
||||
signature = Base64.strict_encode64(@account.keypair.sign(OpenSSL::Digest::SHA256.new, signed_string))
|
||||
|
||||
@@ -60,6 +69,15 @@ class Request
|
||||
@user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +#{root_url})"
|
||||
end
|
||||
|
||||
def key_id
|
||||
case @key_id_format
|
||||
when :acct
|
||||
@account.to_webfinger_s
|
||||
when :uri
|
||||
[ActivityPub::TagManager.instance.uri_for(@account), '#main-key'].join
|
||||
end
|
||||
end
|
||||
|
||||
def timeout
|
||||
{ write: 10, connect: 10, read: 10 }
|
||||
end
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class StreamEntryFinder
|
||||
class StatusFinder
|
||||
attr_reader :url
|
||||
|
||||
def initialize(url)
|
||||
@url = url
|
||||
end
|
||||
|
||||
def stream_entry
|
||||
def status
|
||||
verify_action!
|
||||
|
||||
raise ActiveRecord::RecordNotFound unless TagManager.instance.local_url?(url)
|
||||
|
||||
case recognized_params[:controller]
|
||||
when 'stream_entries'
|
||||
StreamEntry.find(recognized_params[:id])
|
||||
StreamEntry.find(recognized_params[:id]).status
|
||||
when 'statuses'
|
||||
Status.find(recognized_params[:id]).stream_entry
|
||||
Status.find(recognized_params[:id])
|
||||
else
|
||||
raise ActiveRecord::RecordNotFound
|
||||
end
|
||||
Reference in New Issue
Block a user