diff options
Diffstat (limited to 'app/lib')
23 files changed, 795 insertions, 35 deletions
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb new file mode 100644 index 000000000..b06dd6194 --- /dev/null +++ b/app/lib/activitypub/activity.rb @@ -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 diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb new file mode 100644 index 000000000..bd90c9019 --- /dev/null +++ b/app/lib/activitypub/activity/accept.rb @@ -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 diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb new file mode 100644 index 000000000..c4da405c7 --- /dev/null +++ b/app/lib/activitypub/activity/announce.rb @@ -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 diff --git a/app/lib/activitypub/activity/block.rb b/app/lib/activitypub/activity/block.rb new file mode 100644 index 000000000..f630d5db2 --- /dev/null +++ b/app/lib/activitypub/activity/block.rb @@ -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 diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb new file mode 100644 index 000000000..9a34484f5 --- /dev/null +++ b/app/lib/activitypub/activity/create.rb @@ -0,0 +1,185 @@ +# 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 object_url + return if @object['url'].blank? + + value = first_of_value(@object['url']) + + return value if value.is_a?(String) + + value['href'] + 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 diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb new file mode 100644 index 000000000..4c6afb090 --- /dev/null +++ b/app/lib/activitypub/activity/delete.rb @@ -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 diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb new file mode 100644 index 000000000..8adbbb9c3 --- /dev/null +++ b/app/lib/activitypub/activity/follow.rb @@ -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 diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb new file mode 100644 index 000000000..674d5fe47 --- /dev/null +++ b/app/lib/activitypub/activity/like.rb @@ -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 diff --git a/app/lib/activitypub/activity/reject.rb b/app/lib/activitypub/activity/reject.rb new file mode 100644 index 000000000..d815feeb6 --- /dev/null +++ b/app/lib/activitypub/activity/reject.rb @@ -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 diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb new file mode 100644 index 000000000..4b0905de2 --- /dev/null +++ b/app/lib/activitypub/activity/undo.rb @@ -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 diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb new file mode 100644 index 000000000..0134b4015 --- /dev/null +++ b/app/lib/activitypub/activity/update.rb @@ -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 diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index 0a70207bc..6ed66a239 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -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 diff --git a/app/lib/activitypub/case_transform.rb b/app/lib/activitypub/case_transform.rb new file mode 100644 index 000000000..7f716f862 --- /dev/null +++ b/app/lib/activitypub/case_transform.rb @@ -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 diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb new file mode 100644 index 000000000..adb8b6cdf --- /dev/null +++ b/app/lib/activitypub/linked_data_signature.rb @@ -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 diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index ec42bcad3..de575d9e6 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -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 diff --git a/app/lib/ostatus/activity/base.rb b/app/lib/ostatus/activity/base.rb index e1477f0eb..1dc7abee3 100644 --- a/app/lib/ostatus/activity/base.rb +++ b/app/lib/ostatus/activity/base.rb @@ -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 diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb index e22f746f2..1a23c9efa 100644 --- a/app/lib/ostatus/activity/creation.rb +++ b/app/lib/ostatus/activity/creation.rb @@ -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 - ) - - save_mentions(status) - save_hashtags(status) - save_media(status) + cached_reblog = reblog + + 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 diff --git a/app/lib/ostatus/activity/deletion.rb b/app/lib/ostatus/activity/deletion.rb index 860faf501..c98f5ee0a 100644 --- a/app/lib/ostatus/activity/deletion.rb +++ b/app/lib/ostatus/activity/deletion.rb @@ -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) diff --git a/app/lib/ostatus/activity/remote.rb b/app/lib/ostatus/activity/remote.rb index ecec6886c..5b204b6d8 100644 --- a/app/lib/ostatus/activity/remote.rb +++ b/app/lib/ostatus/activity/remote.rb @@ -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 diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb index 0d62361be..b8e22a381 100644 --- a/app/lib/ostatus/atom_serializer.rb +++ b/app/lib/ostatus/atom_serializer.rb @@ -65,7 +65,7 @@ class OStatus::AtomSerializer add_namespaces(entry) if root - append_element(entry, 'id', TagManager.instance.unique_tag(stream_entry.created_at, stream_entry.activity_id, stream_entry.activity_type)) + append_element(entry, 'id', TagManager.instance.uri_for(stream_entry.status)) append_element(entry, 'published', stream_entry.created_at.iso8601) append_element(entry, 'updated', stream_entry.updated_at.iso8601) append_element(entry, 'title', stream_entry&.status&.title || "#{stream_entry.account.acct} deleted status") @@ -79,11 +79,14 @@ 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 - append_element(entry, 'link', nil, rel: :alternate, type: 'text/html', href: account_stream_entry_url(stream_entry.account, stream_entry)) + append_element(entry, 'link', nil, rel: :alternate, type: 'text/html', href: TagManager.instance.url_for(stream_entry.status)) append_element(entry, 'link', nil, rel: :self, type: 'application/atom+xml', href: account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom')) append_element(entry, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(stream_entry.thread), href: TagManager.instance.url_for(stream_entry.thread)) if stream_entry.threaded? append_element(entry, 'ostatus:conversation', nil, ref: conversation_uri(stream_entry.status.conversation)) unless stream_entry&.status&.conversation_id.nil? @@ -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) diff --git a/app/lib/request.rb b/app/lib/request.rb index e73c5ac20..c01e07925 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -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 diff --git a/app/lib/stream_entry_finder.rb b/app/lib/status_finder.rb index 0ea33229c..4d1aed297 100644 --- a/app/lib/stream_entry_finder.rb +++ b/app/lib/status_finder.rb @@ -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 diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb index 5f87a2a48..f33a20c6f 100644 --- a/app/lib/tag_manager.rb +++ b/app/lib/tag_manager.rb @@ -49,12 +49,17 @@ class TagManager def unique_tag_to_local_id(tag, expected_type) return nil unless local_id?(tag) - matches = Regexp.new("objectId=([\\d]+):objectType=#{expected_type}").match(tag) - return matches[1] unless matches.nil? + + if ActivityPub::TagManager.instance.local_uri?(tag) + ActivityPub::TagManager.instance.uri_to_local_id(tag) + else + matches = Regexp.new("objectId=([\\d]+):objectType=#{expected_type}").match(tag) + return matches[1] unless matches.nil? + end end def local_id?(id) - id.start_with?("tag:#{Rails.configuration.x.local_domain}") + id.start_with?("tag:#{Rails.configuration.x.local_domain}") || ActivityPub::TagManager.instance.local_uri?(id) end def web_domain?(domain) @@ -92,7 +97,7 @@ class TagManager when :person account_url(target) when :note, :comment, :activity - unique_tag(target.created_at, target.id, 'Status') + target.uri || unique_tag(target.created_at, target.id, 'Status') end end |