From dd7ef0dc41584089a97444d8192bc61505108e6c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 8 Aug 2017 21:52:15 +0200 Subject: Add ActivityPub inbox (#4216) * Add ActivityPub inbox * Handle ActivityPub deletes * Handle ActivityPub creates * Handle ActivityPub announces * Stubs for handling all activities that need to be handled * Add ActivityPub actor resolving * Handle conversation URI passing in ActivityPub * Handle content language in ActivityPub * Send accept header when fetching actor, handle JSON parse errors * Test for ActivityPub::FetchRemoteAccountService * Handle public key and icon/image when embedded/as array/as resolvable URI * Implement ActivityPub::FetchRemoteStatusService * Add stubs for more interactions * Undo activities implemented * Handle out of order activities * Hook up ActivityPub to ResolveRemoteAccountService, handle Update Account activities * Add fragment IDs to all transient activity serializers * Add tests and fixes * Add stubs for missing tests * Add more tests * Add more tests --- app/lib/activitypub/activity.rb | 109 +++++++++++++++++++++++ app/lib/activitypub/activity/announce.rb | 14 +++ app/lib/activitypub/activity/block.rb | 12 +++ app/lib/activitypub/activity/create.rb | 148 +++++++++++++++++++++++++++++++ app/lib/activitypub/activity/delete.rb | 13 +++ app/lib/activitypub/activity/follow.rb | 12 +++ app/lib/activitypub/activity/like.rb | 12 +++ app/lib/activitypub/activity/undo.rb | 69 ++++++++++++++ app/lib/activitypub/activity/update.rb | 17 ++++ app/lib/activitypub/adapter.rb | 2 +- app/lib/activitypub/tag_manager.rb | 25 ++++++ 11 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 app/lib/activitypub/activity.rb create mode 100644 app/lib/activitypub/activity/announce.rb create mode 100644 app/lib/activitypub/activity/block.rb create mode 100644 app/lib/activitypub/activity/create.rb create mode 100644 app/lib/activitypub/activity/delete.rb create mode 100644 app/lib/activitypub/activity/follow.rb create mode 100644 app/lib/activitypub/activity/like.rb create mode 100644 app/lib/activitypub/activity/undo.rb create mode 100644 app/lib/activitypub/activity/update.rb (limited to 'app/lib') diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb new file mode 100644 index 000000000..d1b81a582 --- /dev/null +++ b/app/lib/activitypub/activity.rb @@ -0,0 +1,109 @@ +# 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 + 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 ||= @object.is_a?(String) ? @object : @object['id'] + 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) + key = "delete_upon_arrival:#{@account.id}:#{uri}" + + if redis.exists(key) + redis.del(key) + true + else + false + end + 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/announce.rb b/app/lib/activitypub/activity/announce.rb new file mode 100644 index 000000000..decf8f960 --- /dev/null +++ b/app/lib/activitypub/activity/announce.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Announce < ActivityPub::Activity + def perform + original_status = status_from_uri(object_uri) + original_status = ActivityPub::FetchRemoteStatusService.new.call(object_uri) if original_status.nil? + + return if original_status.nil? || delete_arrived_first?(@json['id']) + + status = Status.create!(account: @account, reblog: original_status, uri: @json['id']) + distribute(status) + status + end +end diff --git a/app/lib/activitypub/activity/block.rb b/app/lib/activitypub/activity/block.rb new file mode 100644 index 000000000..e6b6c837b --- /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']) + + 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..4c4049bc6 --- /dev/null +++ b/app/lib/activitypub/activity/create.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Create < ActivityPub::Activity + def perform + return if delete_arrived_first?(object_uri) || unsupported_object_type? + + status = Status.find_by(uri: object_uri) + + 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) + + status + end + + private + + def status_params + { + uri: @object['id'], + url: @object['url'], + 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 = ActivityPub::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? + ActivityPub::ThreadResolveWorker.perform_async(status.id, @object['inReplyTo']) + 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 if @object['inReplyTo'].blank? + @replied_to_status ||= status_from_uri(@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 +end diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb new file mode 100644 index 000000000..23f3430fb --- /dev/null +++ b/app/lib/activitypub/activity/delete.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Delete < ActivityPub::Activity + def perform + status = Status.find_by(uri: object_uri, account: @account) + + if status.nil? + delete_later!(object_uri) + else + RemoveStatusService.new.call(status) + end + end +end diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb new file mode 100644 index 000000000..7918b5108 --- /dev/null +++ b/app/lib/activitypub/activity/follow.rb @@ -0,0 +1,12 @@ +# 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']) + + follow = @account.follow!(target_account) + NotifyService.new.call(target_account, follow) + end +end diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb new file mode 100644 index 000000000..c24527597 --- /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']) + + favourite = original_status.favourites.where(account: @account).first_or_create!(account: @account) + NotifyService.new.call(original_status.account, favourite) + end +end diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb new file mode 100644 index 000000000..078e97ed4 --- /dev/null +++ b/app/lib/activitypub/activity/undo.rb @@ -0,0 +1,69 @@ +# 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) + 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 ||= @object['object'].is_a?(String) ? @object['object'] : @object['object']['id'] + 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..e038136c0 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -7,7 +7,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base 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': ActivityPub::TagManager::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/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index ec42bcad3..96e610b6d 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 @@ -66,4 +68,27 @@ class ActivityPub::TagManager cc end + + def local_uri?(uri) + host = Addressable::URI.parse(uri).normalized_host + ::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 + else + klass.find_by(uri: uri) + end + end end -- cgit From fdea173237cfcd3a6b36f6ebccb0cb1a21cf9294 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 9 Aug 2017 23:54:14 +0200 Subject: Add Digest header to requests with body, handle acct and URI keyId (#4565) --- app/controllers/concerns/signature_verification.rb | 23 ++++++- app/lib/request.rb | 24 ++++++- .../concerns/signature_verification_spec.rb | 78 ++++++++++++++++------ 3 files changed, 100 insertions(+), 25 deletions(-) (limited to 'app/lib') diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index abe845d93..aeb8da879 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -31,7 +31,7 @@ module SignatureVerification return end - account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, '')) + account = account_from_key_id(signature_params['keyId']) if account.nil? @signed_request_account = nil @@ -49,6 +49,10 @@ module SignatureVerification end end + def request_body + @request_body ||= request.raw_post + end + private def build_signed_string(signed_headers) @@ -57,6 +61,8 @@ module SignatureVerification signed_headers.split(' ').map do |signed_header| if signed_header == Request::REQUEST_TARGET "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" + elsif signed_header == 'digest' + "digest: #{body_digest}" else "#{signed_header}: #{request.headers[to_header_name(signed_header)]}" end @@ -73,6 +79,10 @@ module SignatureVerification (Time.now.utc - time_sent).abs <= 30 end + def body_digest + "SHA-256=#{Digest::SHA256.base64digest(request_body)}" + end + def to_header_name(name) name.split(/-/).map(&:capitalize).join('-') end @@ -81,7 +91,14 @@ module SignatureVerification signature_params['keyId'].blank? || signature_params['signature'].blank? || signature_params['algorithm'].blank? || - signature_params['algorithm'] != 'rsa-sha256' || - !signature_params['keyId'].start_with?('acct:') + signature_params['algorithm'] != 'rsa-sha256' + end + + def account_from_key_id(key_id) + if key_id.start_with?('acct:') + ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, '')) + elsif !ActivityPub::TagManager.instance.local_uri?(key_id) + ActivityPub::FetchRemoteAccountService.new.call(key_id) + end end end 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/spec/controllers/concerns/signature_verification_spec.rb b/spec/controllers/concerns/signature_verification_spec.rb index b371795ab..64648621e 100644 --- a/spec/controllers/concerns/signature_verification_spec.rb +++ b/spec/controllers/concerns/signature_verification_spec.rb @@ -16,7 +16,7 @@ describe ApplicationController, type: :controller do end before do - routes.draw { get 'success' => 'anonymous#success' } + routes.draw { match via: [:get, :post], 'success' => 'anonymous#success' } end context 'without signature header' do @@ -40,34 +40,74 @@ describe ApplicationController, type: :controller do context 'with signature header' do let!(:author) { Fabricate(:account) } - before do - get :success + context 'without body' do + before do + get :success - fake_request = Request.new(:get, request.url) - fake_request.on_behalf_of(author) + fake_request = Request.new(:get, request.url) + fake_request.on_behalf_of(author) - request.headers.merge!(fake_request.headers) - end + request.headers.merge!(fake_request.headers) + end - describe '#signed_request?' do - it 'returns true' do - expect(controller.signed_request?).to be true + describe '#signed_request?' do + it 'returns true' do + expect(controller.signed_request?).to be true + end + end + + describe '#signed_request_account' do + it 'returns an account' do + expect(controller.signed_request_account).to eq author + end + + it 'returns nil when path does not match' do + request.path = '/alternative-path' + expect(controller.signed_request_account).to be_nil + end + + it 'returns nil when method does not match' do + post :success + expect(controller.signed_request_account).to be_nil + end end end - describe '#signed_request_account' do - it 'returns an account' do - expect(controller.signed_request_account).to eq author + context 'with body' do + before do + post :success, body: 'Hello world' + + fake_request = Request.new(:post, request.url, body: 'Hello world') + fake_request.on_behalf_of(author) + + request.headers.merge!(fake_request.headers) end - it 'returns nil when path does not match' do - request.path = '/alternative-path' - expect(controller.signed_request_account).to be_nil + describe '#signed_request?' do + it 'returns true' do + expect(controller.signed_request?).to be true + end end - it 'returns nil when method does not match' do - post :success - expect(controller.signed_request_account).to be_nil + describe '#signed_request_account' do + it 'returns an account' do + expect(controller.signed_request_account).to eq author + end + + it 'returns nil when path does not match' do + request.path = '/alternative-path' + expect(controller.signed_request_account).to be_nil + end + + it 'returns nil when method does not match' do + get :success + expect(controller.signed_request_account).to be_nil + end + + it 'returns nil when body has been tampered' do + request.headers['RAW_POST_DATA'] = 'doo doo doo' + expect(controller.signed_request_account).to be_nil + end end end end -- cgit From 81c1303cd6137c8c90794cc0bfdc1a0479eb2153 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 10 Aug 2017 22:33:12 +0200 Subject: Handle ActivityPub follows correctly (#4571) * Handle ActivityPub follows correctly ActivityPub follows are follow-requests. Always require an Accept. If account is not locked, auto-accept. * Handle ActivityPub Accept/Reject-Follow * Fix wrong method * Fix wrong class --- app/lib/activitypub/activity.rb | 4 ++++ app/lib/activitypub/activity/accept.rb | 25 +++++++++++++++++++++++++ app/lib/activitypub/activity/follow.rb | 10 ++++++++-- app/lib/activitypub/activity/reject.rb | 25 +++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 app/lib/activitypub/activity/accept.rb create mode 100644 app/lib/activitypub/activity/reject.rb (limited to 'app/lib') diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index d1b81a582..5debe023a 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -39,6 +39,10 @@ class ActivityPub::Activity ActivityPub::Activity::Update when 'Undo' ActivityPub::Activity::Undo + when 'Accept' + ActivityPub::Activity::Accept + when 'Reject' + ActivityPub::Activity::Reject end end end diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb new file mode 100644 index 000000000..f5880937a --- /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 ||= @object['object'].is_a?(String) ? @object['object'] : @object['object']['id'] + end +end diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb index 7918b5108..3fb698d1d 100644 --- a/app/lib/activitypub/activity/follow.rb +++ b/app/lib/activitypub/activity/follow.rb @@ -6,7 +6,13 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) - follow = @account.follow!(target_account) - NotifyService.new.call(target_account, follow) + 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/reject.rb b/app/lib/activitypub/activity/reject.rb new file mode 100644 index 000000000..78dbfd1e5 --- /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 ||= @object['object'].is_a?(String) ? @object['object'] : @object['object']['id'] + end +end -- cgit From ccdd5a9576819cdc95946d98fea0e3c8bbd1d626 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 12 Aug 2017 17:41:03 +0200 Subject: Add serializing/unserializing of "locked" actor attribute (#4585) --- app/lib/activitypub/adapter.rb | 4 ++++ app/lib/activitypub/case_transform.rb | 24 ++++++++++++++++++++++ app/serializers/activitypub/actor_serializer.rb | 2 ++ .../activitypub/process_account_service.rb | 1 + 4 files changed, 31 insertions(+) create mode 100644 app/lib/activitypub/case_transform.rb (limited to 'app/lib') diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index e038136c0..df132f019 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -5,6 +5,10 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base :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': ActivityPub::TagManager::CONTEXT }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options)) 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/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index 8a119603d..b15736868 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -9,6 +9,8 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer has_one :public_key, serializer: ActivityPub::PublicKeySerializer + attribute :locked, key: '_:locked' + class ImageSerializer < ActiveModel::Serializer include RoutingHelper diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 92e2dbb30..9fb7ebf9e 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -46,6 +46,7 @@ class ActivityPub::ProcessAccountService < BaseService @account.avatar_remote_url = image_url('icon') @account.header_remote_url = image_url('image') @account.public_key = public_key || '' + @account.locked = @json['_:locked'] || false @account.save! end -- cgit From b7370ac8baa643d93ea727699b3b11f9d3a55bea Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 13 Aug 2017 00:44:41 +0200 Subject: ActivityPub delivery (#4566) * Deliver ActivityPub Like * Deliver ActivityPub Undo-Like * Deliver ActivityPub Create/Announce activities * Deliver ActivityPub creates from mentions * Deliver ActivityPub Block/Undo-Block * Deliver ActivityPub Accept/Reject-Follow * Deliver ActivityPub Undo-Follow * Deliver ActivityPub Follow * Deliver ActivityPub Delete activities Incidentally fix #889 * Adjust BatchedRemoveStatusService for ActivityPub * Add tests for ActivityPub workers * Add tests for FollowService * Add tests for FavouriteService, UnfollowService and PostStatusService * Add tests for ReblogService, BlockService, UnblockService, ProcessMentionsService * Add tests for AuthorizeFollowService, RejectFollowService, RemoveStatusService * Add tests for BatchedRemoveStatusService * Deliver updates to a local account to ActivityPub followers * Minor adjustments --- .../api/v1/accounts/credentials_controller.rb | 3 +- app/controllers/settings/profiles_controller.rb | 1 + app/lib/activitypub/activity.rb | 2 +- app/models/account.rb | 4 ++ app/services/authorize_follow_service.rb | 19 ++++++- app/services/batched_remove_status_service.rb | 43 ++++++++++++-- app/services/block_service.rb | 19 ++++++- app/services/favourite_service.rb | 28 ++++++--- app/services/follow_service.rb | 14 ++++- app/services/post_status_service.rb | 1 + app/services/process_mentions_service.rb | 28 ++++++--- app/services/reblog_service.rb | 28 +++++++-- app/services/reject_follow_service.rb | 19 ++++++- app/services/remove_status_service.rb | 49 +++++++++++++--- app/services/unblock_service.rb | 19 ++++++- app/services/unfavourite_service.rb | 22 +++++++- app/services/unfollow_service.rb | 19 ++++++- app/workers/activitypub/delivery_worker.rb | 37 ++++++++++++ app/workers/activitypub/distribution_worker.rb | 38 +++++++++++++ app/workers/activitypub/processing_worker.rb | 2 +- .../activitypub/update_distribution_worker.rb | 31 ++++++++++ .../api/v1/accounts/credentials_controller_spec.rb | 6 ++ .../settings/profiles_controller_spec.rb | 2 + spec/services/authorize_follow_service_spec.rb | 24 +++++++- .../services/batched_remove_status_service_spec.rb | 7 +++ spec/services/block_service_spec.rb | 19 ++++++- spec/services/favourite_service_spec.rb | 22 +++++++- spec/services/follow_service_spec.rb | 25 ++++++-- spec/services/post_status_service_spec.rb | 8 ++- spec/services/process_mentions_service_spec.rb | 46 +++++++++++---- spec/services/reblog_service_spec.rb | 49 ++++++++++++---- spec/services/reject_follow_service_spec.rb | 24 +++++++- spec/services/remove_status_service_spec.rb | 8 +++ .../resolve_remote_account_service_spec.rb | 66 ++++++++++++---------- spec/services/unblock_service_spec.rb | 22 +++++++- spec/services/unfollow_service_spec.rb | 22 +++++++- spec/workers/activitypub/delivery_worker_spec.rb | 23 ++++++++ .../activitypub/distribution_worker_spec.rb | 48 ++++++++++++++++ spec/workers/activitypub/processing_worker_spec.rb | 15 +++++ .../activitypub/thread_resolve_worker_spec.rb | 16 ++++++ .../activitypub/update_distribution_worker_spec.rb | 20 +++++++ 41 files changed, 785 insertions(+), 113 deletions(-) create mode 100644 app/workers/activitypub/delivery_worker.rb create mode 100644 app/workers/activitypub/distribution_worker.rb create mode 100644 app/workers/activitypub/update_distribution_worker.rb create mode 100644 spec/workers/activitypub/delivery_worker_spec.rb create mode 100644 spec/workers/activitypub/distribution_worker_spec.rb create mode 100644 spec/workers/activitypub/processing_worker_spec.rb create mode 100644 spec/workers/activitypub/thread_resolve_worker_spec.rb create mode 100644 spec/workers/activitypub/update_distribution_worker_spec.rb (limited to 'app/lib') diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index 073808532..90a580c33 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -10,8 +10,9 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController end def update - current_account.update!(account_params) @account = current_account + @account.update!(account_params) + ActivityPub::UpdateDistributionWorker.perform_async(@account.id) render json: @account, serializer: REST::CredentialAccountSerializer end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 0367e3593..c751c64ae 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -15,6 +15,7 @@ class Settings::ProfilesController < ApplicationController def update if @account.update(account_params) + ActivityPub::UpdateDistributionWorker.perform_async(@account.id) redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg') else render :show diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 5debe023a..f8de8060c 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -93,7 +93,7 @@ class ActivityPub::Activity end def distribute_to_followers(status) - DistributionWorker.perform_async(status.id) + ::DistributionWorker.perform_async(status.id) end def delete_arrived_first?(uri) diff --git a/app/models/account.rb b/app/models/account.rb index 163bd1c0e..a7264353e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -171,6 +171,10 @@ class Account < ApplicationRecord reorder(nil).pluck('distinct accounts.domain') end + def inboxes + reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)") + end + def triadic_closures(account, limit: 5, offset: 0) sql = <<-SQL.squish WITH first_degree AS ( diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb index 41815a393..db35b6030 100644 --- a/app/services/authorize_follow_service.rb +++ b/app/services/authorize_follow_service.rb @@ -4,11 +4,28 @@ class AuthorizeFollowService < BaseService def call(source_account, target_account) follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account) follow_request.authorize! - NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local? + create_notification(follow_request) unless source_account.local? + follow_request end private + def create_notification(follow_request) + if follow_request.account.ostatus? + NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id) + elsif follow_request.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url) + end + end + + def build_json(follow_request) + ActiveModelSerializers::SerializableResource.new( + follow_request, + serializer: ActivityPub::AcceptFollowSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def build_xml(follow_request) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.authorize_follow_request_salmon(follow_request)) end diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index ab810c628..e6c8c9208 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -15,9 +15,11 @@ class BatchedRemoveStatusService < BaseService @mentions = statuses.map { |s| [s.id, s.mentions.includes(:account).to_a] }.to_h @tags = statuses.map { |s| [s.id, s.tags.pluck(:name)] }.to_h - @stream_entry_batches = [] - @salmon_batches = [] - @json_payloads = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h + @stream_entry_batches = [] + @salmon_batches = [] + @activity_json_batches = [] + @json_payloads = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h + @activity_json = {} # Ensure that rendered XML reflects destroyed state Status.where(id: statuses.map(&:id)).in_batches.destroy_all @@ -27,7 +29,11 @@ class BatchedRemoveStatusService < BaseService account = account_statuses.first.account unpush_from_home_timelines(account_statuses) - batch_stream_entries(account_statuses) if account.local? + + if account.local? + batch_stream_entries(account_statuses) + batch_activity_json(account, account_statuses) + end end # Cannot be batched @@ -38,6 +44,7 @@ class BatchedRemoveStatusService < BaseService Pubsubhubbub::DistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch } NotificationWorker.push_bulk(@salmon_batches) { |batch| batch } + ActivityPub::DeliveryWorker.push_bulk(@activity_json_batches) { |batch| batch } end private @@ -50,6 +57,22 @@ class BatchedRemoveStatusService < BaseService end end + def batch_activity_json(account, statuses) + account.followers.inboxes.each do |inbox_url| + statuses.each do |status| + @activity_json_batches << [build_json(status), account.id, inbox_url] + end + end + + statuses.each do |status| + other_recipients = (status.mentions + status.reblogs).map(&:account).reject(&:local?).select(&:activitypub?).uniq(&:id) + + other_recipients.each do |target_account| + @activity_json_batches << [build_json(status), account.id, target_account.inbox_url] + end + end + end + def unpush_from_home_timelines(statuses) account = statuses.first.account recipients = account.followers.local.pluck(:id) @@ -79,7 +102,7 @@ class BatchedRemoveStatusService < BaseService return if @mentions[status.id].empty? payload = stream_entry_to_xml(status.stream_entry.reload) - recipients = @mentions[status.id].map(&:account).reject(&:local?).uniq(&:domain).map(&:id) + recipients = @mentions[status.id].map(&:account).reject(&:local?).select(&:ostatus?).uniq(&:domain).map(&:id) recipients.each do |recipient_id| @salmon_batches << [payload, status.account_id, recipient_id] @@ -111,4 +134,14 @@ class BatchedRemoveStatusService < BaseService def redis Redis.current end + + def build_json(status) + return @activity_json[status.id] if @activity_json.key?(status.id) + + @activity_json[status.id] = ActiveModelSerializers::SerializableResource.new( + status, + serializer: ActivityPub::DeleteSerializer, + adapter: ActivityPub::Adapter + ).to_json + end end diff --git a/app/services/block_service.rb b/app/services/block_service.rb index 5d7bf6a3b..f2253226b 100644 --- a/app/services/block_service.rb +++ b/app/services/block_service.rb @@ -12,11 +12,28 @@ class BlockService < BaseService block = account.block!(target_account) BlockWorker.perform_async(account.id, target_account.id) - NotificationWorker.perform_async(build_xml(block), account.id, target_account.id) unless target_account.local? + create_notification(block) unless target_account.local? + block end private + def create_notification(block) + if block.target_account.ostatus? + NotificationWorker.perform_async(build_xml(block), block.account_id, block.target_account_id) + elsif block.target_account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(block), block.account_id, block.target_account.inbox_url) + end + end + + def build_json(block) + ActiveModelSerializers::SerializableResource.new( + block, + serializer: ActivityPub::BlockSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def build_xml(block) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.block_salmon(block)) end diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index 291f9e56e..4aa935170 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -15,18 +15,32 @@ class FavouriteService < BaseService return favourite unless favourite.nil? favourite = Favourite.create!(account: account, status: status) - - if status.local? - NotifyService.new.call(favourite.status.account, favourite) - else - NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id) - end - + create_notification(favourite) favourite end private + def create_notification(favourite) + status = favourite.status + + if status.account.local? + NotifyService.new.call(status.account, favourite) + elsif status.account.ostatus? + NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id) + elsif status.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url) + end + end + + def build_json(favourite) + ActiveModelSerializers::SerializableResource.new( + favourite, + serializer: ActivityPub::LikeSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def build_xml(favourite) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.favourite_salmon(favourite)) end diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 3155feaa4..2be625cd8 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -14,7 +14,7 @@ class FollowService < BaseService return if source_account.following?(target_account) - if target_account.locked? + if target_account.locked? || target_account.activitypub? request_follow(source_account, target_account) else direct_follow(source_account, target_account) @@ -28,9 +28,11 @@ class FollowService < BaseService if target_account.local? NotifyService.new.call(target_account, follow_request) - else + elsif target_account.ostatus? NotificationWorker.perform_async(build_follow_request_xml(follow_request), source_account.id, target_account.id) AfterRemoteFollowRequestWorker.perform_async(follow_request.id) + elsif target_account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), source_account.id, target_account.inbox_url) end follow_request @@ -63,4 +65,12 @@ class FollowService < BaseService def build_follow_xml(follow) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_salmon(follow)) end + + def build_json(follow_request) + ActiveModelSerializers::SerializableResource.new( + follow_request, + serializer: ActivityPub::FollowSerializer, + adapter: ActivityPub::Adapter + ).to_json + end end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 951a38e19..5ff93f21e 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -39,6 +39,7 @@ class PostStatusService < BaseService LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text? DistributionWorker.perform_async(status.id) Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) + ActivityPub::DistributionWorker.perform_async(status.id) if options[:idempotency].present? redis.setex("idempotency:status:#{account.id}:#{options[:idempotency]}", 3_600, status.id) diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 438033d22..407fa8c18 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -28,18 +28,32 @@ class ProcessMentionsService < BaseService end status.mentions.includes(:account).each do |mention| - mentioned_account = mention.account - - if mentioned_account.local? - NotifyService.new.call(mentioned_account, mention) - else - NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id) - end + create_notification(status, mention) end end private + def create_notification(status, mention) + mentioned_account = mention.account + + if mentioned_account.local? + NotifyService.new.call(mentioned_account, mention) + elsif mentioned_account.ostatus? + NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id) + elsif mentioned_account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(mention.status), mention.status.account_id, mentioned_account.inbox_url) + end + end + + def build_json(status) + ActiveModelSerializers::SerializableResource.new( + status, + serializer: ActivityPub::ActivitySerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def follow_remote_account_service @follow_remote_account_service ||= ResolveRemoteAccountService.new end diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index ba24b1f9d..7f886af7c 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -21,13 +21,31 @@ class ReblogService < BaseService DistributionWorker.perform_async(reblog.id) Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) + ActivityPub::DistributionWorker.perform_async(reblog.id) - if reblogged_status.local? - NotifyService.new.call(reblog.reblog.account, reblog) - else - NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), account.id, reblog.reblog.account_id) + create_notification(reblog) + reblog + end + + private + + def create_notification(reblog) + reblogged_status = reblog.reblog + + if reblogged_status.account.local? + NotifyService.new.call(reblogged_status.account, reblog) + elsif reblogged_status.account.ostatus? + NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), reblog.account_id, reblogged_status.account_id) + elsif reblogged_status.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url) end + end - reblog + def build_json(reblog) + ActiveModelSerializers::SerializableResource.new( + reblog, + serializer: ActivityPub::ActivitySerializer, + adapter: ActivityPub::Adapter + ).to_json end end diff --git a/app/services/reject_follow_service.rb b/app/services/reject_follow_service.rb index fd7e66c23..a91266aa4 100644 --- a/app/services/reject_follow_service.rb +++ b/app/services/reject_follow_service.rb @@ -4,11 +4,28 @@ class RejectFollowService < BaseService def call(source_account, target_account) follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account) follow_request.reject! - NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local? + create_notification(follow_request) unless source_account.local? + follow_request end private + def create_notification(follow_request) + if follow_request.account.ostatus? + NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id) + elsif follow_request.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url) + end + end + + def build_json(follow_request) + ActiveModelSerializers::SerializableResource.new( + follow_request, + serializer: ActivityPub::RejectFollowSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def build_xml(follow_request) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.reject_follow_request_salmon(follow_request)) end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index a5281f586..fcccbaa24 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -22,8 +22,10 @@ class RemoveStatusService < BaseService return unless @account.local? - remove_from_mentioned(@stream_entry.reload) - Pubsubhubbub::DistributionWorker.perform_async(@stream_entry.id) + @stream_entry = @stream_entry.reload + + remove_from_remote_followers + remove_from_remote_affected end private @@ -38,15 +40,48 @@ class RemoveStatusService < BaseService end end - def remove_from_mentioned(stream_entry) - salmon_xml = stream_entry_to_xml(stream_entry) - target_accounts = @mentions.map(&:account).reject(&:local?).uniq(&:domain) + def remove_from_remote_affected + # People who got mentioned in the status, or who + # reblogged it from someone else might not follow + # the author and wouldn't normally receive the + # delete notification - so here, we explicitly + # send it to them + + target_accounts = (@mentions.map(&:account).reject(&:local?) + @reblogs.map(&:account).reject(&:local?)).uniq(&:id) + + # Ostatus + NotificationWorker.push_bulk(target_accounts.select(&:ostatus?).uniq(&:domain)) do |target_account| + [salmon_xml, @account.id, target_account.id] + end + + # ActivityPub + ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:inbox_url)) do |inbox_url| + [activity_json, @account.id, inbox_url] + end + end + + def remove_from_remote_followers + # OStatus + Pubsubhubbub::DistributionWorker.perform_async(@stream_entry.id) - NotificationWorker.push_bulk(target_accounts) do |target_account| - [salmon_xml, stream_entry.account_id, target_account.id] + # ActivityPub + ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url| + [activity_json, @account.id, inbox_url] end end + def salmon_xml + @salmon_xml ||= stream_entry_to_xml(@stream_entry) + end + + def activity_json + @activity_json ||= ActiveModelSerializers::SerializableResource.new( + @status, + serializer: ActivityPub::DeleteSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def remove_reblogs # We delete reblogs of the status before the original status, # because once original status is gone, reblogs will disappear diff --git a/app/services/unblock_service.rb b/app/services/unblock_service.rb index ff15c7275..72fc5ab15 100644 --- a/app/services/unblock_service.rb +++ b/app/services/unblock_service.rb @@ -5,11 +5,28 @@ class UnblockService < BaseService return unless account.blocking?(target_account) unblock = account.unblock!(target_account) - NotificationWorker.perform_async(build_xml(unblock), account.id, target_account.id) unless target_account.local? + create_notification(unblock) unless target_account.local? + unblock end private + def create_notification(unblock) + if unblock.target_account.ostatus? + NotificationWorker.perform_async(build_xml(unblock), unblock.account_id, unblock.target_account_id) + elsif unblock.target_account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(unblock), unblock.account_id, unblock.target_account.inbox_url) + end + end + + def build_json(unblock) + ActiveModelSerializers::SerializableResource.new( + unblock, + serializer: ActivityPub::UndoBlockSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def build_xml(block) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unblock_salmon(block)) end diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb index 564aaee46..e53798e66 100644 --- a/app/services/unfavourite_service.rb +++ b/app/services/unfavourite_service.rb @@ -4,14 +4,30 @@ class UnfavouriteService < BaseService def call(account, status) favourite = Favourite.find_by!(account: account, status: status) favourite.destroy! - - NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id) unless status.local? - + create_notification(favourite) unless status.local? favourite end private + def create_notification(favourite) + status = favourite.status + + if status.account.ostatus? + NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id) + elsif status.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url) + end + end + + def build_json(favourite) + ActiveModelSerializers::SerializableResource.new( + favourite, + serializer: ActivityPub::UndoLikeSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def build_xml(favourite) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfavourite_salmon(favourite)) end diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index 388909586..10af75146 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -7,12 +7,29 @@ class UnfollowService < BaseService def call(source_account, target_account) follow = source_account.unfollow!(target_account) return unless follow - NotificationWorker.perform_async(build_xml(follow), source_account.id, target_account.id) unless target_account.local? + create_notification(follow) unless target_account.local? UnmergeWorker.perform_async(target_account.id, source_account.id) + follow end private + def create_notification(follow) + if follow.target_account.ostatus? + NotificationWorker.perform_async(build_xml(follow), follow.account_id, follow.target_account_id) + elsif follow.target_account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.account_id, follow.target_account.inbox_url) + end + end + + def build_json(follow) + ActiveModelSerializers::SerializableResource.new( + follow, + serializer: ActivityPub::UndoFollowSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def build_xml(follow) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfollow_salmon(follow)) end diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb new file mode 100644 index 000000000..cd67b6710 --- /dev/null +++ b/app/workers/activitypub/delivery_worker.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class ActivityPub::DeliveryWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push', retry: 5, dead: false + + HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze + + def perform(json, source_account_id, inbox_url) + @json = json + @source_account = Account.find(source_account_id) + @inbox_url = inbox_url + + perform_request + + raise Mastodon::UnexpectedResponseError, @response unless response_successful? + rescue => e + raise e.class, "Delivery failed for #{inbox_url}: #{e.message}" + end + + private + + def build_request + request = Request.new(:post, @inbox_url, body: @json) + request.on_behalf_of(@source_account, :uri) + request.add_headers(HEADERS) + end + + def perform_request + @response = build_request.perform + end + + def response_successful? + @response.code > 199 && @response.code < 300 + end +end diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb new file mode 100644 index 000000000..004dd25d1 --- /dev/null +++ b/app/workers/activitypub/distribution_worker.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ActivityPub::DistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(status_id) + @status = Status.find(status_id) + @account = @status.account + + return if skip_distribution? + + ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| + [payload, @account.id, inbox_url] + end + rescue ActiveRecord::RecordNotFound + true + end + + private + + def skip_distribution? + @status.direct_visibility? + end + + def inboxes + @inboxes ||= @account.followers.inboxes + end + + def payload + @payload ||= ActiveModelSerializers::SerializableResource.new( + @status, + serializer: ActivityPub::ActivitySerializer, + adapter: ActivityPub::Adapter + ).to_json + end +end diff --git a/app/workers/activitypub/processing_worker.rb b/app/workers/activitypub/processing_worker.rb index 7656ab56a..bb9adf64b 100644 --- a/app/workers/activitypub/processing_worker.rb +++ b/app/workers/activitypub/processing_worker.rb @@ -6,6 +6,6 @@ class ActivityPub::ProcessingWorker sidekiq_options backtrace: true def perform(account_id, body) - ProcessCollectionService.new.call(body, Account.find(account_id)) + ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id)) end end diff --git a/app/workers/activitypub/update_distribution_worker.rb b/app/workers/activitypub/update_distribution_worker.rb new file mode 100644 index 000000000..f3377dcec --- /dev/null +++ b/app/workers/activitypub/update_distribution_worker.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class ActivityPub::UpdateDistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(account_id) + @account = Account.find(account_id) + + ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| + [payload, @account.id, inbox_url] + end + rescue ActiveRecord::RecordNotFound + true + end + + private + + def inboxes + @inboxes ||= @account.followers.inboxes + end + + def payload + @payload ||= ActiveModelSerializers::SerializableResource.new( + @account, + serializer: ActivityPub::UpdateSerializer, + adapter: ActivityPub::Adapter + ).to_json + end +end diff --git a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb index 4a3100348..bc89772b9 100644 --- a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb @@ -20,6 +20,8 @@ describe Api::V1::Accounts::CredentialsController do describe 'PATCH #update' do describe 'with valid data' do before do + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) + patch :update, params: { display_name: "Alice Isn't Dead", note: "Hi!\n\nToot toot!", @@ -40,6 +42,10 @@ describe Api::V1::Accounts::CredentialsController do expect(user.account.avatar).to exist expect(user.account.header).to exist end + + it 'queues up an account update distribution' do + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(user.account_id) + end end describe 'with invalid data' do diff --git a/spec/controllers/settings/profiles_controller_spec.rb b/spec/controllers/settings/profiles_controller_spec.rb index e502dbda7..ee3315be6 100644 --- a/spec/controllers/settings/profiles_controller_spec.rb +++ b/spec/controllers/settings/profiles_controller_spec.rb @@ -17,11 +17,13 @@ RSpec.describe Settings::ProfilesController, type: :controller do describe 'PUT #update' do it 'updates the user profile' do + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) account = Fabricate(:account, user: @user, display_name: 'Old name') put :update, params: { account: { display_name: 'New name' } } expect(account.reload.display_name).to eq 'New name' expect(response).to redirect_to(settings_profile_path) + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) end end end diff --git a/spec/services/authorize_follow_service_spec.rb b/spec/services/authorize_follow_service_spec.rb index 3f3a2bc56..d74eb41a2 100644 --- a/spec/services/authorize_follow_service_spec.rb +++ b/spec/services/authorize_follow_service_spec.rb @@ -22,7 +22,7 @@ RSpec.describe AuthorizeFollowService do end end - describe 'remote' do + describe 'remote OStatus' do let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do @@ -46,4 +46,26 @@ RSpec.describe AuthorizeFollowService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox')).account } + + before do + FollowRequest.create(account: bob, target_account: sender) + stub_request(:post, bob.inbox_url).to_return(status: 200) + subject.call(bob, sender) + end + + it 'removes follow request' do + expect(bob.requested?(sender)).to be false + end + + it 'creates follow relation' do + expect(bob.following?(sender)).to be true + end + + it 'sends an accept activity' do + expect(a_request(:post, bob.inbox_url)).to have_been_made.once + end + end end diff --git a/spec/services/batched_remove_status_service_spec.rb b/spec/services/batched_remove_status_service_spec.rb index c20085e25..2484d4b58 100644 --- a/spec/services/batched_remove_status_service_spec.rb +++ b/spec/services/batched_remove_status_service_spec.rb @@ -6,6 +6,7 @@ RSpec.describe BatchedRemoveStatusService do let!(:alice) { Fabricate(:account) } let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://example.com/salmon') } let!(:jeff) { Fabricate(:account) } + let!(:hank) { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } let(:status1) { PostStatusService.new.call(alice, 'Hello @bob@example.com') } let(:status2) { PostStatusService.new.call(alice, 'Another status') } @@ -15,9 +16,11 @@ RSpec.describe BatchedRemoveStatusService do stub_request(:post, 'http://example.com/push').to_return(status: 200, body: '', headers: {}) stub_request(:post, 'http://example.com/salmon').to_return(status: 200, body: '', headers: {}) + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) Fabricate(:subscription, account: alice, callback_url: 'http://example.com/push', confirmed: true, expires_at: 30.days.from_now) jeff.follow!(alice) + hank.follow!(alice) status1 status2 @@ -58,4 +61,8 @@ RSpec.describe BatchedRemoveStatusService do xml.match(TagManager::VERBS[:delete]) }).to have_been_made.once end + + it 'sends delete activity to followers' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.at_least_once + end end diff --git a/spec/services/block_service_spec.rb b/spec/services/block_service_spec.rb index 2a54e032e..bd2ab3d53 100644 --- a/spec/services/block_service_spec.rb +++ b/spec/services/block_service_spec.rb @@ -17,7 +17,7 @@ RSpec.describe BlockService do end end - describe 'remote' do + describe 'remote OStatus' do let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do @@ -36,4 +36,21 @@ RSpec.describe BlockService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + + before do + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + subject.call(sender, bob) + end + + it 'creates a blocking relation' do + expect(sender.blocking?(bob)).to be true + end + + it 'sends a block activity' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end end diff --git a/spec/services/favourite_service_spec.rb b/spec/services/favourite_service_spec.rb index 36f1b64d4..2ab1f32ca 100644 --- a/spec/services/favourite_service_spec.rb +++ b/spec/services/favourite_service_spec.rb @@ -18,8 +18,8 @@ RSpec.describe FavouriteService do end end - describe 'remote' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } + describe 'remote OStatus' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :ostatus, domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } let(:status) { Fabricate(:status, account: bob, uri: 'tag:example.com:blahblah') } before do @@ -38,4 +38,22 @@ RSpec.describe FavouriteService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :activitypub, username: 'bob', domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + let(:status) { Fabricate(:status, account: bob) } + + before do + stub_request(:post, "http://example.com/inbox").to_return(:status => 200, :body => "", :headers => {}) + subject.call(sender, status) + end + + it 'creates a favourite' do + expect(status.favourites.first).to_not be_nil + end + + it 'sends a like activity' do + expect(a_request(:post, "http://example.com/inbox")).to have_been_made.once + end + end end diff --git a/spec/services/follow_service_spec.rb b/spec/services/follow_service_spec.rb index 32dedb3ad..1e2378031 100644 --- a/spec/services/follow_service_spec.rb +++ b/spec/services/follow_service_spec.rb @@ -44,9 +44,9 @@ RSpec.describe FollowService do end end - context 'remote account' do + context 'remote OStatus account' do describe 'locked account' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, locked: true, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :ostatus, locked: true, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do stub_request(:post, "http://salmon.example.com/").to_return(:status => 200, :body => "", :headers => {}) @@ -66,7 +66,7 @@ RSpec.describe FollowService do end describe 'unlocked account' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account } + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :ostatus, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account } before do stub_request(:post, "http://salmon.example.com/").to_return(:status => 200, :body => "", :headers => {}) @@ -91,7 +91,7 @@ RSpec.describe FollowService do end describe 'already followed account' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account } + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :ostatus, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account } before do sender.follow!(bob) @@ -111,4 +111,21 @@ RSpec.describe FollowService do end end end + + context 'remote ActivityPub account' do + let(:bob) { Fabricate(:user, account: Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox')).account } + + before do + stub_request(:post, "http://example.com/inbox").to_return(:status => 200, :body => "", :headers => {}) + subject.call(sender, bob.acct) + end + + it 'creates follow request' do + expect(FollowRequest.find_by(account: sender, target_account: bob)).to_not be_nil + end + + it 'sends a follow activity to the inbox' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end end diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index 57876dcc2..4182c4e1f 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -100,16 +100,18 @@ RSpec.describe PostStatusService do expect(hashtags_service).to have_received(:call).with(status) end - it 'pings PuSH hubs' do + it 'gets distributed' do allow(DistributionWorker).to receive(:perform_async) allow(Pubsubhubbub::DistributionWorker).to receive(:perform_async) + allow(ActivityPub::DistributionWorker).to receive(:perform_async) + account = Fabricate(:account) status = subject.call(account, "test status update") expect(DistributionWorker).to have_received(:perform_async).with(status.id) - expect(Pubsubhubbub::DistributionWorker). - to have_received(:perform_async).with(status.stream_entry.id) + expect(Pubsubhubbub::DistributionWorker).to have_received(:perform_async).with(status.stream_entry.id) + expect(ActivityPub::DistributionWorker).to have_received(:perform_async).with(status.id) end it 'crawls links' do diff --git a/spec/services/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb index 984d13746..09f8fa45b 100644 --- a/spec/services/process_mentions_service_spec.rb +++ b/spec/services/process_mentions_service_spec.rb @@ -1,22 +1,44 @@ require 'rails_helper' RSpec.describe ProcessMentionsService do - let(:account) { Fabricate(:account, username: 'alice') } - let(:remote_user) { Fabricate(:account, username: 'remote_user', domain: 'example.com', salmon_url: 'http://salmon.example.com') } - let(:status) { Fabricate(:status, account: account, text: "Hello @#{remote_user.acct}") } + let(:account) { Fabricate(:account, username: 'alice') } + let(:status) { Fabricate(:status, account: account, text: "Hello @#{remote_user.acct}") } - subject { ProcessMentionsService.new } + context 'OStatus' do + let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :ostatus, domain: 'example.com', salmon_url: 'http://salmon.example.com') } - before do - stub_request(:post, remote_user.salmon_url) - subject.(status) - end + subject { ProcessMentionsService.new } + + before do + stub_request(:post, remote_user.salmon_url) + subject.call(status) + end - it 'creates a mention' do - expect(remote_user.mentions.where(status: status).count).to eq 1 + it 'creates a mention' do + expect(remote_user.mentions.where(status: status).count).to eq 1 + end + + it 'posts to remote user\'s Salmon end point' do + expect(a_request(:post, remote_user.salmon_url)).to have_been_made.once + end end - it 'posts to remote user\'s Salmon end point' do - expect(a_request(:post, remote_user.salmon_url)).to have_been_made + context 'ActivityPub' do + let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } + + subject { ProcessMentionsService.new } + + before do + stub_request(:post, remote_user.inbox_url) + subject.call(status) + end + + it 'creates a mention' do + expect(remote_user.mentions.where(status: status).count).to eq 1 + end + + it 'sends activity to the inbox' do + expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once + end end end diff --git a/spec/services/reblog_service_spec.rb b/spec/services/reblog_service_spec.rb index 5f89169e9..0ad5c5f6b 100644 --- a/spec/services/reblog_service_spec.rb +++ b/spec/services/reblog_service_spec.rb @@ -2,22 +2,49 @@ require 'rails_helper' RSpec.describe ReblogService do let(:alice) { Fabricate(:account, username: 'alice') } - let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com') } - let(:status) { Fabricate(:status, account: bob, uri: 'tag:example.com;something:something') } - subject { ReblogService.new } + context 'OStatus' do + let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com') } + let(:status) { Fabricate(:status, account: bob, uri: 'tag:example.com;something:something') } - before do - stub_request(:post, 'http://salmon.example.com') + subject { ReblogService.new } - subject.(alice, status) - end + before do + stub_request(:post, 'http://salmon.example.com') + subject.call(alice, status) + end + + it 'creates a reblog' do + expect(status.reblogs.count).to eq 1 + end - it 'creates a reblog' do - expect(status.reblogs.count).to eq 1 + it 'sends a Salmon slap for a remote reblog' do + expect(a_request(:post, 'http://salmon.example.com')).to have_been_made + end end - it 'sends a Salmon slap for a remote reblog' do - expect(a_request(:post, 'http://salmon.example.com')).to have_been_made + context 'ActivityPub' do + let(:bob) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } + let(:status) { Fabricate(:status, account: bob) } + + subject { ReblogService.new } + + before do + stub_request(:post, bob.inbox_url) + allow(ActivityPub::DistributionWorker).to receive(:perform_async) + subject.call(alice, status) + end + + it 'creates a reblog' do + expect(status.reblogs.count).to eq 1 + end + + it 'distributes to followers' do + expect(ActivityPub::DistributionWorker).to have_received(:perform_async) + end + + it 'sends an announce activity to the author' do + expect(a_request(:post, bob.inbox_url)).to have_been_made.once + end end end diff --git a/spec/services/reject_follow_service_spec.rb b/spec/services/reject_follow_service_spec.rb index 50749b633..2e06345b3 100644 --- a/spec/services/reject_follow_service_spec.rb +++ b/spec/services/reject_follow_service_spec.rb @@ -22,7 +22,7 @@ RSpec.describe RejectFollowService do end end - describe 'remote' do + describe 'remote OStatus' do let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do @@ -46,4 +46,26 @@ RSpec.describe RejectFollowService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox')).account } + + before do + FollowRequest.create(account: bob, target_account: sender) + stub_request(:post, bob.inbox_url).to_return(status: 200) + subject.call(bob, sender) + end + + it 'removes follow request' do + expect(bob.requested?(sender)).to be false + end + + it 'does not create follow relation' do + expect(bob.following?(sender)).to be false + end + + it 'sends a reject activity' do + expect(a_request(:post, bob.inbox_url)).to have_been_made.once + end + end end diff --git a/spec/services/remove_status_service_spec.rb b/spec/services/remove_status_service_spec.rb index a3bce7613..dc6b350cb 100644 --- a/spec/services/remove_status_service_spec.rb +++ b/spec/services/remove_status_service_spec.rb @@ -6,13 +6,17 @@ RSpec.describe RemoveStatusService do let!(:alice) { Fabricate(:account) } let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://example.com/salmon') } let!(:jeff) { Fabricate(:account) } + let!(:hank) { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } before do stub_request(:post, 'http://example.com/push').to_return(status: 200, body: '', headers: {}) stub_request(:post, 'http://example.com/salmon').to_return(status: 200, body: '', headers: {}) + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) Fabricate(:subscription, account: alice, callback_url: 'http://example.com/push', confirmed: true, expires_at: 30.days.from_now) jeff.follow!(alice) + hank.follow!(alice) + @status = PostStatusService.new.call(alice, 'Hello @bob@example.com') subject.call(@status) end @@ -31,6 +35,10 @@ RSpec.describe RemoveStatusService do }).to have_been_made end + it 'sends delete activity to followers' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.twice + end + it 'sends Salmon slap to previously mentioned users' do expect(a_request(:post, "http://example.com/salmon").with { |req| xml = OStatus2::Salmon.new.unpack(req.body) diff --git a/spec/services/resolve_remote_account_service_spec.rb b/spec/services/resolve_remote_account_service_spec.rb index c3b902b34..d0eab2310 100644 --- a/spec/services/resolve_remote_account_service_spec.rb +++ b/spec/services/resolve_remote_account_service_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe ResolveRemoteAccountService do - subject { ResolveRemoteAccountService.new } + subject { described_class.new } before do stub_request(:get, "https://quitter.no/.well-known/host-meta").to_return(request_fixture('.host-meta.txt')) @@ -29,29 +29,6 @@ RSpec.describe ResolveRemoteAccountService do expect(subject.call('catsrgr8@example.com')).to be_nil end - it 'returns an already existing remote account' do - old_account = Fabricate(:account, username: 'gargron', domain: 'quitter.no') - returned_account = subject.call('gargron@quitter.no') - - expect(old_account.id).to eq returned_account.id - end - - it 'returns a new remote account' do - account = subject.call('gargron@quitter.no') - - expect(account.username).to eq 'gargron' - expect(account.domain).to eq 'quitter.no' - expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom' - end - - it 'follows a legitimate account redirection' do - account = subject.call('gargron@redirected.com') - - expect(account.username).to eq 'gargron' - expect(account.domain).to eq 'quitter.no' - expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom' - end - it 'prevents hijacking existing accounts' do account = subject.call('hacker1@redirected.com') expect(account.salmon_url).to_not eq 'https://hacker.com/main/salmon/user/7477' @@ -61,12 +38,41 @@ RSpec.describe ResolveRemoteAccountService do expect(subject.call('hacker2@redirected.com')).to be_nil end - it 'returns a new remote account' do - account = subject.call('foo@localdomain.com') + context 'with an OStatus account' do + it 'returns an already existing remote account' do + old_account = Fabricate(:account, username: 'gargron', domain: 'quitter.no') + returned_account = subject.call('gargron@quitter.no') + + expect(old_account.id).to eq returned_account.id + end + + it 'returns a new remote account' do + account = subject.call('gargron@quitter.no') + + expect(account.username).to eq 'gargron' + expect(account.domain).to eq 'quitter.no' + expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom' + end + + it 'follows a legitimate account redirection' do + account = subject.call('gargron@redirected.com') + + expect(account.username).to eq 'gargron' + expect(account.domain).to eq 'quitter.no' + expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom' + end + + it 'returns a new remote account' do + account = subject.call('foo@localdomain.com') + + expect(account.username).to eq 'foo' + expect(account.domain).to eq 'localdomain.com' + expect(account.remote_url).to eq 'https://webdomain.com/users/foo.atom' + end + end - expect(account.username).to eq 'foo' - expect(account.domain).to eq 'localdomain.com' - expect(account.remote_url).to eq 'https://webdomain.com/users/foo.atom' + context 'with an ActivityPub account' do + pending end it 'processes one remote account at a time using locks' do @@ -78,7 +84,7 @@ RSpec.describe ResolveRemoteAccountService do Thread.new do true while wait_for_start begin - return_values << ResolveRemoteAccountService.new.call('foo@localdomain.com') + return_values << described_class.new.call('foo@localdomain.com') rescue ActiveRecord::RecordNotUnique fail_occurred = true end diff --git a/spec/services/unblock_service_spec.rb b/spec/services/unblock_service_spec.rb index 1b9ae1239..def4981e7 100644 --- a/spec/services/unblock_service_spec.rb +++ b/spec/services/unblock_service_spec.rb @@ -18,7 +18,7 @@ RSpec.describe UnblockService do end end - describe 'remote' do + describe 'remote OStatus' do let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do @@ -28,7 +28,7 @@ RSpec.describe UnblockService do end it 'destroys the blocking relation' do - expect(sender.following?(bob)).to be false + expect(sender.blocking?(bob)).to be false end it 'sends an unblock salmon slap' do @@ -38,4 +38,22 @@ RSpec.describe UnblockService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + + before do + sender.block!(bob) + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + subject.call(sender, bob) + end + + it 'destroys the blocking relation' do + expect(sender.blocking?(bob)).to be false + end + + it 'sends an unblock activity' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end end diff --git a/spec/services/unfollow_service_spec.rb b/spec/services/unfollow_service_spec.rb index 8ec2148a1..29040431e 100644 --- a/spec/services/unfollow_service_spec.rb +++ b/spec/services/unfollow_service_spec.rb @@ -18,8 +18,8 @@ RSpec.describe UnfollowService do end end - describe 'remote' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } + describe 'remote OStatus' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :ostatus, domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do sender.follow!(bob) @@ -38,4 +38,22 @@ RSpec.describe UnfollowService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + + before do + sender.follow!(bob) + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + subject.call(sender, bob) + end + + it 'destroys the following relation' do + expect(sender.following?(bob)).to be false + end + + it 'sends an unfollow activity' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end end diff --git a/spec/workers/activitypub/delivery_worker_spec.rb b/spec/workers/activitypub/delivery_worker_spec.rb new file mode 100644 index 000000000..351be185c --- /dev/null +++ b/spec/workers/activitypub/delivery_worker_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ActivityPub::DeliveryWorker do + subject { described_class.new } + + let(:sender) { Fabricate(:account) } + let(:payload) { 'test' } + + describe 'perform' do + it 'performs a request' do + stub_request(:post, 'https://example.com/api').to_return(status: 200) + subject.perform(payload, sender.id, 'https://example.com/api') + expect(a_request(:post, 'https://example.com/api')).to have_been_made.once + end + + it 'raises when request fails' do + stub_request(:post, 'https://example.com/api').to_return(status: 500) + expect { subject.perform(payload, sender.id, 'https://example.com/api') }.to raise_error Mastodon::UnexpectedResponseError + end + end +end diff --git a/spec/workers/activitypub/distribution_worker_spec.rb b/spec/workers/activitypub/distribution_worker_spec.rb new file mode 100644 index 000000000..368ca025a --- /dev/null +++ b/spec/workers/activitypub/distribution_worker_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +describe ActivityPub::DistributionWorker do + subject { described_class.new } + + let(:status) { Fabricate(:status) } + let(:follower) { Fabricate(:account, protocol: :activitypub, inbox_url: 'http://example.com') } + + describe '#perform' do + before do + allow(ActivityPub::DeliveryWorker).to receive(:push_bulk) + follower.follow!(status.account) + end + + context 'with public status' do + before do + status.update(visibility: :public) + end + + it 'delivers to followers' do + subject.perform(status.id) + expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com']) + end + end + + context 'with private status' do + before do + status.update(visibility: :private) + end + + it 'delivers to followers' do + subject.perform(status.id) + expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com']) + end + end + + context 'with direct status' do + before do + status.update(visibility: :direct) + end + + it 'does nothing' do + subject.perform(status.id) + expect(ActivityPub::DeliveryWorker).to_not have_received(:push_bulk) + end + end + end +end diff --git a/spec/workers/activitypub/processing_worker_spec.rb b/spec/workers/activitypub/processing_worker_spec.rb new file mode 100644 index 000000000..b42c0bdbc --- /dev/null +++ b/spec/workers/activitypub/processing_worker_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +describe ActivityPub::ProcessingWorker do + subject { described_class.new } + + let(:account) { Fabricate(:account) } + + describe '#perform' do + it 'delegates to ActivityPub::ProcessCollectionService' do + allow(ActivityPub::ProcessCollectionService).to receive(:new).and_return(double(:service, call: nil)) + subject.perform(account.id, '') + expect(ActivityPub::ProcessCollectionService).to have_received(:new) + end + end +end diff --git a/spec/workers/activitypub/thread_resolve_worker_spec.rb b/spec/workers/activitypub/thread_resolve_worker_spec.rb new file mode 100644 index 000000000..b954cb62c --- /dev/null +++ b/spec/workers/activitypub/thread_resolve_worker_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +describe ActivityPub::ThreadResolveWorker do + subject { described_class.new } + + let(:status) { Fabricate(:status) } + let(:parent) { Fabricate(:status) } + + describe '#perform' do + it 'gets parent from ActivityPub::FetchRemoteStatusService and glues them together' do + allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(double(:service, call: parent)) + subject.perform(status.id, 'http://example.com/123') + expect(status.reload.in_reply_to_id).to eq parent.id + end + end +end diff --git a/spec/workers/activitypub/update_distribution_worker_spec.rb b/spec/workers/activitypub/update_distribution_worker_spec.rb new file mode 100644 index 000000000..688a424d5 --- /dev/null +++ b/spec/workers/activitypub/update_distribution_worker_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' + +describe ActivityPub::UpdateDistributionWorker do + subject { described_class.new } + + let(:account) { Fabricate(:account) } + let(:follower) { Fabricate(:account, protocol: :activitypub, inbox_url: 'http://example.com') } + + describe '#perform' do + before do + allow(ActivityPub::DeliveryWorker).to receive(:push_bulk) + follower.follow!(account) + end + + it 'delivers to followers' do + subject.perform(account.id) + expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com']) + end + end +end -- cgit From a855956185630742ad670f971337a3ff76fd8b32 Mon Sep 17 00:00:00 2001 From: unarist Date: Mon, 14 Aug 2017 23:57:46 +0900 Subject: Fix ActivityPub follow interaction and add more specs (#4601) --- app/lib/activitypub/activity/accept.rb | 2 +- app/lib/activitypub/activity/reject.rb | 2 +- spec/lib/activitypub/activity/accept_spec.rb | 38 ++++++++++++++++++++++++++++ spec/lib/activitypub/activity/follow_spec.rb | 29 ++++++++++++++++++--- spec/lib/activitypub/activity/reject_spec.rb | 38 ++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 spec/lib/activitypub/activity/accept_spec.rb create mode 100644 spec/lib/activitypub/activity/reject_spec.rb (limited to 'app/lib') diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb index f5880937a..44c432ae7 100644 --- a/app/lib/activitypub/activity/accept.rb +++ b/app/lib/activitypub/activity/accept.rb @@ -20,6 +20,6 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity end def target_uri - @target_uri ||= @object['object'].is_a?(String) ? @object['object'] : @object['object']['id'] + @target_uri ||= @object['actor'] end end diff --git a/app/lib/activitypub/activity/reject.rb b/app/lib/activitypub/activity/reject.rb index 78dbfd1e5..6a234994e 100644 --- a/app/lib/activitypub/activity/reject.rb +++ b/app/lib/activitypub/activity/reject.rb @@ -20,6 +20,6 @@ class ActivityPub::Activity::Reject < ActivityPub::Activity end def target_uri - @target_uri ||= @object['object'].is_a?(String) ? @object['object'] : @object['object']['id'] + @target_uri ||= @object['actor'] end end diff --git a/spec/lib/activitypub/activity/accept_spec.rb b/spec/lib/activitypub/activity/accept_spec.rb new file mode 100644 index 000000000..6503c83e3 --- /dev/null +++ b/spec/lib/activitypub/activity/accept_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Accept do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Accept', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: { + id: 'bar', + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(recipient), + object: ActivityPub::TagManager.instance.uri_for(sender), + }, + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + Fabricate(:follow_request, account: recipient, target_account: sender) + subject.perform + end + + it 'creates a follow relationship' do + expect(recipient.following?(sender)).to be true + end + + it 'removes the follow request' do + expect(recipient.requested?(sender)).to be false + end + end +end diff --git a/spec/lib/activitypub/activity/follow_spec.rb b/spec/lib/activitypub/activity/follow_spec.rb index 7c0e447f3..6bbacdbe6 100644 --- a/spec/lib/activitypub/activity/follow_spec.rb +++ b/spec/lib/activitypub/activity/follow_spec.rb @@ -17,12 +17,33 @@ RSpec.describe ActivityPub::Activity::Follow do describe '#perform' do subject { described_class.new(json, sender) } - before do - subject.perform + context 'unlocked account' do + before do + subject.perform + end + + it 'creates a follow from sender to recipient' do + expect(sender.following?(recipient)).to be true + end + + it 'does not create a follow request' do + expect(sender.requested?(recipient)).to be false + end end - it 'creates a follow from sender to recipient' do - expect(sender.following?(recipient)).to be true + context 'locked account' do + before do + recipient.update(locked: true) + subject.perform + end + + it 'does not create a follow from sender to recipient' do + expect(sender.following?(recipient)).to be false + end + + it 'creates a follow request' do + expect(sender.requested?(recipient)).to be true + end end end end diff --git a/spec/lib/activitypub/activity/reject_spec.rb b/spec/lib/activitypub/activity/reject_spec.rb new file mode 100644 index 000000000..7fd95bcc6 --- /dev/null +++ b/spec/lib/activitypub/activity/reject_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Reject do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Reject', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: { + id: 'bar', + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(recipient), + object: ActivityPub::TagManager.instance.uri_for(sender), + }, + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + Fabricate(:follow_request, account: recipient, target_account: sender) + subject.perform + end + + it 'does not create a follow relationship' do + expect(recipient.following?(sender)).to be false + end + + it 'removes the follow request' do + expect(recipient.requested?(sender)).to be false + end + end +end -- cgit From 4fcbb1f8385dbcb0ca0e3b6127b75c9485243367 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 14 Aug 2017 21:37:21 +0200 Subject: Re-add missing transaction around status-from-OStatus creation (#4603) --- app/lib/ostatus/activity/creation.rb | 40 ++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 18 deletions(-) (limited to 'app/lib') diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb index e22f746f2..6ec2cdd56 100644 --- a/app/lib/ostatus/activity/creation.rb +++ b/app/lib/ostatus/activity/creation.rb @@ -16,24 +16,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) : 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}" -- cgit From ad892dbc0c377d82b2cae349f189a41708a1e6af Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 17 Aug 2017 21:35:00 +0200 Subject: Add _:atomUri property for deduplicating OStatus/ActivityPub legacy records (#4593) --- app/lib/activitypub/activity/create.rb | 8 +++++++- app/serializers/activitypub/note_serializer.rb | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 4c4049bc6..de9c47d5c 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -4,7 +4,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def perform return if delete_arrived_first?(object_uri) || unsupported_object_type? - status = Status.find_by(uri: object_uri) + status = find_existing_status return status unless status.nil? @@ -23,6 +23,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity private + def find_existing_status + status = Status.find_by(uri: object_uri) + status ||= Status.find_by(uri: @object['_:atomUri']) if @object['_:atomUri'].present? + status + end + def status_params { uri: @object['id'], diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 4c13f8e59..bc8eb8a35 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -8,6 +8,8 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer has_many :media_attachments, key: :attachment has_many :virtual_tags, key: :tag + attribute :atom_uri, key: '_:atomUri', if: :local? + def id ActivityPub::TagManager.instance.uri_for(object) end @@ -52,6 +54,14 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer object.mentions + object.tags end + def atom_uri + ::TagManager.instance.uri_for(object) + end + + def local? + object.account.local? + end + class MediaAttachmentSerializer < ActiveModel::Serializer include RoutingHelper -- cgit From 40c45f5dd958aa1319b4e8cb664e6b4cac029526 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 18 Aug 2017 01:03:18 +0200 Subject: Put ActivityPub alternate link into Atom, prefer it when processing Atom (#4623) --- app/lib/activitypub/tag_manager.rb | 2 +- app/lib/ostatus/activity/base.rb | 14 +++++++++++++- app/lib/ostatus/activity/creation.rb | 5 +++++ app/lib/ostatus/activity/remote.rb | 6 +++++- app/lib/ostatus/atom_serializer.rb | 2 ++ spec/lib/ostatus/atom_serializer_spec.rb | 11 ++++++----- 6 files changed, 32 insertions(+), 8 deletions(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 96e610b6d..bd5dddcac 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -71,7 +71,7 @@ class ActivityPub::TagManager def local_uri?(uri) host = Addressable::URI.parse(uri).normalized_host - ::TagManager.instance.local_domain?(host) || ::TagManager.instance.web_domain?(host) + !host.nil? && (::TagManager.instance.local_domain?(host) || ::TagManager.instance.web_domain?(host)) end def uri_to_local_id(uri, param = :id) diff --git a/app/lib/ostatus/activity/base.rb b/app/lib/ostatus/activity/base.rb index e1477f0eb..da9a01759 100644 --- a/app/lib/ostatus/activity/base.rb +++ b/app/lib/ostatus/activity/base.rb @@ -29,16 +29,28 @@ 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) diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb index 6ec2cdd56..12488ab31 100644 --- a/app/lib/ostatus/activity/creation.rb +++ b/app/lib/ostatus/activity/creation.rb @@ -8,6 +8,7 @@ class OStatus::Activity::Creation < OStatus::Activity::Base end return [nil, false] if @account.suspended? + return perform_via_activitypub if activitypub_uri? Rails.logger.debug "Creating remote status #{id}" @@ -52,6 +53,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/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..92a16d228 100644 --- a/app/lib/ostatus/atom_serializer.rb +++ b/app/lib/ostatus/atom_serializer.rb @@ -343,6 +343,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/spec/lib/ostatus/atom_serializer_spec.rb b/spec/lib/ostatus/atom_serializer_spec.rb index b0cb8f019..301a0ce30 100644 --- a/spec/lib/ostatus/atom_serializer_spec.rb +++ b/spec/lib/ostatus/atom_serializer_spec.rb @@ -196,7 +196,7 @@ RSpec.describe OStatus::AtomSerializer do author = OStatus::AtomSerializer.new.author(account) - link = author.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' } + link = author.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' } expect(link[:type]).to eq 'text/html' expect(link[:rel]).to eq 'alternate' expect(link[:href]).to eq 'https://cb6e6126.ngrok.io/@username' @@ -407,6 +407,7 @@ RSpec.describe OStatus::AtomSerializer do remote_status.stream_entry.update!(created_at: '2000-01-01T00:00:00Z') entry = OStatus::AtomSerializer.new.entry(remote_status.stream_entry, true) + entry.nodes.delete_if { |node| node[:type] == 'application/activity+json' } # Remove ActivityPub link to simplify test xml = OStatus::AtomSerializer.render(entry).gsub('cb6e6126.ngrok.io', 'remote') remote_status.destroy! @@ -415,7 +416,7 @@ RSpec.describe OStatus::AtomSerializer do account = Account.create!( domain: 'remote', username: 'username', - last_webfingered_at: Time.now.utc, + last_webfingered_at: Time.now.utc ) ProcessFeedService.new.call(xml, account) @@ -529,7 +530,7 @@ RSpec.describe OStatus::AtomSerializer do entry = OStatus::AtomSerializer.new.entry(status.stream_entry) - link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' } + link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' } expect(link[:type]).to eq 'text/html' expect(link[:href]).to eq "https://cb6e6126.ngrok.io/users/username/updates/#{status.stream_entry.id}" end @@ -642,7 +643,7 @@ RSpec.describe OStatus::AtomSerializer do feed = OStatus::AtomSerializer.new.feed(account, []) - link = feed.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' } + link = feed.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' } expect(link[:type]).to eq 'text/html' expect(link[:href]).to eq 'https://cb6e6126.ngrok.io/@username' end @@ -1509,7 +1510,7 @@ RSpec.describe OStatus::AtomSerializer do entry = OStatus::AtomSerializer.new.object(status) - link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' } + link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' } expect(link[:type]).to eq 'text/html' expect(link[:href]).to eq "https://cb6e6126.ngrok.io/@username/#{status.id}" end -- cgit From 10489b4e4ab75c72f3fb49761cb7e08151752a6e Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 18 Aug 2017 02:29:12 +0200 Subject: If url attribute not present in Note, fallback to id attribute (#4629) --- app/lib/activitypub/activity/create.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index de9c47d5c..77d66fba3 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -32,7 +32,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def status_params { uri: @object['id'], - url: @object['url'], + url: @object['url'] || @object['id'], account: @account, text: text_from_content || '', language: language_from_content, -- cgit From 4edf9d849f5241aed42042577c889549a5b6beaa Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 18 Aug 2017 03:21:59 +0200 Subject: Make ActivityPub::TagManager#local_uri? recognize local URIs with ports (#4628) --- app/lib/activitypub/tag_manager.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index bd5dddcac..3d7f08cd3 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -70,7 +70,10 @@ class ActivityPub::TagManager end def local_uri?(uri) - host = Addressable::URI.parse(uri).normalized_host + 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 -- cgit From c7d2619ab1eeb096cea4bde0c35a45eea794c5b0 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 18 Aug 2017 11:24:44 +0200 Subject: Parse OStatus tag URIs in ActivityPub handlers when those are local (#4631) --- app/lib/activitypub/tag_manager.rb | 2 ++ 1 file changed, 2 insertions(+) (limited to 'app/lib') diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 3d7f08cd3..855881612 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -90,6 +90,8 @@ class ActivityPub::TagManager 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) end -- cgit From 412ea873060da4dc73236fdd63a2931d27dbfa40 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 19 Aug 2017 18:44:48 +0200 Subject: Improve ActivityPub/OStatus compatibility (#4632) *Note: OStatus URIs are invalid for ActivityPub. But we have them for as long as we want to keep old OStatus-sourced content and as long as we remain OStatus-compatible.* - In Announce handling, if object URI is not a URL, fallback to object URL - Do not use specialized ThreadResolveWorker, rely on generalized handling - When serializing notes, if parent's URI is not a URL, use parent's URL --- app/lib/activitypub/activity/announce.rb | 14 ++++++++++++-- app/lib/activitypub/activity/create.rb | 2 +- app/serializers/activitypub/note_serializer.rb | 8 +++++++- app/workers/activitypub/thread_resolve_worker.rb | 17 ----------------- spec/workers/activitypub/thread_resolve_worker_spec.rb | 16 ---------------- 5 files changed, 20 insertions(+), 37 deletions(-) delete mode 100644 app/workers/activitypub/thread_resolve_worker.rb delete mode 100644 spec/workers/activitypub/thread_resolve_worker_spec.rb (limited to 'app/lib') diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index decf8f960..09fec28a0 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -2,8 +2,8 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity def perform - original_status = status_from_uri(object_uri) - original_status = ActivityPub::FetchRemoteStatusService.new.call(object_uri) if original_status.nil? + original_status = status_from_uri(object_uri) + original_status ||= fetch_remote_original_status return if original_status.nil? || delete_arrived_first?(@json['id']) @@ -11,4 +11,14 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity 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/create.rb b/app/lib/activitypub/activity/create.rb index 77d66fba3..154125759 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -91,7 +91,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def resolve_thread(status) return unless status.reply? && status.thread.nil? - ActivityPub::ThreadResolveWorker.perform_async(status.id, @object['inReplyTo']) + ThreadResolveWorker.perform_async(status.id, @object['inReplyTo']) end def conversation_from_uri(uri) diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index bc8eb8a35..4061b9ce4 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -27,7 +27,13 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer end def in_reply_to - ActivityPub::TagManager.instance.uri_for(object.thread) if object.reply? + return unless object.reply? + + if object.thread.uri.nil? || object.thread.uri.start_with?('http') + ActivityPub::TagManager.instance.uri_for(object.thread) + else + object.thread.url + end end def published diff --git a/app/workers/activitypub/thread_resolve_worker.rb b/app/workers/activitypub/thread_resolve_worker.rb deleted file mode 100644 index 4ef762d06..000000000 --- a/app/workers/activitypub/thread_resolve_worker.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -class ActivityPub::ThreadResolveWorker - include Sidekiq::Worker - - sidekiq_options queue: 'pull', retry: false - - def perform(child_status_id, parent_uri) - child_status = Status.find(child_status_id) - parent_status = ActivityPub::FetchRemoteStatusService.new.call(parent_uri) - - return if parent_status.nil? - - child_status.thread = parent_status - child_status.save! - end -end diff --git a/spec/workers/activitypub/thread_resolve_worker_spec.rb b/spec/workers/activitypub/thread_resolve_worker_spec.rb deleted file mode 100644 index b954cb62c..000000000 --- a/spec/workers/activitypub/thread_resolve_worker_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -require 'rails_helper' - -describe ActivityPub::ThreadResolveWorker do - subject { described_class.new } - - let(:status) { Fabricate(:status) } - let(:parent) { Fabricate(:status) } - - describe '#perform' do - it 'gets parent from ActivityPub::FetchRemoteStatusService and glues them together' do - allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(double(:service, call: parent)) - subject.perform(status.id, 'http://example.com/123') - expect(status.reload.in_reply_to_id).to eq parent.id - end - end -end -- cgit From fe5b66aa0870212e27a6632fb9c83a2d16bd99ab Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 20 Aug 2017 16:53:47 +0200 Subject: Handle duplicate ActivityPub activities (#4639) * Handle duplicate ActivityPub activities Only perform side-effects when record processed for the first time * Fast-forward repeat follow requests --- app/lib/activitypub/activity/announce.rb | 4 ++++ app/lib/activitypub/activity/block.rb | 2 +- app/lib/activitypub/activity/follow.rb | 8 +++++++- app/lib/activitypub/activity/like.rb | 4 ++-- app/services/authorize_follow_service.rb | 11 ++++++++--- 5 files changed, 22 insertions(+), 7 deletions(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index 09fec28a0..c4da405c7 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -7,6 +7,10 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity 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 diff --git a/app/lib/activitypub/activity/block.rb b/app/lib/activitypub/activity/block.rb index e6b6c837b..f630d5db2 100644 --- a/app/lib/activitypub/activity/block.rb +++ b/app/lib/activitypub/activity/block.rb @@ -4,7 +4,7 @@ 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']) + 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) diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb index 3fb698d1d..8adbbb9c3 100644 --- a/app/lib/activitypub/activity/follow.rb +++ b/app/lib/activitypub/activity/follow.rb @@ -4,7 +4,13 @@ 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']) + 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) diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb index c24527597..674d5fe47 100644 --- a/app/lib/activitypub/activity/like.rb +++ b/app/lib/activitypub/activity/like.rb @@ -4,9 +4,9 @@ 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']) + return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status) - favourite = original_status.favourites.where(account: @account).first_or_create!(account: @account) + favourite = original_status.favourites.create!(account: @account) NotifyService.new.call(original_status.account, favourite) end end diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb index db35b6030..6f036dc5a 100644 --- a/app/services/authorize_follow_service.rb +++ b/app/services/authorize_follow_service.rb @@ -1,9 +1,14 @@ # frozen_string_literal: true class AuthorizeFollowService < BaseService - def call(source_account, target_account) - follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account) - follow_request.authorize! + def call(source_account, target_account, options = {}) + if options[:skip_follow_request] + follow_request = FollowRequest.new(account: source_account, target_account: target_account) + else + follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account) + follow_request.authorize! + end + create_notification(follow_request) unless source_account.local? follow_request end -- cgit From 72bb3e03fdf4d8c886d41f3459000b336a3a362b Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 21 Aug 2017 22:57:34 +0200 Subject: Support more variations of ActivityPub keyId in signature (#4630) - Tries to avoid performing HTTP request if the keyId is an actor URI - Likewise if the URI is a fragment URI on top of actor URI - Resolves public key, returns owner if the owner links back to the key --- app/controllers/concerns/signature_verification.rb | 4 +- app/helpers/jsonld_helper.rb | 6 ++- app/lib/activitypub/activity.rb | 2 +- app/lib/activitypub/activity/accept.rb | 2 +- app/lib/activitypub/activity/reject.rb | 2 +- app/lib/activitypub/activity/undo.rb | 2 +- app/lib/activitypub/tag_manager.rb | 2 +- .../activitypub/fetch_remote_key_service.rb | 47 ++++++++++++++++++++++ 8 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 app/services/activitypub/fetch_remote_key_service.rb (limited to 'app/lib') diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index aeb8da879..4211283ed 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -98,7 +98,9 @@ module SignatureVerification if key_id.start_with?('acct:') ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, '')) elsif !ActivityPub::TagManager.instance.local_uri?(key_id) - ActivityPub::FetchRemoteAccountService.new.call(key_id) + account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account) + account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id) + account end end end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index c750a7038..d8b3ddf18 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -9,6 +9,10 @@ module JsonLdHelper value.is_a?(Array) ? value.first : value end + def value_or_id(value) + value.is_a?(String) ? value : value['id'] + end + def supported_context?(json) equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) end @@ -20,7 +24,7 @@ module JsonLdHelper end def body_to_json(body) - body.nil? ? nil : Oj.load(body, mode: :strict) + body.is_a?(String) ? Oj.load(body, mode: :strict) : body rescue Oj::ParseError nil end diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index f8de8060c..14e3ca784 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -58,7 +58,7 @@ class ActivityPub::Activity end def object_uri - @object_uri ||= @object.is_a?(String) ? @object : @object['id'] + @object_uri ||= value_or_id(@object) end def redis diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb index 44c432ae7..bd90c9019 100644 --- a/app/lib/activitypub/activity/accept.rb +++ b/app/lib/activitypub/activity/accept.rb @@ -20,6 +20,6 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity end def target_uri - @target_uri ||= @object['actor'] + @target_uri ||= value_or_id(@object['actor']) end end diff --git a/app/lib/activitypub/activity/reject.rb b/app/lib/activitypub/activity/reject.rb index 6a234994e..d815feeb6 100644 --- a/app/lib/activitypub/activity/reject.rb +++ b/app/lib/activitypub/activity/reject.rb @@ -20,6 +20,6 @@ class ActivityPub::Activity::Reject < ActivityPub::Activity end def target_uri - @target_uri ||= @object['actor'] + @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 index 078e97ed4..097b1dba4 100644 --- a/app/lib/activitypub/activity/undo.rb +++ b/app/lib/activitypub/activity/undo.rb @@ -64,6 +64,6 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity end def target_uri - @target_uri ||= @object['object'].is_a?(String) ? @object['object'] : @object['object']['id'] + @target_uri ||= value_or_id(@object['object']) end end diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 855881612..3c16006cb 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -93,7 +93,7 @@ class ActivityPub::TagManager 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) + klass.find_by(uri: uri.split('#').first) end end end diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb new file mode 100644 index 000000000..ebd64071e --- /dev/null +++ b/app/services/activitypub/fetch_remote_key_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRemoteKeyService < BaseService + include JsonLdHelper + + # Returns account that owns the key + def call(uri, prefetched_json = nil) + @json = body_to_json(prefetched_json) || fetch_resource(uri) + + return unless supported_context?(@json) && expected_type? + return find_account(uri, @json) if person? + + @owner = fetch_resource(owner_uri) + + return unless supported_context?(@owner) && confirmed_owner? + + find_account(owner_uri, @owner) + end + + private + + def find_account(uri, prefetched_json) + account = ActivityPub::TagManager.instance.uri_to_resource(uri, Account) + account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_json) + account + end + + def expected_type? + person? || public_key? + end + + def person? + @json['type'] == 'Person' + end + + def public_key? + @json['publicKeyPem'].present? && @json['owner'].present? + end + + def owner_uri + @owner_uri ||= value_or_id(@json['owner']) + end + + def confirmed_owner? + @owner['type'] == 'Person' && value_or_id(@owner['publicKey']) == @json['id'] + end +end -- cgit From d63de55ef84eea883b72a121d680b8841af8e2c0 Mon Sep 17 00:00:00 2001 From: unarist Date: Wed, 23 Aug 2017 01:30:15 +0900 Subject: Fix bugs which OStatus accounts may detected as ActivityPub ready (#4662) * Fallback to OStatus in FetchAtomService * Skip activity+json link if that activity is Person without inbox * If unsupported activity was detected and all other URLs failed, retry with ActivityPub-less Accept header * Allow mention to OStatus account in ActivityPub * Don't update profile with inbox-less Person object --- app/lib/activitypub/activity/create.rb | 2 +- .../activitypub/process_account_service.rb | 2 + app/services/fetch_atom_service.rb | 60 +++++++++++++--------- .../fetch_remote_account_service_spec.rb | 27 ++++++++++ 4 files changed, 67 insertions(+), 24 deletions(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 154125759..5c59c4b24 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -68,7 +68,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def process_mention(tag, status) account = account_from_uri(tag['href']) - account = ActivityPub::FetchRemoteAccountService.new.call(tag['href']) if account.nil? + account = FetchRemoteAccountService.new.call(tag['href']) if account.nil? return if account.nil? account.mentions.create(status: status) end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 2f2dfd330..99f9dbdc2 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -6,6 +6,8 @@ class ActivityPub::ProcessAccountService < BaseService # Should be called with confirmed valid JSON # and WebFinger-resolved username and domain def call(username, domain, json) + return unless json['inbox'].present? + @json = json @uri = @json['id'] @username = username diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb index c6a4dc2e9..3cf39e006 100644 --- a/app/services/fetch_atom_service.rb +++ b/app/services/fetch_atom_service.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true class FetchAtomService < BaseService + include JsonLdHelper + def call(url) return if url.blank? - @url = url + result = process(url) - perform_request - process_response + # retry without ActivityPub + result ||= process(url) if @unsupported_activity + + result rescue OpenSSL::SSL::SSLError => e Rails.logger.debug "SSL error: #{e}" nil @@ -18,9 +22,18 @@ class FetchAtomService < BaseService private + def process(url, terminal = false) + @url = url + perform_request + process_response(terminal) + end + def perform_request + accept = 'text/html' + accept = 'application/activity+json, application/ld+json, application/atom+xml, ' + accept unless @unsupported_activity + @response = Request.new(:get, @url) - .add_headers('Accept' => 'application/activity+json, application/ld+json, application/atom+xml, text/html') + .add_headers('Accept' => accept) .perform end @@ -30,7 +43,12 @@ class FetchAtomService < BaseService if @response.mime_type == 'application/atom+xml' [@url, @response.to_s, :ostatus] elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type) - [@url, @response.to_s, :activitypub] + if supported_activity?(@response.to_s) + [@url, @response.to_s, :activitypub] + else + @unsupported_activity = true + nil + end elsif @response['Link'] && !terminal process_headers elsif @response.mime_type == 'text/html' && !terminal @@ -44,15 +62,10 @@ class FetchAtomService < BaseService json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) } atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' } - if !json_link.nil? - @url = json_link['href'] - perform_request - process_response(true) - elsif !atom_link.nil? - @url = atom_link['href'] - perform_request - process_response(true) - end + result ||= process(json_link.href, terminal: true) unless json_link.nil? || @unsupported_activity + result ||= process(atom_link.href, terminal: true) unless atom_link.nil? + + result end def process_headers @@ -61,14 +74,15 @@ class FetchAtomService < BaseService json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"']) atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml)) - if !json_link.nil? - @url = json_link.href - perform_request - process_response(true) - elsif !atom_link.nil? - @url = atom_link.href - perform_request - process_response(true) - end + result ||= process(json_link.href, terminal: true) unless json_link.nil? || @unsupported_activity + result ||= process(atom_link.href, terminal: true) unless atom_link.nil? + + result + end + + def supported_activity?(body) + json = body_to_json(body) + return false if json.nil? || !supported_context?(json) + json['type'] == 'Person' ? json['inbox'].present? : true end end diff --git a/spec/services/activitypub/fetch_remote_account_service_spec.rb b/spec/services/activitypub/fetch_remote_account_service_spec.rb index 786d7f7f2..391d051c1 100644 --- a/spec/services/activitypub/fetch_remote_account_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_account_service_spec.rb @@ -11,6 +11,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService do preferredUsername: 'alice', name: 'Alice', summary: 'Foo bar', + inbox: 'http://example.com/alice/inbox', } end @@ -35,6 +36,32 @@ RSpec.describe ActivityPub::FetchRemoteAccountService do end end + context 'when the account does not have a inbox' do + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + + before do + actor[:inbox] = nil + + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'fetches resource' do + account + expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once + end + + it 'looks up webfinger' do + account + expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once + end + + it 'returns nil' do + expect(account).to be_nil + end + + end + context 'when URI and WebFinger share the same host' do let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } -- cgit From b01a19fe392e0dd16d6b3da3f0b56369f7837cc9 Mon Sep 17 00:00:00 2001 From: unarist Date: Thu, 24 Aug 2017 23:21:42 +0900 Subject: Fetch reblogs as Announce activity instead of Note object (#4672) * Process Create / Announce activity in FetchRemoteStatusService * Use activity URL in ActivityPub for reblogs * Redirect to the original status on StatusesController#show --- app/controllers/statuses_controller.rb | 5 ++ app/lib/activitypub/tag_manager.rb | 8 +++ app/serializers/activitypub/activity_serializer.rb | 2 +- .../activitypub/fetch_remote_status_service.rb | 30 ++++++--- spec/controllers/statuses_controller_spec.rb | 12 ++++ .../fetch_remote_status_service_spec.rb | 72 +++++++++++++++++++++- 6 files changed, 118 insertions(+), 11 deletions(-) (limited to 'app/lib') diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index aa24f23c9..a9768d092 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -9,6 +9,7 @@ class StatusesController < ApplicationController before_action :set_status before_action :set_link_headers before_action :check_account_suspension + before_action :redirect_to_original, only: [:show] def show respond_to do |format| @@ -58,4 +59,8 @@ class StatusesController < ApplicationController def check_account_suspension gone if @account.suspended? end + + def redirect_to_original + redirect_to ::TagManager.instance.url_for(@status.reblog) if @status.reblog? + end end diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 3c16006cb..de575d9e6 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -19,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 @@ -30,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 diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb index 69e2160c5..d20ee9920 100644 --- a/app/serializers/activitypub/activity_serializer.rb +++ b/app/serializers/activitypub/activity_serializer.rb @@ -6,7 +6,7 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer def id - [ActivityPub::TagManager.instance.uri_for(object), '/activity'].join + [ActivityPub::TagManager.instance.activity_uri_for(object)].join end def type diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb index 993e5389c..c114515cd 100644 --- a/app/services/activitypub/fetch_remote_status_service.rb +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -7,21 +7,33 @@ class ActivityPub::FetchRemoteStatusService < BaseService def call(uri, prefetched_json = nil) @json = body_to_json(prefetched_json) || fetch_resource(uri) - return unless supported_context? && expected_type? + return unless supported_context? - attributed_to = first_of_value(@json['attributedTo']) - attributed_to = attributed_to['id'] if attributed_to.is_a?(Hash) + activity = activity_json + actor_id = value_or_id(activity['actor']) - return unless trustworthy_attribution?(uri, attributed_to) + return unless expected_type?(activity) && trustworthy_attribution?(uri, actor_id) - actor = ActivityPub::TagManager.instance.uri_to_resource(attributed_to, Account) - actor = ActivityPub::FetchRemoteAccountService.new.call(attributed_to) if actor.nil? + actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account) + actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id) if actor.nil? - ActivityPub::Activity::Create.new({ 'object' => @json }, actor).perform + ActivityPub::Activity.factory(activity, actor).perform end private + def activity_json + if %w(Note Article).include? @json['type'] + { + 'type' => 'Create', + 'actor' => first_of_value(@json['attributedTo']), + 'object' => @json, + } + else + @json + end + end + def trustworthy_attribution?(uri, attributed_to) Addressable::URI.parse(uri).normalized_host.casecmp(Addressable::URI.parse(attributed_to).normalized_host).zero? end @@ -30,7 +42,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService super(@json) end - def expected_type? - %w(Note Article).include? @json['type'] + def expected_type?(json) + %w(Create Announce).include? json['type'] end end diff --git a/spec/controllers/statuses_controller_spec.rb b/spec/controllers/statuses_controller_spec.rb index 88d365624..95fb4d594 100644 --- a/spec/controllers/statuses_controller_spec.rb +++ b/spec/controllers/statuses_controller_spec.rb @@ -30,6 +30,18 @@ describe StatusesController do end end + context 'status is a reblog' do + it 'redirects to the original status' do + original_account = Fabricate(:account, domain: 'example.com') + original_status = Fabricate(:status, account: original_account, uri: 'tag:example.com,2017:foo', url: 'https://example.com/123') + status = Fabricate(:status, reblog: original_status) + + get :show, params: { account_username: status.account.username, id: status.id } + + expect(response).to redirect_to(original_status.url) + end + end + context 'account is not suspended and status is permitted' do it 'assigns @account' do status = Fabricate(:status) diff --git a/spec/services/activitypub/fetch_remote_status_service_spec.rb b/spec/services/activitypub/fetch_remote_status_service_spec.rb index 47a33b6cb..3b22257ed 100644 --- a/spec/services/activitypub/fetch_remote_status_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_status_service_spec.rb @@ -1,5 +1,75 @@ require 'rails_helper' RSpec.describe ActivityPub::FetchRemoteStatusService do - pending + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + let(:valid_domain) { Rails.configuration.x.local_domain } + + let(:note) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: "https://#{valid_domain}/@foo/1234", + type: 'Note', + content: 'Lorem ipsum', + attributedTo: ActivityPub::TagManager.instance.uri_for(sender), + } + end + + let(:create) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: "https://#{valid_domain}/@foo/1234/activity", + type: 'Create', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: note, + } + end + + subject { described_class.new } + + describe '#call' do + before do + subject.call(object[:id], Oj.dump(object)) + end + + context 'with Note object' do + let(:object) { note } + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + end + end + + context 'with Create activity' do + let(:object) { create } + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + end + end + + context 'with Announce activity' do + let(:status) { Fabricate(:status, account: recipient) } + + let(:object) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: "https://#{valid_domain}/@foo/1234/activity", + type: 'Announce', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + } + end + + it 'creates a reblog by sender of status' do + expect(sender.reblogged?(status)).to be true + end + end + end end -- cgit From 00840f4f2edb8d1d46638ccbc90a1f4462d0867a Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 26 Aug 2017 13:47:38 +0200 Subject: Add handling of Linked Data Signatures in payloads (#4687) * Add handling of Linked Data Signatures in payloads * Add a way to sign JSON, fix canonicalization of signature options * Fix signatureValue encoding, send out signed JSON when distributing * Add missing security context --- .rubocop.yml | 1 + Gemfile | 3 + Gemfile.lock | 16 ++++ app/helpers/jsonld_helper.rb | 13 ++++ app/lib/activitypub/adapter.rb | 2 +- app/lib/activitypub/linked_data_signature.rb | 56 ++++++++++++++ .../activitypub/process_collection_service.rb | 11 +++ app/services/authorize_follow_service.rb | 4 +- app/services/batched_remove_status_service.rb | 8 +- app/services/block_service.rb | 4 +- app/services/favourite_service.rb | 4 +- app/services/follow_service.rb | 4 +- app/services/process_mentions_service.rb | 4 +- app/services/reblog_service.rb | 4 +- app/services/reject_follow_service.rb | 4 +- app/services/remove_status_service.rb | 10 ++- app/services/unblock_service.rb | 4 +- app/services/unfavourite_service.rb | 4 +- app/services/unfollow_service.rb | 4 +- app/workers/activitypub/distribution_worker.rb | 8 +- config/initializers/json_ld.rb | 4 + lib/json_ld/identity.rb | 86 ++++++++++++++++++++++ lib/json_ld/security.rb | 50 +++++++++++++ spec/lib/activitypub/linked_data_signature_spec.rb | 86 ++++++++++++++++++++++ .../activitypub/process_collection_service_spec.rb | 5 +- 25 files changed, 369 insertions(+), 30 deletions(-) create mode 100644 app/lib/activitypub/linked_data_signature.rb create mode 100644 config/initializers/json_ld.rb create mode 100644 lib/json_ld/identity.rb create mode 100644 lib/json_ld/security.rb create mode 100644 spec/lib/activitypub/linked_data_signature_spec.rb (limited to 'app/lib') diff --git a/.rubocop.yml b/.rubocop.yml index ae3697174..a36aa5cae 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -10,6 +10,7 @@ AllCops: - 'node_modules/**/*' - 'Vagrantfile' - 'vendor/**/*' + - 'lib/json_ld/*' Bundler/OrderedGems: Enabled: false diff --git a/Gemfile b/Gemfile index 52ac43b9a..ae90697f1 100644 --- a/Gemfile +++ b/Gemfile @@ -68,6 +68,9 @@ gem 'tzinfo-data', '~> 1.2017' gem 'webpacker', '~> 2.0' gem 'webpush' +gem 'json-ld-preloaded', '~> 2.2.1' +gem 'rdf-normalize', '~> 0.3.1' + group :development, :test do gem 'fabrication', '~> 2.16' gem 'fuubar', '~> 2.2' diff --git a/Gemfile.lock b/Gemfile.lock index adc37f7de..cd4573637 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -179,6 +179,8 @@ GEM activesupport (>= 4.0.1) hamlit (>= 1.2.0) railties (>= 4.0.1) + hamster (3.0.0) + concurrent-ruby (~> 1.0) hashdiff (0.3.5) highline (1.7.8) hiredis (0.6.1) @@ -211,6 +213,13 @@ GEM idn-ruby (0.1.0) jmespath (1.3.1) json (2.1.0) + json-ld (2.1.5) + multi_json (~> 1.12) + rdf (~> 2.2) + json-ld-preloaded (2.2.1) + json-ld (~> 2.1, >= 2.1.5) + multi_json (~> 1.11) + rdf (~> 2.2) jsonapi-renderer (0.1.3) jwt (1.5.6) kaminari (1.0.1) @@ -348,6 +357,11 @@ GEM rainbow (2.2.2) rake rake (12.0.0) + rdf (2.2.8) + hamster (~> 3.0) + link_header (~> 0.0, >= 0.0.8) + rdf-normalize (0.3.2) + rdf (~> 2.0) redis (3.3.3) redis-actionpack (5.0.1) actionpack (>= 4.0, < 6) @@ -531,6 +545,7 @@ DEPENDENCIES httplog (~> 0.99) i18n-tasks (~> 0.9) idn-ruby + json-ld-preloaded (~> 2.2.1) kaminari (~> 1.0) letter_opener (~> 1.4) letter_opener_web (~> 1.3) @@ -560,6 +575,7 @@ DEPENDENCIES rails-controller-testing (~> 1.0) rails-i18n (~> 5.0) rails-settings-cached (~> 0.6) + rdf-normalize (~> 0.3.1) redis (~> 3.3) redis-namespace (~> 1.5) redis-rails (~> 5.0) diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 8355eb055..09446c8be 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -17,6 +17,11 @@ module JsonLdHelper !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) end + def canonicalize(json) + graph = RDF::Graph.new << JSON::LD::API.toRdf(json) + graph.dump(:normalize) + end + def fetch_resource(uri) response = build_request(uri).perform return if response.code != 200 @@ -29,6 +34,14 @@ module JsonLdHelper nil end + def merge_context(context, new_context) + if context.is_a?(Array) + context << new_context + else + [context, new_context] + end + end + private def build_request(uri) diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index df132f019..92210579e 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -11,7 +11,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base def serializable_hash(options = nil) options = serialization_options(options) - serialized_hash = { '@context': ActivityPub::TagManager::CONTEXT }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options)) + serialized_hash = { '@context': [ActivityPub::TagManager::CONTEXT, 'https://w3id.org/security/v1'] }.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/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb new file mode 100644 index 000000000..7173aed19 --- /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 + 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('@context' => merge_context(@json['@context'], CONTEXT), 'signature' => options.merge('signatureValue' => signature)) + end + + private + + def hash(obj) + Digest::SHA256.hexdigest(canonicalize(obj)) + end +end diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb index cd861c075..2cf15553d 100644 --- a/app/services/activitypub/process_collection_service.rb +++ b/app/services/activitypub/process_collection_service.rb @@ -9,6 +9,8 @@ class ActivityPub::ProcessCollectionService < BaseService return if @account.suspended? || !supported_context? + verify_account! if different_actor? + case @json['type'] when 'Collection', 'CollectionPage' process_items @json['items'] @@ -23,6 +25,10 @@ class ActivityPub::ProcessCollectionService < BaseService private + def different_actor? + @json['actor'].present? && value_or_id(@json['actor']) != @account.uri && @json['signature'].present? + end + def process_items(items) items.reverse_each.map { |item| process_item(item) }.compact end @@ -35,4 +41,9 @@ class ActivityPub::ProcessCollectionService < BaseService activity = ActivityPub::Activity.factory(item, @account) activity&.perform end + + def verify_account! + account = ActivityPub::LinkedDataSignature.new(@json).verify_account! + @account = account unless account.nil? + end end diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb index 6f036dc5a..b1bff8962 100644 --- a/app/services/authorize_follow_service.rb +++ b/app/services/authorize_follow_service.rb @@ -24,11 +24,11 @@ class AuthorizeFollowService < BaseService end def build_json(follow_request) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( follow_request, serializer: ActivityPub::AcceptFollowSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(follow_request.target_account)) end def build_xml(follow_request) diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index e6c8c9208..c90f4401d 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -138,10 +138,14 @@ class BatchedRemoveStatusService < BaseService def build_json(status) return @activity_json[status.id] if @activity_json.key?(status.id) - @activity_json[status.id] = ActiveModelSerializers::SerializableResource.new( + @activity_json[status.id] = sign_json(status, ActiveModelSerializers::SerializableResource.new( status, serializer: ActivityPub::DeleteSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json) + end + + def sign_json(status, json) + Oj.dump(ActivityPub::LinkedDataSignature.new(json).sign!(status.account)) end end diff --git a/app/services/block_service.rb b/app/services/block_service.rb index f2253226b..b39c3eef2 100644 --- a/app/services/block_service.rb +++ b/app/services/block_service.rb @@ -27,11 +27,11 @@ class BlockService < BaseService end def build_json(block) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( block, serializer: ActivityPub::BlockSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(block.account)) end def build_xml(block) diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index 4aa935170..44df3ed13 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -34,11 +34,11 @@ class FavouriteService < BaseService end def build_json(favourite) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( favourite, serializer: ActivityPub::LikeSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(favourite.account)) end def build_xml(favourite) diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 2be625cd8..a92eb6b88 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -67,10 +67,10 @@ class FollowService < BaseService end def build_json(follow_request) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( follow_request, serializer: ActivityPub::FollowSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(follow_request.account)) end end diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 2b8a77147..f123bf869 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -47,11 +47,11 @@ class ProcessMentionsService < BaseService end def build_json(status) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(status.account)) end def follow_remote_account_service diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 7f886af7c..5ed16c64b 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -42,10 +42,10 @@ class ReblogService < BaseService end def build_json(reblog) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( reblog, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(reblog.account)) end end diff --git a/app/services/reject_follow_service.rb b/app/services/reject_follow_service.rb index a91266aa4..c1f7bcb60 100644 --- a/app/services/reject_follow_service.rb +++ b/app/services/reject_follow_service.rb @@ -19,11 +19,11 @@ class RejectFollowService < BaseService end def build_json(follow_request) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( follow_request, serializer: ActivityPub::RejectFollowSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(follow_request.target_account)) end def build_xml(follow_request) diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index fcccbaa24..62eea677f 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -56,7 +56,7 @@ class RemoveStatusService < BaseService # ActivityPub ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:inbox_url)) do |inbox_url| - [activity_json, @account.id, inbox_url] + [signed_activity_json, @account.id, inbox_url] end end @@ -66,7 +66,7 @@ class RemoveStatusService < BaseService # ActivityPub ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url| - [activity_json, @account.id, inbox_url] + [signed_activity_json, @account.id, inbox_url] end end @@ -74,12 +74,16 @@ class RemoveStatusService < BaseService @salmon_xml ||= stream_entry_to_xml(@stream_entry) end + def signed_activity_json + @signed_activity_json ||= Oj.dump(ActivityPub::LinkedDataSignature.new(activity_json).sign!(@account)) + end + def activity_json @activity_json ||= ActiveModelSerializers::SerializableResource.new( @status, serializer: ActivityPub::DeleteSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json end def remove_reblogs diff --git a/app/services/unblock_service.rb b/app/services/unblock_service.rb index 72fc5ab15..869f62d1c 100644 --- a/app/services/unblock_service.rb +++ b/app/services/unblock_service.rb @@ -20,11 +20,11 @@ class UnblockService < BaseService end def build_json(unblock) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( unblock, serializer: ActivityPub::UndoBlockSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(unblock.account)) end def build_xml(block) diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb index e53798e66..2fda11bd6 100644 --- a/app/services/unfavourite_service.rb +++ b/app/services/unfavourite_service.rb @@ -21,11 +21,11 @@ class UnfavouriteService < BaseService end def build_json(favourite) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( favourite, serializer: ActivityPub::UndoLikeSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(favourite.account)) end def build_xml(favourite) diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index 10af75146..bf151ee28 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -23,11 +23,11 @@ class UnfollowService < BaseService end def build_json(follow) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( follow, serializer: ActivityPub::UndoFollowSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(follow.account)) end def build_xml(follow) diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb index 004dd25d1..14bb933c0 100644 --- a/app/workers/activitypub/distribution_worker.rb +++ b/app/workers/activitypub/distribution_worker.rb @@ -12,7 +12,7 @@ class ActivityPub::DistributionWorker return if skip_distribution? ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| - [payload, @account.id, inbox_url] + [signed_payload, @account.id, inbox_url] end rescue ActiveRecord::RecordNotFound true @@ -28,11 +28,15 @@ class ActivityPub::DistributionWorker @inboxes ||= @account.followers.inboxes end + def signed_payload + @signed_payload ||= Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@account)) + end + def payload @payload ||= ActiveModelSerializers::SerializableResource.new( @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json end end diff --git a/config/initializers/json_ld.rb b/config/initializers/json_ld.rb new file mode 100644 index 000000000..408e6490d --- /dev/null +++ b/config/initializers/json_ld.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative '../../lib/json_ld/identity' +require_relative '../../lib/json_ld/security' diff --git a/lib/json_ld/identity.rb b/lib/json_ld/identity.rb new file mode 100644 index 000000000..cfe50b956 --- /dev/null +++ b/lib/json_ld/identity.rb @@ -0,0 +1,86 @@ +# -*- encoding: utf-8 -*- +# frozen_string_literal: true +# This file generated automatically from https://w3id.org/identity/v1 +require 'json/ld' +class JSON::LD::Context + add_preloaded("https://w3id.org/identity/v1") do + new(processingMode: "json-ld-1.0", term_definitions: { + "Credential" => TermDefinition.new("Credential", id: "https://w3id.org/credentials#Credential", simple: true), + "CryptographicKey" => TermDefinition.new("CryptographicKey", id: "https://w3id.org/security#Key", simple: true), + "CryptographicKeyCredential" => TermDefinition.new("CryptographicKeyCredential", id: "https://w3id.org/credentials#CryptographicKeyCredential", simple: true), + "EncryptedMessage" => TermDefinition.new("EncryptedMessage", id: "https://w3id.org/security#EncryptedMessage", simple: true), + "GraphSignature2012" => TermDefinition.new("GraphSignature2012", id: "https://w3id.org/security#GraphSignature2012", simple: true), + "Group" => TermDefinition.new("Group", id: "https://www.w3.org/ns/activitystreams#Group", simple: true), + "Identity" => TermDefinition.new("Identity", id: "https://w3id.org/identity#Identity", simple: true), + "LinkedDataSignature2015" => TermDefinition.new("LinkedDataSignature2015", id: "https://w3id.org/security#LinkedDataSignature2015", simple: true), + "Organization" => TermDefinition.new("Organization", id: "http://schema.org/Organization", simple: true), + "Person" => TermDefinition.new("Person", id: "http://schema.org/Person", simple: true), + "PostalAddress" => TermDefinition.new("PostalAddress", id: "http://schema.org/PostalAddress", simple: true), + "about" => TermDefinition.new("about", id: "http://schema.org/about", type_mapping: "@id"), + "accessControl" => TermDefinition.new("accessControl", id: "https://w3id.org/permissions#accessControl", type_mapping: "@id"), + "address" => TermDefinition.new("address", id: "http://schema.org/address", type_mapping: "@id"), + "addressCountry" => TermDefinition.new("addressCountry", id: "http://schema.org/addressCountry", simple: true), + "addressLocality" => TermDefinition.new("addressLocality", id: "http://schema.org/addressLocality", simple: true), + "addressRegion" => TermDefinition.new("addressRegion", id: "http://schema.org/addressRegion", simple: true), + "cipherAlgorithm" => TermDefinition.new("cipherAlgorithm", id: "https://w3id.org/security#cipherAlgorithm", simple: true), + "cipherData" => TermDefinition.new("cipherData", id: "https://w3id.org/security#cipherData", simple: true), + "cipherKey" => TermDefinition.new("cipherKey", id: "https://w3id.org/security#cipherKey", simple: true), + "claim" => TermDefinition.new("claim", id: "https://w3id.org/credentials#claim", type_mapping: "@id"), + "comment" => TermDefinition.new("comment", id: "http://www.w3.org/2000/01/rdf-schema#comment", simple: true), + "created" => TermDefinition.new("created", id: "http://purl.org/dc/terms/created", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "creator" => TermDefinition.new("creator", id: "http://purl.org/dc/terms/creator", type_mapping: "@id"), + "cred" => TermDefinition.new("cred", id: "https://w3id.org/credentials#", simple: true, prefix: true), + "credential" => TermDefinition.new("credential", id: "https://w3id.org/credentials#credential", type_mapping: "@id"), + "dc" => TermDefinition.new("dc", id: "http://purl.org/dc/terms/", simple: true, prefix: true), + "description" => TermDefinition.new("description", id: "http://schema.org/description", simple: true), + "digestAlgorithm" => TermDefinition.new("digestAlgorithm", id: "https://w3id.org/security#digestAlgorithm", simple: true), + "digestValue" => TermDefinition.new("digestValue", id: "https://w3id.org/security#digestValue", simple: true), + "domain" => TermDefinition.new("domain", id: "https://w3id.org/security#domain", simple: true), + "email" => TermDefinition.new("email", id: "http://schema.org/email", simple: true), + "expires" => TermDefinition.new("expires", id: "https://w3id.org/security#expiration", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "familyName" => TermDefinition.new("familyName", id: "http://schema.org/familyName", simple: true), + "givenName" => TermDefinition.new("givenName", id: "http://schema.org/givenName", simple: true), + "id" => TermDefinition.new("id", id: "@id", simple: true), + "identity" => TermDefinition.new("identity", id: "https://w3id.org/identity#", simple: true, prefix: true), + "identityService" => TermDefinition.new("identityService", id: "https://w3id.org/identity#identityService", type_mapping: "@id"), + "idp" => TermDefinition.new("idp", id: "https://w3id.org/identity#idp", type_mapping: "@id"), + "image" => TermDefinition.new("image", id: "http://schema.org/image", type_mapping: "@id"), + "initializationVector" => TermDefinition.new("initializationVector", id: "https://w3id.org/security#initializationVector", simple: true), + "issued" => TermDefinition.new("issued", id: "https://w3id.org/credentials#issued", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "issuer" => TermDefinition.new("issuer", id: "https://w3id.org/credentials#issuer", type_mapping: "@id"), + "label" => TermDefinition.new("label", id: "http://www.w3.org/2000/01/rdf-schema#label", simple: true), + "member" => TermDefinition.new("member", id: "http://schema.org/member", type_mapping: "@id"), + "memberOf" => TermDefinition.new("memberOf", id: "http://schema.org/memberOf", type_mapping: "@id"), + "name" => TermDefinition.new("name", id: "http://schema.org/name", simple: true), + "nonce" => TermDefinition.new("nonce", id: "https://w3id.org/security#nonce", simple: true), + "normalizationAlgorithm" => TermDefinition.new("normalizationAlgorithm", id: "https://w3id.org/security#normalizationAlgorithm", simple: true), + "owner" => TermDefinition.new("owner", id: "https://w3id.org/security#owner", type_mapping: "@id"), + "password" => TermDefinition.new("password", id: "https://w3id.org/security#password", simple: true), + "paymentProcessor" => TermDefinition.new("paymentProcessor", id: "https://w3id.org/payswarm#processor", simple: true), + "perm" => TermDefinition.new("perm", id: "https://w3id.org/permissions#", simple: true, prefix: true), + "postalCode" => TermDefinition.new("postalCode", id: "http://schema.org/postalCode", simple: true), + "preferences" => TermDefinition.new("preferences", id: "https://w3id.org/payswarm#preferences", type_mapping: "@vocab"), + "privateKey" => TermDefinition.new("privateKey", id: "https://w3id.org/security#privateKey", type_mapping: "@id"), + "privateKeyPem" => TermDefinition.new("privateKeyPem", id: "https://w3id.org/security#privateKeyPem", simple: true), + "ps" => TermDefinition.new("ps", id: "https://w3id.org/payswarm#", simple: true, prefix: true), + "publicKey" => TermDefinition.new("publicKey", id: "https://w3id.org/security#publicKey", type_mapping: "@id"), + "publicKeyPem" => TermDefinition.new("publicKeyPem", id: "https://w3id.org/security#publicKeyPem", simple: true), + "publicKeyService" => TermDefinition.new("publicKeyService", id: "https://w3id.org/security#publicKeyService", type_mapping: "@id"), + "rdf" => TermDefinition.new("rdf", id: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", simple: true, prefix: true), + "rdfs" => TermDefinition.new("rdfs", id: "http://www.w3.org/2000/01/rdf-schema#", simple: true, prefix: true), + "recipient" => TermDefinition.new("recipient", id: "https://w3id.org/credentials#recipient", type_mapping: "@id"), + "revoked" => TermDefinition.new("revoked", id: "https://w3id.org/security#revoked", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "schema" => TermDefinition.new("schema", id: "http://schema.org/", simple: true, prefix: true), + "sec" => TermDefinition.new("sec", id: "https://w3id.org/security#", simple: true, prefix: true), + "signature" => TermDefinition.new("signature", id: "https://w3id.org/security#signature", simple: true), + "signatureAlgorithm" => TermDefinition.new("signatureAlgorithm", id: "https://w3id.org/security#signatureAlgorithm", simple: true), + "signatureValue" => TermDefinition.new("signatureValue", id: "https://w3id.org/security#signatureValue", simple: true), + "streetAddress" => TermDefinition.new("streetAddress", id: "http://schema.org/streetAddress", simple: true), + "title" => TermDefinition.new("title", id: "http://purl.org/dc/terms/title", simple: true), + "type" => TermDefinition.new("type", id: "@type", simple: true), + "url" => TermDefinition.new("url", id: "http://schema.org/url", type_mapping: "@id"), + "writePermission" => TermDefinition.new("writePermission", id: "https://w3id.org/permissions#writePermission", type_mapping: "@id"), + "xsd" => TermDefinition.new("xsd", id: "http://www.w3.org/2001/XMLSchema#", simple: true, prefix: true) + }) + end +end diff --git a/lib/json_ld/security.rb b/lib/json_ld/security.rb new file mode 100644 index 000000000..1230206f0 --- /dev/null +++ b/lib/json_ld/security.rb @@ -0,0 +1,50 @@ +# -*- encoding: utf-8 -*- +# frozen_string_literal: true +# This file generated automatically from https://w3id.org/security/v1 +require 'json/ld' +class JSON::LD::Context + add_preloaded("https://w3id.org/security/v1") do + new(processingMode: "json-ld-1.0", term_definitions: { + "CryptographicKey" => TermDefinition.new("CryptographicKey", id: "https://w3id.org/security#Key", simple: true), + "EcdsaKoblitzSignature2016" => TermDefinition.new("EcdsaKoblitzSignature2016", id: "https://w3id.org/security#EcdsaKoblitzSignature2016", simple: true), + "EncryptedMessage" => TermDefinition.new("EncryptedMessage", id: "https://w3id.org/security#EncryptedMessage", simple: true), + "GraphSignature2012" => TermDefinition.new("GraphSignature2012", id: "https://w3id.org/security#GraphSignature2012", simple: true), + "LinkedDataSignature2015" => TermDefinition.new("LinkedDataSignature2015", id: "https://w3id.org/security#LinkedDataSignature2015", simple: true), + "LinkedDataSignature2016" => TermDefinition.new("LinkedDataSignature2016", id: "https://w3id.org/security#LinkedDataSignature2016", simple: true), + "authenticationTag" => TermDefinition.new("authenticationTag", id: "https://w3id.org/security#authenticationTag", simple: true), + "canonicalizationAlgorithm" => TermDefinition.new("canonicalizationAlgorithm", id: "https://w3id.org/security#canonicalizationAlgorithm", simple: true), + "cipherAlgorithm" => TermDefinition.new("cipherAlgorithm", id: "https://w3id.org/security#cipherAlgorithm", simple: true), + "cipherData" => TermDefinition.new("cipherData", id: "https://w3id.org/security#cipherData", simple: true), + "cipherKey" => TermDefinition.new("cipherKey", id: "https://w3id.org/security#cipherKey", simple: true), + "created" => TermDefinition.new("created", id: "http://purl.org/dc/terms/created", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "creator" => TermDefinition.new("creator", id: "http://purl.org/dc/terms/creator", type_mapping: "@id"), + "dc" => TermDefinition.new("dc", id: "http://purl.org/dc/terms/", simple: true, prefix: true), + "digestAlgorithm" => TermDefinition.new("digestAlgorithm", id: "https://w3id.org/security#digestAlgorithm", simple: true), + "digestValue" => TermDefinition.new("digestValue", id: "https://w3id.org/security#digestValue", simple: true), + "domain" => TermDefinition.new("domain", id: "https://w3id.org/security#domain", simple: true), + "encryptionKey" => TermDefinition.new("encryptionKey", id: "https://w3id.org/security#encryptionKey", simple: true), + "expiration" => TermDefinition.new("expiration", id: "https://w3id.org/security#expiration", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "expires" => TermDefinition.new("expires", id: "https://w3id.org/security#expiration", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "id" => TermDefinition.new("id", id: "@id", simple: true), + "initializationVector" => TermDefinition.new("initializationVector", id: "https://w3id.org/security#initializationVector", simple: true), + "iterationCount" => TermDefinition.new("iterationCount", id: "https://w3id.org/security#iterationCount", simple: true), + "nonce" => TermDefinition.new("nonce", id: "https://w3id.org/security#nonce", simple: true), + "normalizationAlgorithm" => TermDefinition.new("normalizationAlgorithm", id: "https://w3id.org/security#normalizationAlgorithm", simple: true), + "owner" => TermDefinition.new("owner", id: "https://w3id.org/security#owner", type_mapping: "@id"), + "password" => TermDefinition.new("password", id: "https://w3id.org/security#password", simple: true), + "privateKey" => TermDefinition.new("privateKey", id: "https://w3id.org/security#privateKey", type_mapping: "@id"), + "privateKeyPem" => TermDefinition.new("privateKeyPem", id: "https://w3id.org/security#privateKeyPem", simple: true), + "publicKey" => TermDefinition.new("publicKey", id: "https://w3id.org/security#publicKey", type_mapping: "@id"), + "publicKeyPem" => TermDefinition.new("publicKeyPem", id: "https://w3id.org/security#publicKeyPem", simple: true), + "publicKeyService" => TermDefinition.new("publicKeyService", id: "https://w3id.org/security#publicKeyService", type_mapping: "@id"), + "revoked" => TermDefinition.new("revoked", id: "https://w3id.org/security#revoked", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "salt" => TermDefinition.new("salt", id: "https://w3id.org/security#salt", simple: true), + "sec" => TermDefinition.new("sec", id: "https://w3id.org/security#", simple: true, prefix: true), + "signature" => TermDefinition.new("signature", id: "https://w3id.org/security#signature", simple: true), + "signatureAlgorithm" => TermDefinition.new("signatureAlgorithm", id: "https://w3id.org/security#signingAlgorithm", simple: true), + "signatureValue" => TermDefinition.new("signatureValue", id: "https://w3id.org/security#signatureValue", simple: true), + "type" => TermDefinition.new("type", id: "@type", simple: true), + "xsd" => TermDefinition.new("xsd", id: "http://www.w3.org/2001/XMLSchema#", simple: true, prefix: true) + }) + end +end diff --git a/spec/lib/activitypub/linked_data_signature_spec.rb b/spec/lib/activitypub/linked_data_signature_spec.rb new file mode 100644 index 000000000..ee4b68028 --- /dev/null +++ b/spec/lib/activitypub/linked_data_signature_spec.rb @@ -0,0 +1,86 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::LinkedDataSignature do + include JsonLdHelper + + let!(:sender) { Fabricate(:account, uri: 'http://example.com/alice') } + + let(:raw_json) do + { + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => 'http://example.com/hello-world', + } + end + + let(:json) { raw_json.merge('signature' => signature) } + + subject { described_class.new(json) } + + describe '#verify_account!' do + context 'when signature matches' do + let(:raw_signature) do + { + 'creator' => 'http://example.com/alice', + 'created' => '2017-09-23T20:21:34Z', + } + end + + let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => sign(sender, raw_signature, raw_json)) } + + it 'returns creator' do + expect(subject.verify_account!).to eq sender + end + end + + context 'when signature is missing' do + let(:signature) { nil } + + it 'returns nil' do + expect(subject.verify_account!).to be_nil + end + end + + context 'when signature is tampered' do + let(:raw_signature) do + { + 'creator' => 'http://example.com/alice', + 'created' => '2017-09-23T20:21:34Z', + } + end + + let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => 's69F3mfddd99dGjmvjdjjs81e12jn121Gkm1') } + + it 'returns nil' do + expect(subject.verify_account!).to be_nil + end + end + end + + describe '#sign!' do + subject { described_class.new(raw_json).sign!(sender) } + + it 'returns a hash' do + expect(subject).to be_a Hash + end + + it 'contains signature context' do + expect(subject['@context']).to include('https://www.w3.org/ns/activitystreams', 'https://w3id.org/identity/v1') + end + + it 'contains signature' do + expect(subject['signature']).to be_a Hash + expect(subject['signature']['signatureValue']).to be_present + end + + it 'can be verified again' do + expect(described_class.new(subject).verify_account!).to eq sender + end + end + + def sign(from_account, options, document) + options_hash = Digest::SHA256.hexdigest(canonicalize(options.merge('@context' => ActivityPub::LinkedDataSignature::CONTEXT))) + document_hash = Digest::SHA256.hexdigest(canonicalize(document)) + to_be_verified = options_hash + document_hash + Base64.strict_encode64(from_account.keypair.sign(OpenSSL::Digest::SHA256.new, to_be_verified)) + end +end diff --git a/spec/services/activitypub/process_collection_service_spec.rb b/spec/services/activitypub/process_collection_service_spec.rb index 6486483f6..bf3bc82aa 100644 --- a/spec/services/activitypub/process_collection_service_spec.rb +++ b/spec/services/activitypub/process_collection_service_spec.rb @@ -1,9 +1,10 @@ require 'rails_helper' RSpec.describe ActivityPub::ProcessCollectionService do - subject { ActivityPub::ProcessCollectionService.new } + subject { described_class.new } describe '#call' do - pending + context 'when actor is the sender' + context 'when actor differs from sender' end end -- cgit From 884b085f534b02c664bcddd3a2fefcad6d4e5cc4 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 26 Aug 2017 16:10:35 +0200 Subject: Use Tombstone and _:atomUri in Delete activities as fallback (#4704) --- app/lib/activitypub/activity/delete.rb | 3 ++- app/serializers/activitypub/delete_serializer.rb | 24 +++++++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index 23f3430fb..a1598dfd3 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -2,7 +2,8 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity def perform - status = Status.find_by(uri: object_uri, account: @account) + 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? if status.nil? delete_later!(object_uri) diff --git a/app/serializers/activitypub/delete_serializer.rb b/app/serializers/activitypub/delete_serializer.rb index b49268d72..a041c577b 100644 --- a/app/serializers/activitypub/delete_serializer.rb +++ b/app/serializers/activitypub/delete_serializer.rb @@ -1,8 +1,26 @@ # frozen_string_literal: true class ActivityPub::DeleteSerializer < ActiveModel::Serializer + class TombstoneSerializer < ActiveModel::Serializer + attributes :id, :type + attribute :atom_uri, key: '_:atomUri' + + def id + ActivityPub::TagManager.instance.uri_for(object) + end + + def type + 'Tombstone' + end + + def atom_uri + ::TagManager.instance.uri_for(object) + end + end + attributes :id, :type, :actor - attribute :virtual_object, key: :object + + has_one :object, serializer: TombstoneSerializer def id [ActivityPub::TagManager.instance.uri_for(object), '#delete'].join @@ -15,8 +33,4 @@ class ActivityPub::DeleteSerializer < ActiveModel::Serializer def actor ActivityPub::TagManager.instance.uri_for(object.account) end - - def virtual_object - ActivityPub::TagManager.instance.uri_for(object) - end end -- cgit From 0397c58b61d8a364ff488fc5631bfc751903c242 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 26 Aug 2017 18:52:53 +0200 Subject: Forward ActivityPub deletes to followers of rebloggers (#4706) --- app/lib/activitypub/activity/delete.rb | 19 +++++++++++++++++- app/workers/activitypub/raw_distribution_worker.rb | 23 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 app/workers/activitypub/raw_distribution_worker.rb (limited to 'app/lib') diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index a1598dfd3..e7eb53a02 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -8,7 +8,24 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity if status.nil? delete_later!(object_uri) else - RemoveStatusService.new.call(status) + forward_for_reblogs(status) + delete_now!(status) end end + + private + + def forward_for_reblogs(status) + ActivityPub::RawDistributionWorker.push_bulk(status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)) do |account| + [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/workers/activitypub/raw_distribution_worker.rb b/app/workers/activitypub/raw_distribution_worker.rb new file mode 100644 index 000000000..d73466f6e --- /dev/null +++ b/app/workers/activitypub/raw_distribution_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class ActivityPub::RawDistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(json, source_account_id) + @account = Account.find(source_account_id) + + ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| + [json, @account.id, inbox_url] + end + rescue ActiveRecord::RecordNotFound + true + end + + private + + def inboxes + @inboxes ||= @account.followers.inboxes + end +end -- cgit From 0d5d11eeff048a5022a6eef68d299856f5bb9860 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 26 Aug 2017 19:55:10 +0200 Subject: Add _:inReplyToAtomUri to ActivityPub (#4702) --- app/helpers/jsonld_helper.rb | 2 +- app/lib/activitypub/activity/create.rb | 17 ++++++++++++++--- app/serializers/activitypub/activity_serializer.rb | 6 +++++- app/serializers/activitypub/note_serializer.rb | 7 +++++++ 4 files changed, 27 insertions(+), 5 deletions(-) (limited to 'app/lib') diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 09446c8be..d82a07332 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -10,7 +10,7 @@ module JsonLdHelper end def value_or_id(value) - value.is_a?(String) ? value : value['id'] + value.is_a?(String) || value.nil? ? value : value['id'] end def supported_context?(json) diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 5c59c4b24..114aed84f 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -91,7 +91,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def resolve_thread(status) return unless status.reply? && status.thread.nil? - ThreadResolveWorker.perform_async(status.id, @object['inReplyTo']) + ThreadResolveWorker.perform_async(status.id, in_reply_to_uri) end def conversation_from_uri(uri) @@ -118,8 +118,19 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def replied_to_status - return if @object['inReplyTo'].blank? - @replied_to_status ||= status_from_uri(@object['inReplyTo']) + 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 diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb index d20ee9920..349495e84 100644 --- a/app/serializers/activitypub/activity_serializer.rb +++ b/app/serializers/activitypub/activity_serializer.rb @@ -10,7 +10,7 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer end def type - object.reblog? ? 'Announce' : 'Create' + announce? ? 'Announce' : 'Create' end def actor @@ -24,4 +24,8 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer def cc ActivityPub::TagManager.instance.cc(object) end + + def announce? + object.reblog? + end end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 4061b9ce4..15031dfdc 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -9,6 +9,7 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer has_many :virtual_tags, key: :tag attribute :atom_uri, key: '_:atomUri', if: :local? + attribute :in_reply_to_atom_uri, key: '_:inReplyToAtomUri' def id ActivityPub::TagManager.instance.uri_for(object) @@ -64,6 +65,12 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer ::TagManager.instance.uri_for(object) end + def in_reply_to_atom_uri + return unless object.reply? + + ::TagManager.instance.uri_for(object.thread) + end + def local? object.account.local? end -- cgit From f095a9f8a5788aaf4205e9bdca5845a844f8c0d3 Mon Sep 17 00:00:00 2001 From: unarist Date: Sun, 27 Aug 2017 20:35:01 +0900 Subject: Allow Symbol keyed Hash in LinkedDataSignature (#4715) SerializarbleResource#as_json serializes to Symbol keyed Hash, but current implementation of LinkedDataSignature expects String keyed Hash. So it generates broken payload. --- app/lib/activitypub/linked_data_signature.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb index 7173aed19..4483339a9 100644 --- a/app/lib/activitypub/linked_data_signature.rb +++ b/app/lib/activitypub/linked_data_signature.rb @@ -6,7 +6,7 @@ class ActivityPub::LinkedDataSignature CONTEXT = 'https://w3id.org/identity/v1' def initialize(json) - @json = json + @json = json.with_indifferent_access end def verify_account! -- cgit From 938cd2875b14db3655a6c9f82f672f4baf7720a3 Mon Sep 17 00:00:00 2001 From: unarist Date: Tue, 29 Aug 2017 05:08:11 +0900 Subject: Fix Delete activity handling when the status has been reblogged (#4729) --- app/lib/activitypub/activity/delete.rb | 4 ++-- spec/lib/activitypub/activity/delete_spec.rb | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index e7eb53a02..789ed58f1 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -16,8 +16,8 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity private def forward_for_reblogs(status) - ActivityPub::RawDistributionWorker.push_bulk(status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)) do |account| - [payload, account.id] + ActivityPub::RawDistributionWorker.push_bulk(status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)) do |account_id| + [payload, account_id] end end diff --git a/spec/lib/activitypub/activity/delete_spec.rb b/spec/lib/activitypub/activity/delete_spec.rb index 398669b48..6601f7262 100644 --- a/spec/lib/activitypub/activity/delete_spec.rb +++ b/spec/lib/activitypub/activity/delete_spec.rb @@ -25,4 +25,28 @@ RSpec.describe ActivityPub::Activity::Delete do expect(Status.find_by(id: status.id)).to be_nil end end + + context 'when the status has been reblogged' do + describe '#perform' do + subject { described_class.new(json, sender) } + let(:reblogger) { Fabricate(:account) } + let(:follower) { Fabricate(:account, username: 'follower', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } + + before do + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + follower.follow!(reblogger) + Fabricate(:status, account: reblogger, reblog: status) + subject.perform + end + + it 'deletes sender\'s status' do + expect(Status.find_by(id: status.id)).to be_nil + end + + it 'sends delete activity to followers of rebloggers' do + # one for Delete original post, and one for Undo reblog (normal delivery) + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.twice + end + end + end end -- cgit From 4c76402ba1d355061e7e208b7a2f8251388a38e1 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 29 Aug 2017 16:11:05 +0200 Subject: Serialize ActivityPub alternate link into OStatus deletes, handle it (#4730) Requires moving Atom rendering from DistributionWorker (where `stream_entry.status` is already nil) to inline (where `stream_entry.status.destroyed?` is true) and distributing that. Unfortunately, such XML renderings can no longer be easily chained together into one payload of n items. --- app/lib/ostatus/activity/deletion.rb | 4 +++- app/lib/ostatus/atom_serializer.rb | 3 +++ app/models/status.rb | 13 ++++++++++-- app/services/batched_remove_status_service.rb | 24 +++++++++++++--------- app/services/remove_status_service.rb | 4 +--- .../pubsubhubbub/raw_distribution_worker.rb | 22 ++++++++++++++++++++ .../services/batched_remove_status_service_spec.rb | 7 +++---- 7 files changed, 57 insertions(+), 20 deletions(-) create mode 100644 app/workers/pubsubhubbub/raw_distribution_worker.rb (limited to 'app/lib') 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/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb index 92a16d228..81fae4140 100644 --- a/app/lib/ostatus/atom_serializer.rb +++ b/app/lib/ostatus/atom_serializer.rb @@ -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 diff --git a/app/models/status.rb b/app/models/status.rb index 3dc83ad1f..abd902cd7 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -51,6 +51,7 @@ class Status < ApplicationRecord has_one :notification, as: :activity, dependent: :destroy has_one :preview_card, dependent: :destroy + has_one :stream_entry, as: :activity, inverse_of: :status validates :uri, uniqueness: true, unless: :local? validates :text, presence: true, unless: :reblog? @@ -90,7 +91,11 @@ class Status < ApplicationRecord end def verb - reblog? ? :share : :post + if destroyed? + :delete + else + reblog? ? :share : :post + end end def object_type @@ -110,7 +115,11 @@ class Status < ApplicationRecord end def title - reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}" + if destroyed? + "#{account.acct} deleted status" + else + reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}" + end end def hidden? diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index e9e22298d..86eaa5735 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -20,9 +20,10 @@ class BatchedRemoveStatusService < BaseService @activity_json_batches = [] @json_payloads = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h @activity_json = {} + @activity_xml = {} # Ensure that rendered XML reflects destroyed state - Status.where(id: statuses.map(&:id)).in_batches.destroy_all + statuses.each(&:destroy) # Batch by source account statuses.group_by(&:account_id).each do |_, account_statuses| @@ -31,7 +32,7 @@ class BatchedRemoveStatusService < BaseService unpush_from_home_timelines(account_statuses) if account.local? - batch_stream_entries(account_statuses) + batch_stream_entries(account, account_statuses) batch_activity_json(account, account_statuses) end end @@ -42,18 +43,16 @@ class BatchedRemoveStatusService < BaseService batch_salmon_slaps(status) if status.local? end - Pubsubhubbub::DistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch } + Pubsubhubbub::RawDistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch } NotificationWorker.push_bulk(@salmon_batches) { |batch| batch } ActivityPub::DeliveryWorker.push_bulk(@activity_json_batches) { |batch| batch } end private - def batch_stream_entries(statuses) - stream_entry_ids = statuses.map { |s| s.stream_entry.id } - - stream_entry_ids.each_slice(100) do |batch_of_stream_entry_ids| - @stream_entry_batches << [batch_of_stream_entry_ids] + def batch_stream_entries(account, statuses) + statuses.each do |status| + @stream_entry_batches << [build_xml(status.stream_entry), account.id] end end @@ -101,11 +100,10 @@ class BatchedRemoveStatusService < BaseService def batch_salmon_slaps(status) return if @mentions[status.id].empty? - payload = stream_entry_to_xml(status.stream_entry.reload) recipients = @mentions[status.id].map(&:account).reject(&:local?).select(&:ostatus?).uniq(&:domain).map(&:id) recipients.each do |recipient_id| - @salmon_batches << [payload, status.account_id, recipient_id] + @salmon_batches << [build_xml(status.stream_entry), status.account_id, recipient_id] end end @@ -145,6 +143,12 @@ class BatchedRemoveStatusService < BaseService ).as_json) end + def build_xml(stream_entry) + return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id) + + @activity_xml[stream_entry.id] = stream_entry_to_xml(stream_entry) + end + def sign_json(status, json) Oj.dump(ActivityPub::LinkedDataSignature.new(json).sign!(status.account)) end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 7ddbd8906..83fc77043 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -22,8 +22,6 @@ class RemoveStatusService < BaseService return unless @account.local? - @stream_entry = @stream_entry.reload - remove_from_remote_followers remove_from_remote_affected end @@ -62,7 +60,7 @@ class RemoveStatusService < BaseService def remove_from_remote_followers # OStatus - Pubsubhubbub::DistributionWorker.perform_async(@stream_entry.id) + Pubsubhubbub::RawDistributionWorker.perform_async(salmon_xml, @account.id) # ActivityPub ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url| diff --git a/app/workers/pubsubhubbub/raw_distribution_worker.rb b/app/workers/pubsubhubbub/raw_distribution_worker.rb new file mode 100644 index 000000000..16962a623 --- /dev/null +++ b/app/workers/pubsubhubbub/raw_distribution_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Pubsubhubbub::RawDistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(xml, source_account_id) + @account = Account.find(source_account_id) + @subscriptions = active_subscriptions.to_a + + Pubsubhubbub::DeliveryWorker.push_bulk(@subscriptions) do |subscription| + [subscription.id, xml] + end + end + + private + + def active_subscriptions + Subscription.where(account: @account).active.select('id, callback_url, domain') + end +end diff --git a/spec/services/batched_remove_status_service_spec.rb b/spec/services/batched_remove_status_service_spec.rb index 2484d4b58..b1e9ac567 100644 --- a/spec/services/batched_remove_status_service_spec.rb +++ b/spec/services/batched_remove_status_service_spec.rb @@ -48,11 +48,10 @@ RSpec.describe BatchedRemoveStatusService do expect(Redis.current).to have_received(:publish).with('timeline:public', any_args).at_least(:once) end - it 'sends PuSH update to PuSH subscribers with two payloads united' do + it 'sends PuSH update to PuSH subscribers' do expect(a_request(:post, 'http://example.com/push').with { |req| - matches = req.body.scan(TagManager::VERBS[:delete]) - matches.size == 2 - }).to have_been_made + matches = req.body.match(TagManager::VERBS[:delete]) + }).to have_been_made.at_least_once end it 'sends Salmon slap to previously mentioned users' do -- cgit From e95bdec7c5da63930fc2e08e67e4358fec19296d Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 30 Aug 2017 10:23:43 +0200 Subject: Update status embeds (#4742) - Use statuses controller for embeds instead of stream entries controller - Prefer /@:username/:id/embed URL for embeds - Use /@:username as author_url in OEmbed - Add follow link to embeds which opens web intent in new window - Use redis cache in development - Cache entire embed --- app/controllers/api/oembed_controller.rb | 8 ++-- app/controllers/statuses_controller.rb | 5 ++ app/controllers/stream_entries_controller.rb | 5 +- app/helpers/stream_entries_helper.rb | 2 +- app/javascript/packs/public.js | 7 +++ app/javascript/styles/stream_entries.scss | 30 ++++++++++++ app/lib/status_finder.rb | 34 +++++++++++++ app/lib/stream_entry_finder.rb | 34 ------------- app/serializers/oembed_serializer.rb | 4 +- .../stream_entries/_detailed_status.html.haml | 5 ++ app/views/stream_entries/embed.html.haml | 5 +- config/brakeman.ignore | 50 ++++++++++---------- config/environments/development.rb | 5 +- config/routes.rb | 2 + spec/controllers/stream_entries_controller_spec.rb | 6 +-- spec/lib/status_finder_spec.rb | 55 ++++++++++++++++++++++ spec/lib/stream_entry_finder_spec.rb | 55 ---------------------- 17 files changed, 179 insertions(+), 133 deletions(-) create mode 100644 app/lib/status_finder.rb delete mode 100644 app/lib/stream_entry_finder.rb create mode 100644 spec/lib/status_finder_spec.rb delete mode 100644 spec/lib/stream_entry_finder_spec.rb (limited to 'app/lib') diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb index f8c87dd16..37a163cd3 100644 --- a/app/controllers/api/oembed_controller.rb +++ b/app/controllers/api/oembed_controller.rb @@ -4,14 +4,14 @@ class Api::OEmbedController < Api::BaseController respond_to :json def show - @stream_entry = find_stream_entry.stream_entry - render json: @stream_entry, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default + @status = status_finder.status + render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default end private - def find_stream_entry - StreamEntryFinder.new(params[:url]) + def status_finder + StatusFinder.new(params[:url]) end def maxwidth_or_default diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index a9768d092..65206ea96 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -30,6 +30,11 @@ class StatusesController < ApplicationController render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end + def embed + response.headers['X-Frame-Options'] = 'ALLOWALL' + render 'stream_entries/embed', layout: 'embedded' + end + private def set_account diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index ccb15495e..cc579dbc8 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -25,10 +25,7 @@ class StreamEntriesController < ApplicationController end def embed - response.headers['X-Frame-Options'] = 'ALLOWALL' - return gone if @stream_entry.activity.nil? - - render layout: 'embedded' + redirect_to embed_short_account_status_url(@account, @stream_entry.activity), status: 301 end private diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index 4ef7cffb0..445114985 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module StreamEntriesHelper - EMBEDDED_CONTROLLER = 'stream_entries' + EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_ACTION = 'embed' def display_name(account) diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index d8a0f4eee..ce12041e6 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -38,6 +38,13 @@ function main() { content.title = dateTimeFormat.format(datetime); content.textContent = relativeFormat.format(datetime); }); + + [].forEach.call(document.querySelectorAll('.logo-button'), (content) => { + content.addEventListener('click', (e) => { + e.preventDefault(); + window.open(e.target.href, 'mastodon-intent', 'width=400,height=400,resizable=no,menubar=no,status=no,scrollbars=yes'); + }); + }); }); delegate(document, '.video-player video', 'click', ({ target }) => { diff --git a/app/javascript/styles/stream_entries.scss b/app/javascript/styles/stream_entries.scss index 1192e2a80..7048ab110 100644 --- a/app/javascript/styles/stream_entries.scss +++ b/app/javascript/styles/stream_entries.scss @@ -421,3 +421,33 @@ } } } + +.button.button-secondary.logo-button { + position: absolute; + right: 14px; + top: 14px; + font-size: 14px; + + svg { + width: 20px; + height: auto; + vertical-align: middle; + margin-right: 5px; + + path:first-child { + fill: $ui-primary-color; + } + + path:last-child { + fill: $simple-background-color; + } + } + + &:active, + &:focus, + &:hover { + svg path:first-child { + fill: lighten($ui-primary-color, 4%); + } + } +} diff --git a/app/lib/status_finder.rb b/app/lib/status_finder.rb new file mode 100644 index 000000000..bd910f12b --- /dev/null +++ b/app/lib/status_finder.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class StatusFinder + attr_reader :url + + def initialize(url) + @url = url + end + + def status + verify_action! + + case recognized_params[:controller] + when 'stream_entries' + StreamEntry.find(recognized_params[:id]).status + when 'statuses' + Status.find(recognized_params[:id]) + else + raise ActiveRecord::RecordNotFound + end + end + + private + + def recognized_params + Rails.application.routes.recognize_path(url) + end + + def verify_action! + unless recognized_params[:action] == 'show' + raise ActiveRecord::RecordNotFound + end + end +end diff --git a/app/lib/stream_entry_finder.rb b/app/lib/stream_entry_finder.rb deleted file mode 100644 index 0ea33229c..000000000 --- a/app/lib/stream_entry_finder.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -class StreamEntryFinder - attr_reader :url - - def initialize(url) - @url = url - end - - def stream_entry - verify_action! - - case recognized_params[:controller] - when 'stream_entries' - StreamEntry.find(recognized_params[:id]) - when 'statuses' - Status.find(recognized_params[:id]).stream_entry - else - raise ActiveRecord::RecordNotFound - end - end - - private - - def recognized_params - Rails.application.routes.recognize_path(url) - end - - def verify_action! - unless recognized_params[:action] == 'show' - raise ActiveRecord::RecordNotFound - end - end -end diff --git a/app/serializers/oembed_serializer.rb b/app/serializers/oembed_serializer.rb index 78376d253..0c2ced859 100644 --- a/app/serializers/oembed_serializer.rb +++ b/app/serializers/oembed_serializer.rb @@ -21,7 +21,7 @@ class OEmbedSerializer < ActiveModel::Serializer end def author_url - account_url(object.account) + short_account_url(object.account) end def provider_name @@ -38,7 +38,7 @@ class OEmbedSerializer < ActiveModel::Serializer def html tag :iframe, - src: embed_account_stream_entry_url(object.account, object), + src: embed_short_account_status_url(object.account, object), style: 'width: 100%; overflow: hidden', frameborder: '0', scrolling: 'no', diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index 193cc6470..107202b75 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -1,4 +1,9 @@ .detailed-status.light + - if embedded_view? + = link_to "web+mastodon://follow?uri=#{status.account.local_username_and_domain}", class: 'button button-secondary logo-button', target: '_new' do + = render file: Rails.root.join('app', 'javascript', 'images', 'logo.svg') + = t('accounts.follow') + = link_to TagManager.instance.url_for(status.account), class: 'detailed-status__display-name p-author h-card', target: stream_link_target, rel: 'noopener' do %div .avatar diff --git a/app/views/stream_entries/embed.html.haml b/app/views/stream_entries/embed.html.haml index 5df82528b..b703c15d2 100644 --- a/app/views/stream_entries/embed.html.haml +++ b/app/views/stream_entries/embed.html.haml @@ -1,2 +1,3 @@ -.activity-stream.activity-stream-headless - = render @type, @type.to_sym => @stream_entry.activity, centered: true +- cache @stream_entry.activity do + .activity-stream.activity-stream-headless + = render "stream_entries/#{@type}", @type.to_sym => @stream_entry.activity, centered: true diff --git a/config/brakeman.ignore b/config/brakeman.ignore index f9bc77069..dbb59dd07 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,5 +1,24 @@ { "ignored_warnings": [ + { + "warning_type": "Dynamic Render Path", + "warning_code": 15, + "fingerprint": "44d3f14e05d8fbb5b23e13ac02f15aa38b2a2f0f03b9ba76bab7f98e155a4a4e", + "check_name": "Render", + "message": "Render path contains parameter value", + "file": "app/views/stream_entries/embed.html.haml", + "line": 3, + "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", + "code": "render(action => \"stream_entries/#{Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase}\", { Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity, :centered => true })", + "render_path": [{"type":"controller","class":"StatusesController","method":"embed","line":35,"file":"app/controllers/statuses_controller.rb"}], + "location": { + "type": "template", + "template": "stream_entries/embed" + }, + "user_input": "params[:id]", + "confidence": "Weak", + "note": "" + }, { "warning_type": "Dynamic Render Path", "warning_code": 15, @@ -7,10 +26,10 @@ "check_name": "Render", "message": "Render path contains parameter value", "file": "app/views/admin/accounts/index.html.haml", - "line": 32, + "line": 63, "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", "code": "render(action => filtered_accounts.page(params[:page]), {})", - "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"index","line":7,"file":"app/controllers/admin/accounts_controller.rb"}], + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"index","line":10,"file":"app/controllers/admin/accounts_controller.rb"}], "location": { "type": "template", "template": "admin/accounts/index" @@ -39,25 +58,6 @@ "confidence": "High", "note": "" }, - { - "warning_type": "Dynamic Render Path", - "warning_code": 15, - "fingerprint": "c417f9d44ab05dd9cf3d5ec9df2324a5036774c151181787b32c4c940623191b", - "check_name": "Render", - "message": "Render path contains parameter value", - "file": "app/views/stream_entries/embed.html.haml", - "line": 2, - "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", - "code": "render(action => Account.find_local!(params[:account_username]).stream_entries.where(:activity_type => \"Status\").find(params[:id]).activity_type.downcase, { Account.find_local!(params[:account_username]).stream_entries.where(:activity_type => \"Status\").find(params[:id]).activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).stream_entries.where(:activity_type => \"Status\").find(params[:id]).activity, :centered => true })", - "render_path": [{"type":"controller","class":"StreamEntriesController","method":"embed","line":32,"file":"app/controllers/stream_entries_controller.rb"}], - "location": { - "type": "template", - "template": "stream_entries/embed" - }, - "user_input": "params[:id]", - "confidence": "Weak", - "note": "" - }, { "warning_type": "Dynamic Render Path", "warning_code": 15, @@ -84,10 +84,10 @@ "check_name": "Render", "message": "Render path contains parameter value", "file": "app/views/stream_entries/show.html.haml", - "line": 19, + "line": 23, "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", "code": "render(partial => \"stream_entries/#{Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase}\", { :locals => ({ Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity, :include_threads => true }) })", - "render_path": [{"type":"controller","class":"StatusesController","method":"show","line":15,"file":"app/controllers/statuses_controller.rb"}], + "render_path": [{"type":"controller","class":"StatusesController","method":"show","line":20,"file":"app/controllers/statuses_controller.rb"}], "location": { "type": "template", "template": "stream_entries/show" @@ -97,6 +97,6 @@ "note": "" } ], - "updated": "2017-05-07 08:26:06 +0900", - "brakeman_version": "3.6.1" + "updated": "2017-08-30 05:14:04 +0200", + "brakeman_version": "3.7.2" } diff --git a/config/environments/development.rb b/config/environments/development.rb index 4c60965c8..59bc2c3e2 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -16,9 +16,10 @@ Rails.application.configure do if Rails.root.join('tmp/caching-dev.txt').exist? config.action_controller.perform_caching = true - config.cache_store = :memory_store + config.cache_store = :redis_store, ENV['REDIS_URL'], REDIS_CACHE_PARAMS + config.public_file_server.headers = { - 'Cache-Control' => "public, max-age=#{2.days.seconds.to_i}" + 'Cache-Control' => "public, max-age=#{2.days.seconds.to_i}", } else config.action_controller.perform_caching = false diff --git a/config/routes.rb b/config/routes.rb index 7588805c0..f8f145e1d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -44,6 +44,7 @@ Rails.application.routes.draw do resources :statuses, only: [:show] do member do get :activity + get :embed end end @@ -59,6 +60,7 @@ Rails.application.routes.draw do get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies get '/@:username/media', to: 'accounts#show', as: :short_account_media get '/@:account_username/:id', to: 'statuses#show', as: :short_account_status + get '/@:account_username/:id/embed', to: 'statuses#embed', as: :embed_short_account_status namespace :settings do resource :profile, only: [:show, :update] diff --git a/spec/controllers/stream_entries_controller_spec.rb b/spec/controllers/stream_entries_controller_spec.rb index 808cf667c..f81e2be7b 100644 --- a/spec/controllers/stream_entries_controller_spec.rb +++ b/spec/controllers/stream_entries_controller_spec.rb @@ -88,14 +88,12 @@ RSpec.describe StreamEntriesController, type: :controller do describe 'GET #embed' do include_examples 'before_action', :embed - it 'returns embedded view of status' do + it 'redirects to new embed page' do status = Fabricate(:status) get :embed, params: { account_username: status.account.username, id: status.stream_entry.id } - expect(response).to have_http_status(:success) - expect(response.headers['X-Frame-Options']).to eq 'ALLOWALL' - expect(response).to render_template(layout: 'embedded') + expect(response).to redirect_to(embed_short_account_status_url(status.account, status)) end end end diff --git a/spec/lib/status_finder_spec.rb b/spec/lib/status_finder_spec.rb new file mode 100644 index 000000000..5c2f2dbe8 --- /dev/null +++ b/spec/lib/status_finder_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe StatusFinder do + include RoutingHelper + + describe '#status' do + context 'with a status url' do + let(:status) { Fabricate(:status) } + let(:url) { short_account_status_url(account_username: status.account.username, id: status.id) } + subject { described_class.new(url) } + + it 'finds the stream entry' do + expect(subject.status).to eq(status) + end + + it 'raises an error if action is not :show' do + recognized = Rails.application.routes.recognize_path(url) + expect(recognized).to receive(:[]).with(:action).and_return(:create) + expect(Rails.application.routes).to receive(:recognize_path).with(url).and_return(recognized) + + expect { subject.status }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with a stream entry url' do + let(:stream_entry) { Fabricate(:stream_entry) } + let(:url) { account_stream_entry_url(stream_entry.account, stream_entry) } + subject { described_class.new(url) } + + it 'finds the stream entry' do + expect(subject.status).to eq(stream_entry.status) + end + end + + context 'with a plausible url' do + let(:url) { 'https://example.com/users/test/updates/123/embed' } + subject { described_class.new(url) } + + it 'raises an error' do + expect { subject.status }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with an unrecognized url' do + let(:url) { 'https://example.com/about' } + subject { described_class.new(url) } + + it 'raises an error' do + expect { subject.status }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/lib/stream_entry_finder_spec.rb b/spec/lib/stream_entry_finder_spec.rb deleted file mode 100644 index 64e03c36a..000000000 --- a/spec/lib/stream_entry_finder_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe StreamEntryFinder do - include RoutingHelper - - describe '#stream_entry' do - context 'with a status url' do - let(:status) { Fabricate(:status) } - let(:url) { short_account_status_url(account_username: status.account.username, id: status.id) } - subject { described_class.new(url) } - - it 'finds the stream entry' do - expect(subject.stream_entry).to eq(status.stream_entry) - end - - it 'raises an error if action is not :show' do - recognized = Rails.application.routes.recognize_path(url) - expect(recognized).to receive(:[]).with(:action).and_return(:create) - expect(Rails.application.routes).to receive(:recognize_path).with(url).and_return(recognized) - - expect { subject.stream_entry }.to raise_error(ActiveRecord::RecordNotFound) - end - end - - context 'with a stream entry url' do - let(:stream_entry) { Fabricate(:stream_entry) } - let(:url) { account_stream_entry_url(stream_entry.account, stream_entry) } - subject { described_class.new(url) } - - it 'finds the stream entry' do - expect(subject.stream_entry).to eq(stream_entry) - end - end - - context 'with a plausible url' do - let(:url) { 'https://example.com/users/test/updates/123/embed' } - subject { described_class.new(url) } - - it 'raises an error' do - expect { subject.stream_entry }.to raise_error(ActiveRecord::RecordNotFound) - end - end - - context 'with an unrecognized url' do - let(:url) { 'https://example.com/about' } - subject { described_class.new(url) } - - it 'raises an error' do - expect { subject.stream_entry }.to raise_error(ActiveRecord::RecordNotFound) - end - end - end -end -- cgit From 7b8f26284072120701289f90bc6602ce918e4304 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 30 Aug 2017 15:37:02 +0200 Subject: Forward ActivityPub creates that reply to local statuses (#4709) * Forward ActivityPub creates that reply to local statuses * Fix test * Fix wrong signers --- app/lib/activitypub/activity/create.rb | 10 ++++++ app/lib/activitypub/activity/delete.rb | 2 ++ app/services/post_status_service.rb | 1 + .../activitypub/reply_distribution_worker.rb | 42 ++++++++++++++++++++++ spec/lib/activitypub/activity/delete_spec.rb | 1 + 5 files changed, 56 insertions(+) create mode 100644 app/workers/activitypub/reply_distribution_worker.rb (limited to 'app/lib') diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 114aed84f..2eea1827a 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -17,6 +17,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity resolve_thread(status) distribute(status) + forward_for_reply if status.public_visibility? || status.unlisted_visibility? status end @@ -162,4 +163,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity 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 index 789ed58f1..afa9a8079 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -16,6 +16,8 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity private 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 diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 5ff93f21e..568f5a9e7 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -40,6 +40,7 @@ class PostStatusService < BaseService DistributionWorker.perform_async(status.id) Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) ActivityPub::DistributionWorker.perform_async(status.id) + ActivityPub::ReplyDistributionWorker.perform_async(status.id) if status.reply? && status.thread.account.local? if options[:idempotency].present? redis.setex("idempotency:status:#{account.id}:#{options[:idempotency]}", 3_600, status.id) diff --git a/app/workers/activitypub/reply_distribution_worker.rb b/app/workers/activitypub/reply_distribution_worker.rb new file mode 100644 index 000000000..f9127340f --- /dev/null +++ b/app/workers/activitypub/reply_distribution_worker.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class ActivityPub::ReplyDistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(status_id) + @status = Status.find(status_id) + @account = @status.thread.account + + return if skip_distribution? + + ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| + [signed_payload, @status.account_id, inbox_url] + end + rescue ActiveRecord::RecordNotFound + true + end + + private + + def skip_distribution? + @status.private_visibility? || @status.direct_visibility? + end + + def inboxes + @inboxes ||= @account.followers.inboxes + end + + def signed_payload + @signed_payload ||= Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@status.account)) + end + + def payload + @payload ||= ActiveModelSerializers::SerializableResource.new( + @status, + serializer: ActivityPub::ActivitySerializer, + adapter: ActivityPub::Adapter + ).as_json + end +end diff --git a/spec/lib/activitypub/activity/delete_spec.rb b/spec/lib/activitypub/activity/delete_spec.rb index 6601f7262..65e743abb 100644 --- a/spec/lib/activitypub/activity/delete_spec.rb +++ b/spec/lib/activitypub/activity/delete_spec.rb @@ -11,6 +11,7 @@ RSpec.describe ActivityPub::Activity::Delete do type: 'Delete', actor: ActivityPub::TagManager.instance.uri_for(sender), object: ActivityPub::TagManager.instance.uri_for(status), + signature: 'foo', }.with_indifferent_access end -- cgit From 9a5ae096206df2240ba042efed62854193898a65 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 31 Aug 2017 21:32:09 +0200 Subject: Remove identity context from output of LinkedDataSignature (#4753) --- app/lib/activitypub/linked_data_signature.rb | 2 +- spec/lib/activitypub/linked_data_signature_spec.rb | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb index 4483339a9..adb8b6cdf 100644 --- a/app/lib/activitypub/linked_data_signature.rb +++ b/app/lib/activitypub/linked_data_signature.rb @@ -45,7 +45,7 @@ class ActivityPub::LinkedDataSignature signature = Base64.strict_encode64(creator.keypair.sign(OpenSSL::Digest::SHA256.new, to_be_signed)) - @json.merge('@context' => merge_context(@json['@context'], CONTEXT), 'signature' => options.merge('signatureValue' => signature)) + @json.merge('signature' => options.merge('signatureValue' => signature)) end private diff --git a/spec/lib/activitypub/linked_data_signature_spec.rb b/spec/lib/activitypub/linked_data_signature_spec.rb index ee4b68028..a4d6fe8c3 100644 --- a/spec/lib/activitypub/linked_data_signature_spec.rb +++ b/spec/lib/activitypub/linked_data_signature_spec.rb @@ -63,10 +63,6 @@ RSpec.describe ActivityPub::LinkedDataSignature do expect(subject).to be_a Hash end - it 'contains signature context' do - expect(subject['@context']).to include('https://www.w3.org/ns/activitystreams', 'https://w3id.org/identity/v1') - end - it 'contains signature' do expect(subject['signature']).to be_a Hash expect(subject['signature']['signatureValue']).to be_present -- cgit From af2d22f88cf84313e11a5f0b1f3a1170f721967e Mon Sep 17 00:00:00 2001 From: unarist Date: Fri, 1 Sep 2017 19:33:02 +0900 Subject: Fallback from perform_via_activitypub on private posts (#4758) Currently, private / direct posts via OStatus from AP compatible instance will be dropped due to failing to fetch AP version. So this fallbacks to OStatus handling: * when failed to fetch ActivityPub version * when status is neither :public nor :unlisted --- app/lib/ostatus/activity/creation.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'app/lib') diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb index 12488ab31..b28239a05 100644 --- a/app/lib/ostatus/activity/creation.rb +++ b/app/lib/ostatus/activity/creation.rb @@ -8,7 +8,11 @@ class OStatus::Activity::Creation < OStatus::Activity::Base end return [nil, false] if @account.suspended? - return perform_via_activitypub if activitypub_uri? + + 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}" -- cgit From 504737e860a13f9636fc47131ff27238236e8971 Mon Sep 17 00:00:00 2001 From: unarist Date: Fri, 1 Sep 2017 20:34:04 +0900 Subject: Convert OStatus tag to ActivityPub id on in_reply_to resolution (#4756) --- app/lib/ostatus/activity/base.rb | 10 ++++++++++ app/lib/ostatus/activity/creation.rb | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) (limited to 'app/lib') diff --git a/app/lib/ostatus/activity/base.rb b/app/lib/ostatus/activity/base.rb index da9a01759..1dc7abee3 100644 --- a/app/lib/ostatus/activity/base.rb +++ b/app/lib/ostatus/activity/base.rb @@ -56,6 +56,16 @@ class OStatus::Activity::Base 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 b28239a05..1a23c9efa 100644 --- a/app/lib/ostatus/activity/creation.rb +++ b/app/lib/ostatus/activity/creation.rb @@ -36,7 +36,7 @@ class OStatus::Activity::Creation < OStatus::Activity::Base language: content_language, visibility: visibility_scope, conversation: find_or_create_conversation, - thread: thread? ? find_status(thread.first) : nil + thread: thread? ? find_status(thread.first) || find_activitypub_status(thread.first, thread.second) : nil ) save_mentions(status) -- cgit From 8fd8f81ae7ab7c02c4cb75c3c65ff3ed8c193f38 Mon Sep 17 00:00:00 2001 From: unarist Date: Sat, 2 Sep 2017 04:00:43 +0900 Subject: Deduplicate with local status on Create activity (#4763) --- app/lib/activitypub/activity/create.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 2eea1827a..a6f1e60d9 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -25,7 +25,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity private def find_existing_status - status = Status.find_by(uri: object_uri) + status = status_from_uri(object_uri) status ||= Status.find_by(uri: @object['_:atomUri']) if @object['_:atomUri'].present? status end -- cgit From c2980d5b17d355985a6d5ac4eaa12371463363b3 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 1 Sep 2017 21:12:59 +0200 Subject: Do not rely on activity arriving exactly once after delete arrived (#4754) --- app/lib/activitypub/activity.rb | 9 +-------- app/lib/activitypub/activity/delete.rb | 12 ++++++------ 2 files changed, 7 insertions(+), 14 deletions(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 14e3ca784..b06dd6194 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -97,14 +97,7 @@ class ActivityPub::Activity end def delete_arrived_first?(uri) - key = "delete_upon_arrival:#{@account.id}:#{uri}" - - if redis.exists(key) - redis.del(key) - true - else - false - end + redis.exists("delete_upon_arrival:#{@account.id}:#{uri}") end def delete_later!(uri) diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index afa9a8079..767909fb1 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -5,12 +5,12 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity 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? - if status.nil? - delete_later!(object_uri) - else - forward_for_reblogs(status) - delete_now!(status) - end + delete_later!(object_uri) + + return if status.nil? + + forward_for_reblogs(status) + delete_now!(status) end private -- cgit From e482595a5dfd69efacffeddbd48f48c5b92deaea Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 1 Sep 2017 21:54:42 +0200 Subject: Add ActivityPub handler for Delete->Actor activities (#4761) --- app/lib/activitypub/activity/delete.rb | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index 767909fb1..9d804c86d 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -2,6 +2,20 @@ 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? @@ -13,8 +27,6 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity delete_now!(status) end - private - def forward_for_reblogs(status) return if @json['signature'].blank? -- cgit From 1b5806b74475213ae45b483d08a29daa40988f84 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 2 Sep 2017 14:01:23 +0200 Subject: Define missing JSON-LD properties (#4767) Using _: property names is discouraged, as in the future, canonicalization may throw an error when encountering that instead of discarding it silently like it does now. We are defining some ActivityStreams properties which we expect to land in ActivityStreams eventually, to ensure that future versions of Mastodon will remain compatible with this even once that happens. Those would be `locked`, `sensitive` and `Hashtag` We are defining a custom context inline for some properties which we do not expect to land in any other context. `atomUri`, `inReplyToAtomUri` and `conversation` are part of the custom defined OStatus context. --- app/lib/activitypub/activity/create.rb | 6 +++--- app/lib/activitypub/activity/delete.rb | 2 +- app/lib/activitypub/adapter.rb | 20 +++++++++++++++++++- app/serializers/activitypub/actor_serializer.rb | 4 +--- app/serializers/activitypub/delete_serializer.rb | 3 +-- app/serializers/activitypub/note_serializer.rb | 17 +++++++++++++---- .../activitypub/fetch_remote_status_service.rb | 4 ++-- app/services/activitypub/process_account_service.rb | 2 +- 8 files changed, 41 insertions(+), 17 deletions(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index a6f1e60d9..081e80570 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -26,7 +26,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def find_existing_status status = status_from_uri(object_uri) - status ||= Status.find_by(uri: @object['_:atomUri']) if @object['_:atomUri'].present? + status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present? status end @@ -43,7 +43,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity sensitive: @object['sensitive'] || false, visibility: visibility_from_audience, thread: replied_to_status, - conversation: conversation_from_uri(@object['_:conversation']), + conversation: conversation_from_uri(@object['conversation']), } end @@ -125,7 +125,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity @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 ||= status_from_uri(@object['inReplyToAtomUri']) if @object['inReplyToAtomUri'].present? @replied_to_status end end diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index 9d804c86d..4c6afb090 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -17,7 +17,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity 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? + status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present? delete_later!(object_uri) diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index 92210579e..fe4dddd38 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -1,6 +1,24 @@ # frozen_string_literal: true class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base + CONTEXT = { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + + { + 'locked' => 'as:locked', + '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 @@ -11,7 +29,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base def serializable_hash(options = nil) options = serialization_options(options) - serialized_hash = { '@context': [ActivityPub::TagManager::CONTEXT, 'https://w3id.org/security/v1'] }.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/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index a72ecee24..f004dc326 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -6,12 +6,10 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer attributes :id, :type, :following, :followers, :inbox, :outbox, :shared_inbox, :preferred_username, :name, :summary, - :url + :url, :locked has_one :public_key, serializer: ActivityPub::PublicKeySerializer - attribute :locked, key: '_:locked' - class ImageSerializer < ActiveModel::Serializer include RoutingHelper diff --git a/app/serializers/activitypub/delete_serializer.rb b/app/serializers/activitypub/delete_serializer.rb index a041c577b..87a43b95d 100644 --- a/app/serializers/activitypub/delete_serializer.rb +++ b/app/serializers/activitypub/delete_serializer.rb @@ -2,8 +2,7 @@ class ActivityPub::DeleteSerializer < ActiveModel::Serializer class TombstoneSerializer < ActiveModel::Serializer - attributes :id, :type - attribute :atom_uri, key: '_:atomUri' + attributes :id, :type, :atom_uri def id ActivityPub::TagManager.instance.uri_for(object) diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 15031dfdc..d42f54263 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -3,14 +3,13 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer attributes :id, :type, :summary, :content, :in_reply_to, :published, :url, - :attributed_to, :to, :cc, :sensitive + :attributed_to, :to, :cc, :sensitive, + :atom_uri, :in_reply_to_atom_uri, + :conversation has_many :media_attachments, key: :attachment has_many :virtual_tags, key: :tag - attribute :atom_uri, key: '_:atomUri', if: :local? - attribute :in_reply_to_atom_uri, key: '_:inReplyToAtomUri' - def id ActivityPub::TagManager.instance.uri_for(object) end @@ -62,6 +61,8 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer end def atom_uri + return unless object.local? + ::TagManager.instance.uri_for(object) end @@ -71,6 +72,14 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer ::TagManager.instance.uri_for(object.thread) end + def conversation + if object.conversation.uri? + object.conversation.uri + else + TagManager.instance.unique_tag(object.conversation.created_at, object.conversation.id, 'Conversation') + end + end + def local? object.account.local? end diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb index c114515cd..68ca58d62 100644 --- a/app/services/activitypub/fetch_remote_status_service.rb +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -25,8 +25,8 @@ class ActivityPub::FetchRemoteStatusService < BaseService def activity_json if %w(Note Article).include? @json['type'] { - 'type' => 'Create', - 'actor' => first_of_value(@json['attributedTo']), + 'type' => 'Create', + 'actor' => first_of_value(@json['attributedTo']), 'object' => @json, } else diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 99f9dbdc2..44798d051 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -50,7 +50,7 @@ class ActivityPub::ProcessAccountService < BaseService @account.avatar_remote_url = image_url('icon') @account.header_remote_url = image_url('image') @account.public_key = public_key || '' - @account.locked = @json['_:locked'] || false + @account.locked = @json['locked'] || false @account.save! end -- cgit From 6a4e2db661f47a318bbf93a07ba9f16f7bac3ee0 Mon Sep 17 00:00:00 2001 From: unarist Date: Sun, 3 Sep 2017 00:42:47 +0900 Subject: Raise an error for remote url in StatusFinder (#4776) * Raise an error for remote url in StatusFinder Previous implementation had allowed remote url with status id which also exists on local. Then that bug leads /api/web/embed to return wrong embed url. * Fix oembed_controller_spec --- app/lib/status_finder.rb | 2 ++ spec/controllers/api/oembed_controller_spec.rb | 1 + spec/lib/status_finder_spec.rb | 10 ++++++++++ 3 files changed, 13 insertions(+) (limited to 'app/lib') diff --git a/app/lib/status_finder.rb b/app/lib/status_finder.rb index bd910f12b..4d1aed297 100644 --- a/app/lib/status_finder.rb +++ b/app/lib/status_finder.rb @@ -10,6 +10,8 @@ class StatusFinder 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]).status diff --git a/spec/controllers/api/oembed_controller_spec.rb b/spec/controllers/api/oembed_controller_spec.rb index 43631a7e5..7af4a6a5b 100644 --- a/spec/controllers/api/oembed_controller_spec.rb +++ b/spec/controllers/api/oembed_controller_spec.rb @@ -8,6 +8,7 @@ RSpec.describe Api::OEmbedController, type: :controller do describe 'GET #show' do before do + request.host = Rails.configuration.x.local_domain get :show, params: { url: account_stream_entry_url(alice, status.stream_entry) }, format: :json end diff --git a/spec/lib/status_finder_spec.rb b/spec/lib/status_finder_spec.rb index 5c2f2dbe8..3ef086736 100644 --- a/spec/lib/status_finder_spec.rb +++ b/spec/lib/status_finder_spec.rb @@ -34,6 +34,16 @@ describe StatusFinder do end end + context 'with a remote url even if id exists on local' do + let(:status) { Fabricate(:status) } + let(:url) { "https://example.com/users/test/statuses/#{status.id}" } + subject { described_class.new(url) } + + it 'raises an error' do + expect { subject.status }.to raise_error(ActiveRecord::RecordNotFound) + end + end + context 'with a plausible url' do let(:url) { 'https://example.com/users/test/updates/123/embed' } subject { described_class.new(url) } -- cgit From d3b67461735f9a1c38a7eee655a8131bcf6a0cbf Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 2 Sep 2017 20:44:41 +0200 Subject: Make "unfollow" undo pending outgoing follow request too (#4781) * Make "unfollow" undo pending outgoing follow request too * Add cancel button to web UI when awaiting follow request approval * Make the hourglass button do the cancelling --- .../mastodon/containers/account_container.js | 2 +- .../mastodon/features/account/components/header.js | 4 ++-- .../containers/header_container.js | 2 +- .../mastodon/locales/defaultMessages.json | 2 +- app/javascript/mastodon/locales/en.json | 2 +- app/lib/activitypub/activity/undo.rb | 2 ++ app/services/process_interaction_service.rb | 4 ++++ app/services/unfollow_service.rb | 28 ++++++++++++++++++---- 8 files changed, 36 insertions(+), 10 deletions(-) (limited to 'app/lib') diff --git a/app/javascript/mastodon/containers/account_container.js b/app/javascript/mastodon/containers/account_container.js index ca1efd0e5..7c77cb764 100644 --- a/app/javascript/mastodon/containers/account_container.js +++ b/app/javascript/mastodon/containers/account_container.js @@ -32,7 +32,7 @@ const makeMapStateToProps = () => { const mapDispatchToProps = (dispatch, { intl }) => ({ onFollow (account) { - if (account.getIn(['relationship', 'following'])) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { if (this.unfollowModal) { dispatch(openModal('CONFIRM', { message: @{account.get('acct')} }} />, diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 8f4dd352d..6eb51a5c7 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -11,7 +11,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, - requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, }); const makeMapStateToProps = () => { @@ -102,7 +102,7 @@ export default class Header extends ImmutablePureComponent { if (account.getIn(['relationship', 'requested'])) { actionBtn = (
- +
); } else if (!account.getIn(['relationship', 'blocking'])) { diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index baa81bbc2..dcee78b3e 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -38,7 +38,7 @@ const makeMapStateToProps = () => { const mapDispatchToProps = (dispatch, { intl }) => ({ onFollow (account) { - if (account.getIn(['relationship', 'following'])) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { if (this.unfollowModal) { dispatch(openModal('CONFIRM', { message: @{account.get('acct')} }} />, diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index b13d1646d..a0cb8f978 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -436,7 +436,7 @@ "id": "account.follow" }, { - "defaultMessage": "Awaiting approval", + "defaultMessage": "Awaiting approval. Click to cancel follow request", "id": "account.requested" }, { diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index aac87ac70..6d9b9c208 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -12,7 +12,7 @@ "account.mute": "Mute @{name}", "account.posts": "Posts", "account.report": "Report @{name}", - "account.requested": "Awaiting approval", + "account.requested": "Awaiting approval. Click to cancel follow request", "account.share": "Share @{name}'s profile", "account.unblock": "Unblock @{name}", "account.unblock_domain": "Unhide {domain}", diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb index 097b1dba4..4b0905de2 100644 --- a/app/lib/activitypub/activity/undo.rb +++ b/app/lib/activitypub/activity/undo.rb @@ -33,6 +33,8 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity 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 diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb index cc99cde03..d04e926e7 100644 --- a/app/services/process_interaction_service.rb +++ b/app/services/process_interaction_service.rb @@ -67,10 +67,13 @@ class ProcessInteractionService < BaseService def follow!(account, target_account) follow = account.follow!(target_account) + FollowRequest.find_by(account: account, target_account: target_account)&.destroy NotifyService.new.call(target_account, follow) end def follow_request!(account, target_account) + return if account.requested?(target_account) + follow_request = FollowRequest.create!(account: account, target_account: target_account) NotifyService.new.call(target_account, follow_request) end @@ -88,6 +91,7 @@ class ProcessInteractionService < BaseService def unfollow!(account, target_account) account.unfollow!(target_account) + FollowRequest.find_by(account: account, target_account: target_account)&.destroy end def reflect_block!(account, target_account) diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index bf151ee28..73a64929f 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -5,14 +5,34 @@ class UnfollowService < BaseService # @param [Account] source_account Where to unfollow from # @param [Account] target_account Which to unfollow def call(source_account, target_account) - follow = source_account.unfollow!(target_account) + @source_account = source_account + @target_account = target_account + + unfollow! || undo_follow_request! + end + + private + + def unfollow! + follow = Follow.find_by(account: @source_account, target_account: @target_account) + return unless follow - create_notification(follow) unless target_account.local? - UnmergeWorker.perform_async(target_account.id, source_account.id) + + follow.destroy! + create_notification(follow) unless @target_account.local? + UnmergeWorker.perform_async(@target_account.id, @source_account.id) follow end - private + def undo_follow_request! + follow_request = FollowRequest.find_by(account: @source_account, target_account: @target_account) + + return unless follow_request + + follow_request.destroy! + create_notification(follow_request) unless @target_account.local? + follow_request + end def create_notification(follow) if follow.target_account.ostatus? -- cgit From 37fdddd927614d550aa47f142e3adc32cf70810b Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 2 Sep 2017 23:13:35 +0200 Subject: Rename "locked" to "manuallyApprovesFollowers" in ActivityPub (#4779) See: --- app/lib/activitypub/adapter.rb | 15 +++++++-------- app/serializers/activitypub/actor_serializer.rb | 6 +++++- app/services/activitypub/process_account_service.rb | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index fe4dddd38..6ed66a239 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -7,14 +7,13 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base 'https://w3id.org/security/v1', { - 'locked' => 'as:locked', - 'sensitive' => 'as:sensitive', - 'Hashtag' => 'as:Hashtag', - - 'ostatus' => 'http://ostatus.org#', - 'atomUri' => 'ostatus:atomUri', - 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', - 'conversation' => 'ostatus:conversation', + 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers', + 'sensitive' => 'as:sensitive', + 'Hashtag' => 'as:Hashtag', + 'ostatus' => 'http://ostatus.org#', + 'atomUri' => 'ostatus:atomUri', + 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', + 'conversation' => 'ostatus:conversation', }, ], }.freeze diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index f004dc326..25521eca9 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -6,7 +6,7 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer attributes :id, :type, :following, :followers, :inbox, :outbox, :shared_inbox, :preferred_username, :name, :summary, - :url, :locked + :url, :manually_approves_followers has_one :public_key, serializer: ActivityPub::PublicKeySerializer @@ -90,4 +90,8 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer def header_exists? object.header.exists? end + + def manually_approves_followers + object.locked + end end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 44798d051..a26b39cb5 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -50,7 +50,7 @@ class ActivityPub::ProcessAccountService < BaseService @account.avatar_remote_url = image_url('icon') @account.header_remote_url = image_url('image') @account.public_key = public_key || '' - @account.locked = @json['locked'] || false + @account.locked = @json['manuallyApprovesFollowers'] || false @account.save! end -- cgit From 9b50a9dd835c3a08effc86a6ef3e29e3a16e3d27 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 4 Sep 2017 18:26:33 +0200 Subject: Fix some ActivityPub JSON bugs (#4796) - Fix assumption that `url` is always a string. Handle it if it's an array of strings, array of objects, object, or string, both for accounts and for objects - `sharedInbox` is actually supposed to be under `endpoints`, handle both cases and adjust the serializer --- app/lib/activitypub/activity/create.rb | 12 +++++++++++- app/serializers/activitypub/actor_serializer.rb | 18 +++++++++++++++--- app/services/activitypub/process_account_service.rb | 18 ++++++++++++++---- 3 files changed, 40 insertions(+), 8 deletions(-) (limited to 'app/lib') diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 081e80570..9a34484f5 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -33,7 +33,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def status_params { uri: @object['id'], - url: @object['url'] || @object['id'], + url: object_url || @object['id'], account: @account, text: text_from_content || '', language: language_from_content, @@ -147,6 +147,16 @@ class ActivityPub::Activity::Create < ActivityPub::Activity @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 diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index 25521eca9..a11178f5b 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -4,7 +4,7 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer include RoutingHelper attributes :id, :type, :following, :followers, - :inbox, :outbox, :shared_inbox, + :inbox, :outbox, :preferred_username, :name, :summary, :url, :manually_approves_followers @@ -24,6 +24,18 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer end end + class EndpointsSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :shared_inbox + + def shared_inbox + inbox_url + end + end + + has_one :endpoints, serializer: EndpointsSerializer + has_one :icon, serializer: ImageSerializer, if: :avatar_exists? has_one :image, serializer: ImageSerializer, if: :header_exists? @@ -51,8 +63,8 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer account_outbox_url(object) end - def shared_inbox - inbox_url + def endpoints + object end def preferred_username diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index a26b39cb5..29eb1c2e1 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -6,7 +6,7 @@ class ActivityPub::ProcessAccountService < BaseService # Should be called with confirmed valid JSON # and WebFinger-resolved username and domain def call(username, domain, json) - return unless json['inbox'].present? + return if json['inbox'].blank? @json = json @uri = @json['id'] @@ -42,9 +42,9 @@ class ActivityPub::ProcessAccountService < BaseService @account.protocol = :activitypub @account.inbox_url = @json['inbox'] || '' @account.outbox_url = @json['outbox'] || '' - @account.shared_inbox_url = @json['sharedInbox'] || '' + @account.shared_inbox_url = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || '' @account.followers_url = @json['followers'] || '' - @account.url = @json['url'] || @uri + @account.url = url || @uri @account.display_name = @json['name'] || '' @account.note = @json['summary'] || '' @account.avatar_remote_url = image_url('icon') @@ -62,7 +62,7 @@ class ActivityPub::ProcessAccountService < BaseService value = first_of_value(@json[key]) return if value.nil? - return @json[key]['url'] if @json[key].is_a?(Hash) + return value['url'] if value.is_a?(Hash) image = fetch_resource(value) image['url'] if image @@ -78,6 +78,16 @@ class ActivityPub::ProcessAccountService < BaseService key['publicKeyPem'] if key end + def url + return if @json['url'].blank? + + value = first_of_value(@json['url']) + + return value if value.is_a?(String) + + value['href'] + end + def auto_suspend? domain_block && domain_block.suspend? end -- cgit From e7adbf572a50f77590f889bb4d7cb9efb6fc7036 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 6 Sep 2017 19:01:28 +0200 Subject: Switch to static URIs, new URI format in both protocols for new statuses (#4815) * Decouple Status#local? from uri being nil * Replace on-the-fly URI generation with stored URIs - Generate URI in after_save hook for local statuses - Use static value in TagManager when available, fallback to tag format - Make TagManager use ActivityPub::TagManager to understand new format - Adjust tests * Use other heuristic for locality of old statuses, do not perform long query * Exclude tombstone stream entries from Atom feed * Prevent nil statuses from landing in Pubsubhubbub::DistributionWorker * Fix URI not being saved (#4818) * Add more specs for Status * Save generated uri immediately and also fix method order to minimize diff. * Fix alternate HTML URL in Atom * Fix tests * Remove not-null constraint from statuses migration to speed it up --- app/controllers/accounts_controller.rb | 2 +- app/lib/ostatus/atom_serializer.rb | 4 +-- app/lib/tag_manager.rb | 13 ++++++--- app/models/status.rb | 14 +++++++++- app/workers/pubsubhubbub/distribution_worker.rb | 2 +- db/migrate/20170905165803_add_local_to_statuses.rb | 5 ++++ db/schema.rb | 3 ++- spec/fabricators/status_fabricator.rb | 4 +++ spec/lib/activitypub/activity/delete_spec.rb | 2 +- spec/lib/activitypub/activity/undo_spec.rb | 2 +- spec/lib/formatter_spec.rb | 4 +-- spec/lib/ostatus/atom_serializer_spec.rb | 31 +++++++++++----------- spec/lib/tag_manager_spec.rb | 15 ++--------- spec/models/status_spec.rb | 27 +++++++++++++++++-- spec/services/fetch_link_card_service_spec.rb | 2 +- 15 files changed, 84 insertions(+), 46 deletions(-) create mode 100644 db/migrate/20170905165803_add_local_to_statuses.rb (limited to 'app/lib') diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index afa0417fa..26ab6636b 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -22,7 +22,7 @@ class AccountsController < ApplicationController format.atom do @entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id]) - render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.to_a)) + render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? })) end format.json do diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb index 81fae4140..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") @@ -86,7 +86,7 @@ class OStatus::AtomSerializer 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? 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 diff --git a/app/models/status.rb b/app/models/status.rb index f44f79aaf..53eff0377 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -22,6 +22,7 @@ # reblogs_count :integer default(0), not null # language :string # conversation_id :integer +# local :boolean default(FALSE) # class Status < ApplicationRecord @@ -84,7 +85,7 @@ class Status < ApplicationRecord end def local? - uri.nil? + attributes['local'] || uri.nil? end def reblog? @@ -131,11 +132,14 @@ class Status < ApplicationRecord !sensitive? && media_attachments.any? end + after_create :store_uri, if: :local? + before_validation :prepare_contents, if: :local? before_validation :set_reblog before_validation :set_visibility before_validation :set_conversation before_validation :set_sensitivity + before_validation :set_local class << self def not_in_filtered_languages(account) @@ -253,6 +257,10 @@ class Status < ApplicationRecord private + def store_uri + update_attribute(:uri, ActivityPub::TagManager.instance.uri_for(self)) if uri.nil? + end + def prepare_contents text&.strip! spoiler_text&.strip! @@ -292,4 +300,8 @@ class Status < ApplicationRecord thread.account_id end end + + def set_local + self.local = account.local? + end end diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb index 2a5e60fa0..524f6849f 100644 --- a/app/workers/pubsubhubbub/distribution_worker.rb +++ b/app/workers/pubsubhubbub/distribution_worker.rb @@ -6,7 +6,7 @@ class Pubsubhubbub::DistributionWorker sidekiq_options queue: 'push' def perform(stream_entry_ids) - stream_entries = StreamEntry.where(id: stream_entry_ids).includes(:status).reject { |e| e.status&.direct_visibility? } + stream_entries = StreamEntry.where(id: stream_entry_ids).includes(:status).reject { |e| e.status.nil? || e.status.direct_visibility? } return if stream_entries.empty? diff --git a/db/migrate/20170905165803_add_local_to_statuses.rb b/db/migrate/20170905165803_add_local_to_statuses.rb new file mode 100644 index 000000000..e89a0469d --- /dev/null +++ b/db/migrate/20170905165803_add_local_to_statuses.rb @@ -0,0 +1,5 @@ +class AddLocalToStatuses < ActiveRecord::Migration[5.1] + def change + add_column :statuses, :local, :boolean, null: true, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index ea7edc2f9..21bde2086 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.define(version: 20170905044538) do +ActiveRecord::Schema.define(version: 20170905165803) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -315,6 +315,7 @@ ActiveRecord::Schema.define(version: 20170905044538) do t.integer "reblogs_count", default: 0, null: false t.string "language" t.bigint "conversation_id" + t.boolean "local", default: false t.index ["account_id", "id"], name: "index_statuses_on_account_id_id" t.index ["conversation_id"], name: "index_statuses_on_conversation_id" t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id" diff --git a/spec/fabricators/status_fabricator.rb b/spec/fabricators/status_fabricator.rb index 8ec5f4ba7..04bbbcf4b 100644 --- a/spec/fabricators/status_fabricator.rb +++ b/spec/fabricators/status_fabricator.rb @@ -1,4 +1,8 @@ Fabricator(:status) do account text "Lorem ipsum dolor sit amet" + + after_build do |status| + status.uri = Faker::Internet.device_token if !status.account.local? && status.uri.nil? + end end diff --git a/spec/lib/activitypub/activity/delete_spec.rb b/spec/lib/activitypub/activity/delete_spec.rb index 65e743abb..38254e31c 100644 --- a/spec/lib/activitypub/activity/delete_spec.rb +++ b/spec/lib/activitypub/activity/delete_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe ActivityPub::Activity::Delete do - let(:sender) { Fabricate(:account) } + let(:sender) { Fabricate(:account, domain: 'example.com') } let(:status) { Fabricate(:status, account: sender, uri: 'foobar') } let(:json) do diff --git a/spec/lib/activitypub/activity/undo_spec.rb b/spec/lib/activitypub/activity/undo_spec.rb index 4629a033f..14c68efe5 100644 --- a/spec/lib/activitypub/activity/undo_spec.rb +++ b/spec/lib/activitypub/activity/undo_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe ActivityPub::Activity::Undo do - let(:sender) { Fabricate(:account) } + let(:sender) { Fabricate(:account, domain: 'example.com') } let(:json) do { diff --git a/spec/lib/formatter_spec.rb b/spec/lib/formatter_spec.rb index dfe1d8b8f..ab04ccbab 100644 --- a/spec/lib/formatter_spec.rb +++ b/spec/lib/formatter_spec.rb @@ -178,7 +178,7 @@ RSpec.describe Formatter do end context 'with remote status' do - let(:status) { Fabricate(:status, text: 'Beep boop', uri: 'beepboop') } + let(:status) { Fabricate(:status, account: remote_account, text: 'Beep boop') } it 'reformats' do is_expected.to eq 'Beep boop' @@ -226,7 +226,7 @@ RSpec.describe Formatter do end context 'with remote status' do - let(:status) { Fabricate(:status, text: '', uri: 'beep boop') } + let(:status) { Fabricate(:status, account: remote_account, text: '') } it 'returns tag-stripped text' do is_expected.to eq '' diff --git a/spec/lib/ostatus/atom_serializer_spec.rb b/spec/lib/ostatus/atom_serializer_spec.rb index 301a0ce30..0451eceeb 100644 --- a/spec/lib/ostatus/atom_serializer_spec.rb +++ b/spec/lib/ostatus/atom_serializer_spec.rb @@ -403,8 +403,7 @@ RSpec.describe OStatus::AtomSerializer do it 'returns element whose rendered view triggers creation when processed' do remote_account = Account.create!(username: 'username') - remote_status = Fabricate(:status, account: remote_account) - remote_status.stream_entry.update!(created_at: '2000-01-01T00:00:00Z') + remote_status = Fabricate(:status, account: remote_account, created_at: '2000-01-01T00:00:00Z') entry = OStatus::AtomSerializer.new.entry(remote_status.stream_entry, true) entry.nodes.delete_if { |node| node[:type] == 'application/activity+json' } # Remove ActivityPub link to simplify test @@ -421,7 +420,7 @@ RSpec.describe OStatus::AtomSerializer do ProcessFeedService.new.call(xml, account) - expect(Status.find_by(uri: "tag:remote,2000-01-01:objectId=#{remote_status.id}:objectType=Status")).to be_instance_of Status + expect(Status.find_by(uri: "https://remote/users/#{remote_status.account.to_param}/statuses/#{remote_status.id}")).to be_instance_of Status end end @@ -465,12 +464,11 @@ RSpec.describe OStatus::AtomSerializer do end it 'appends id element with unique tag' do - status = Fabricate(:status, reblog_of_id: nil) - status.stream_entry.update!(created_at: '2000-01-01T00:00:00Z') + status = Fabricate(:status, reblog_of_id: nil, created_at: '2000-01-01T00:00:00Z') entry = OStatus::AtomSerializer.new.entry(status.stream_entry) - expect(entry.id.text).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status" + expect(entry.id.text).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}" end it 'appends published element with created date' do @@ -515,7 +513,7 @@ RSpec.describe OStatus::AtomSerializer do entry = OStatus::AtomSerializer.new.entry(reblog.stream_entry) object = entry.nodes.find { |node| node.name == 'activity:object' } - expect(object.id.text).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{reblogged.id}:objectType=Status" + expect(object.id.text).to eq "https://cb6e6126.ngrok.io/users/#{reblogged.account.to_param}/statuses/#{reblogged.id}" end it 'does not append activity:object element if target is not present' do @@ -532,7 +530,7 @@ RSpec.describe OStatus::AtomSerializer do link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' } expect(link[:type]).to eq 'text/html' - expect(link[:href]).to eq "https://cb6e6126.ngrok.io/users/username/updates/#{status.stream_entry.id}" + expect(link[:href]).to eq "https://cb6e6126.ngrok.io/@username/#{status.id}" end it 'appends link element for itself' do @@ -553,7 +551,7 @@ RSpec.describe OStatus::AtomSerializer do entry = OStatus::AtomSerializer.new.entry(reply_status.stream_entry) in_reply_to = entry.nodes.find { |node| node.name == 'thr:in-reply-to' } - expect(in_reply_to[:ref]).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{in_reply_to_status.id}:objectType=Status" + expect(in_reply_to[:ref]).to eq "https://cb6e6126.ngrok.io/users/#{in_reply_to_status.account.to_param}/statuses/#{in_reply_to_status.id}" end it 'does not append thr:in-reply-to element if not threaded' do @@ -934,7 +932,7 @@ RSpec.describe OStatus::AtomSerializer do favourite_salmon = OStatus::AtomSerializer.new.favourite_salmon(favourite) object = favourite_salmon.nodes.find { |node| node.name == 'activity:object' } - expect(object.id.text).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status" + expect(object.id.text).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}" end it 'appends thr:in-reply-to element for status' do @@ -945,7 +943,7 @@ RSpec.describe OStatus::AtomSerializer do favourite_salmon = OStatus::AtomSerializer.new.favourite_salmon(favourite) in_reply_to = favourite_salmon.nodes.find { |node| node.name == 'thr:in-reply-to' } - expect(in_reply_to.ref).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status" + expect(in_reply_to.ref).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}" expect(in_reply_to.href).to eq "https://cb6e6126.ngrok.io/@username/#{status.id}" end @@ -1034,7 +1032,7 @@ RSpec.describe OStatus::AtomSerializer do unfavourite_salmon = OStatus::AtomSerializer.new.unfavourite_salmon(favourite) object = unfavourite_salmon.nodes.find { |node| node.name == 'activity:object' } - expect(object.id.text).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status" + expect(object.id.text).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}" end it 'appends thr:in-reply-to element for status' do @@ -1045,7 +1043,7 @@ RSpec.describe OStatus::AtomSerializer do unfavourite_salmon = OStatus::AtomSerializer.new.unfavourite_salmon(favourite) in_reply_to = unfavourite_salmon.nodes.find { |node| node.name == 'thr:in-reply-to' } - expect(in_reply_to.ref).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status" + expect(in_reply_to.ref).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}" expect(in_reply_to.href).to eq "https://cb6e6126.ngrok.io/@username/#{status.id}" end @@ -1453,7 +1451,7 @@ RSpec.describe OStatus::AtomSerializer do it 'appends id element with URL for status' do status = Fabricate(:status, created_at: '2000-01-01T00:00:00Z') object = OStatus::AtomSerializer.new.object(status) - expect(object.id.text).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status" + expect(object.id.text).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}" end it 'appends published element with created date' do @@ -1463,7 +1461,8 @@ RSpec.describe OStatus::AtomSerializer do end it 'appends updated element with updated date' do - status = Fabricate(:status, updated_at: '2000-01-01T00:00:00Z') + status = Fabricate(:status) + status.updated_at = '2000-01-01T00:00:00Z' object = OStatus::AtomSerializer.new.object(status) expect(object.updated.text).to eq '2000-01-01T00:00:00Z' end @@ -1523,7 +1522,7 @@ RSpec.describe OStatus::AtomSerializer do entry = OStatus::AtomSerializer.new.object(reply) in_reply_to = entry.nodes.find { |node| node.name == 'thr:in-reply-to' } - expect(in_reply_to.ref).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{thread.id}:objectType=Status" + expect(in_reply_to.ref).to eq "https://cb6e6126.ngrok.io/users/#{thread.account.to_param}/statuses/#{thread.id}" expect(in_reply_to.href).to eq "https://cb6e6126.ngrok.io/@username/#{thread.id}" end diff --git a/spec/lib/tag_manager_spec.rb b/spec/lib/tag_manager_spec.rb index 1fae6bec4..1cd6e0a6f 100644 --- a/spec/lib/tag_manager_spec.rb +++ b/spec/lib/tag_manager_spec.rb @@ -157,23 +157,12 @@ RSpec.describe TagManager do describe '#uri_for' do subject { TagManager.instance.uri_for(target) } - context 'activity object' do - let(:target) { Fabricate(:status, reblog: Fabricate(:status)).stream_entry } - - before { target.update!(created_at: '2000-01-01T00:00:00Z') } - - it 'returns the unique tag for status' do - expect(target.object_type).to eq :activity - is_expected.to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{target.id}:objectType=Status" - end - end - context 'comment object' do let(:target) { Fabricate(:status, created_at: '2000-01-01T00:00:00Z', reply: true) } it 'returns the unique tag for status' do expect(target.object_type).to eq :comment - is_expected.to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{target.id}:objectType=Status" + is_expected.to eq target.uri end end @@ -182,7 +171,7 @@ RSpec.describe TagManager do it 'returns the unique tag for status' do expect(target.object_type).to eq :note - is_expected.to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{target.id}:objectType=Status" + is_expected.to eq target.uri end end diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb index 626fc3f98..484effd5e 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -13,9 +13,15 @@ RSpec.describe Status, type: :model do end it 'returns false if a remote URI is set' do - subject.uri = 'a' + alice.update(domain: 'example.com') + subject.save expect(subject.local?).to be false end + + it 'returns true if a URI is set and `local` is true' do + subject.update(uri: 'example.com', local: true) + expect(subject.local?).to be true + end end describe '#reblog?' do @@ -495,7 +501,7 @@ RSpec.describe Status, type: :model do end end - describe 'before_create' do + describe 'before_validation' do it 'sets account being replied to correctly over intermediary nodes' do first_status = Fabricate(:status, account: bob) intermediary = Fabricate(:status, thread: first_status, account: alice) @@ -512,5 +518,22 @@ RSpec.describe Status, type: :model do parent = Fabricate(:status, text: 'First') expect(Status.create(account: alice, thread: parent, text: 'Response').conversation_id).to eq parent.conversation_id end + + it 'sets `local` to true for status by local account' do + expect(Status.create(account: alice, text: 'foo').local).to be true + end + + it 'sets `local` to false for status by remote account' do + alice.update(domain: 'example.com') + expect(Status.create(account: alice, text: 'foo').local).to be false + end + end + + describe 'after_create' do + it 'saves ActivityPub uri as uri for local status' do + status = Status.create(account: alice, text: 'foo') + status.reload + expect(status.uri).to start_with('https://') + end end end diff --git a/spec/services/fetch_link_card_service_spec.rb b/spec/services/fetch_link_card_service_spec.rb index 3a0786d03..b0aa740ac 100644 --- a/spec/services/fetch_link_card_service_spec.rb +++ b/spec/services/fetch_link_card_service_spec.rb @@ -55,7 +55,7 @@ RSpec.describe FetchLinkCardService do end context 'in a remote status' do - let(:status) { Fabricate(:status, uri: 'abc', text: 'Habt ihr ein paar gute Links zu #Wannacry herumfliegen? Ich will mal unter
https://github.com/qbi/WannaCry was sammeln. !security ') } + let(:status) { Fabricate(:status, account: Fabricate(:account, domain: 'example.com'), text: 'Habt ihr ein paar gute Links zu #Wannacry herumfliegen? Ich will mal unter
https://github.com/qbi/WannaCry was sammeln. !security ') } it 'parses out URLs' do expect(a_request(:head, 'https://github.com/qbi/WannaCry')).to have_been_made.at_least_once -- cgit