diff --git a/app/controllers/activity_pub/application_controller.rb b/app/controllers/activity_pub/application_controller.rb index f9c2b14fe7701c945a7e1d3105d5518e6751401e..70cf881c857a0434f1c34d77d8a9f8b015fc0cbe 100644 --- a/app/controllers/activity_pub/application_controller.rb +++ b/app/controllers/activity_pub/application_controller.rb @@ -8,6 +8,8 @@ class ApplicationController < ::ApplicationController skip_before_action :authenticate_user! after_action :set_content_type + protect_from_forgery with: :null_session + def can?(object, action, subject = :global) Ability.allowed?(object, action, subject) end diff --git a/app/controllers/activity_pub/projects/releases_controller.rb b/app/controllers/activity_pub/projects/releases_controller.rb index 7c4c2a0322be69108a07ef334f2dcdde39cc3b44..eeff96a5ef7047c9c2900cc965d47da30b286849 100644 --- a/app/controllers/activity_pub/projects/releases_controller.rb +++ b/app/controllers/activity_pub/projects/releases_controller.rb @@ -5,15 +5,27 @@ module Projects class ReleasesController < ApplicationController feature_category :release_orchestration + before_action :enforce_payload, only: :inbox + def index opts = { - inbox: nil, + inbox: inbox_project_releases_url(@project), outbox: outbox_project_releases_url(@project) } render json: ActivityPub::ReleasesActorSerializer.new.represent(@project, opts) end + def inbox + service = inbox_service + success = service ? service.execute : true + + response = { success: success } + response[:errors] = service.errors unless success + + render json: response + end + def outbox serializer = ActivityPub::ReleasesOutboxSerializer.new.with_pagination(request, response) render json: serializer.represent(releases) @@ -24,6 +36,39 @@ def outbox def releases(params = {}) ReleasesFinder.new(@project, current_user, params).execute end + + def enforce_payload + return if payload + + head :unprocessable_entity + false + end + + def payload + @payload ||= begin + Gitlab::Json.parse(request.body.read) + rescue JSON::ParserError + nil + end + end + + def follow? + payload['type'] == 'Follow' + end + + def unfollow? + undo = payload['type'] == 'Undo' + object = payload['object'] + follow = object.present? && object.is_a?(Hash) && object['type'] == 'Follow' + undo && follow + end + + def inbox_service + return ReleasesFollowService.new(project, payload) if follow? + return ReleasesUnfollowService.new(project, payload) if unfollow? + + nil + end end end end diff --git a/app/models/activity_pub/releases_subscription.rb b/app/models/activity_pub/releases_subscription.rb index a6304f1fc3544beac741180cf6afc3469e4a81e9..0a4293b2bdea71872e26ad9497e3f3fc50fbd4d7 100644 --- a/app/models/activity_pub/releases_subscription.rb +++ b/app/models/activity_pub/releases_subscription.rb @@ -11,12 +11,12 @@ class ReleasesSubscription < ApplicationRecord validates :payload, json_schema: { filename: 'activity_pub_follow_payload' }, allow_blank: true validates :subscriber_url, presence: true, uniqueness: { case_sensitive: false, scope: :project_id }, public_url: true - validates :subscriber_inbox_url, uniqueness: { case_sensitive: false, scope: :project_id }, + validates :subscriber_inbox_url, uniqueness: { case_sensitive: false, scope: :project_id, allow_nil: true }, public_url: { allow_nil: true } validates :shared_inbox_url, public_url: { allow_nil: true } - def self.find_by_subscriber_url(subscriber_url) - find_by('LOWER(subscriber_url) = ?', subscriber_url.downcase) + def self.find_by_project_and_subscriber(project_id, subscriber_url) + find_by('project_id = ? AND LOWER(subscriber_url) = ?', project_id, subscriber_url.downcase) end end end diff --git a/app/services/activity_pub/projects/releases_follow_service.rb b/app/services/activity_pub/projects/releases_follow_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..3d877a1d08377bad993a6a9b4e29ea33f30a5ddb --- /dev/null +++ b/app/services/activity_pub/projects/releases_follow_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module ActivityPub + module Projects + class ReleasesFollowService < ReleasesSubscriptionService + def execute + unless subscriber_url + errors << "You need to provide an actor id for your subscriber" + return false + end + + return true if previous_subscription.present? + + subscription = ReleasesSubscription.new( + subscriber_url: subscriber_url, + subscriber_inbox_url: subscriber_inbox_url, + payload: payload, + project: project + ) + + unless subscription.save + errors.concat(subscription.errors.full_messages) + return false + end + + enqueue_subscription(subscription) + true + end + + private + + def subscriber_inbox_url + return unless payload['actor'].is_a?(Hash) + + payload['actor']['inbox'] + end + + def enqueue_subscription(subscription) + ReleasesSubscriptionWorker.perform_async(subscription.id) + end + end + end +end diff --git a/app/services/activity_pub/projects/releases_subscription_service.rb b/app/services/activity_pub/projects/releases_subscription_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..27d0e19a172770b592528ece1282a8ea9e9a40e0 --- /dev/null +++ b/app/services/activity_pub/projects/releases_subscription_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ActivityPub + module Projects + class ReleasesSubscriptionService + attr_reader :errors + + def initialize(project, payload) + @project = project + @payload = payload + @errors = [] + end + + def execute + raise "not implemented: abstract class, do not use directly." + end + + private + + attr_reader :project, :payload + + def subscriber_url + return unless payload['actor'] + return payload['actor'] if payload['actor'].is_a?(String) + return unless payload['actor'].is_a?(Hash) && payload['actor']['id'].is_a?(String) + + payload['actor']['id'] + end + + def previous_subscription + @previous_subscription ||= ReleasesSubscription.find_by_project_and_subscriber(project.id, subscriber_url) + end + end + end +end diff --git a/app/services/activity_pub/projects/releases_unfollow_service.rb b/app/services/activity_pub/projects/releases_unfollow_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..df5dcefbb872ce369a85c545c16f69bb479b71f3 --- /dev/null +++ b/app/services/activity_pub/projects/releases_unfollow_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module ActivityPub + module Projects + class ReleasesUnfollowService < ReleasesSubscriptionService + def execute + unless subscriber_url + errors << "You need to provide an actor id for your unsubscribe activity" + return false + end + + return true unless previous_subscription.present? + + previous_subscription.destroy + end + end + end +end diff --git a/config/routes/activity_pub.rb b/config/routes/activity_pub.rb index f400d722e7612c545092539673ccbdbf7cf65455..a967889a0ad4be2ead5e0ca0fe03de8423582da6 100644 --- a/config/routes/activity_pub.rb +++ b/config/routes/activity_pub.rb @@ -21,6 +21,7 @@ resources :releases, only: :index do collection do get 'outbox' + post 'inbox' end end end diff --git a/spec/controllers/activity_pub/projects/releases_controller_spec.rb b/spec/controllers/activity_pub/projects/releases_controller_spec.rb index 8719756b2604b355d52ef09fc512c4765a6a4f45..4102789ee431ac4582ebcae4eb6130ca842bcb19 100644 --- a/spec/controllers/activity_pub/projects/releases_controller_spec.rb +++ b/spec/controllers/activity_pub/projects/releases_controller_spec.rb @@ -11,13 +11,15 @@ let_it_be(:release_1) { create(:release, project: project, released_at: Time.zone.parse('2018-10-18')) } let_it_be(:release_2) { create(:release, project: project, released_at: Time.zone.parse('2019-10-19')) } + let(:request_body) { '' } + before_all do project.add_developer(developer) end shared_examples 'common access controls' do it 'renders a 200' do - get(action, params: params) + perform_action(verb, action, params, request_body) expect(response).to have_gitlab_http_status(:ok) end @@ -27,7 +29,7 @@ context 'when user is not logged in' do it 'renders a 404' do - get(action, params: params) + perform_action(verb, action, params, request_body) expect(response).to have_gitlab_http_status(:not_found) end @@ -39,7 +41,7 @@ end it 'still renders a 404' do - get(action, params: params) + perform_action(verb, action, params, request_body) expect(response).to have_gitlab_http_status(:not_found) end @@ -52,7 +54,7 @@ end it 'renders a 404' do - get(action, params: params) + perform_action(verb, action, params, request_body) expect(response).to have_gitlab_http_status(:not_found) end @@ -64,7 +66,7 @@ end it 'renders a 404' do - get(action, params: params) + perform_action(verb, action, params, request_body) expect(response).to have_gitlab_http_status(:not_found) end @@ -83,9 +85,10 @@ describe 'GET #index' do before do - get(action, params: params) + perform_action(verb, action, params) end + let(:verb) { :get } let(:action) { :index } let(:params) { { namespace_id: project.namespace, project_id: project } } @@ -99,9 +102,10 @@ describe 'GET #outbox' do before do - get(action, params: params) + perform_action(verb, action, params) end + let(:verb) { :get } let(:action) { :outbox } let(:params) { { namespace_id: project.namespace, project_id: project, page: page } } @@ -131,4 +135,172 @@ end end end + + describe 'POST #inbox' do + before do + allow(ActivityPub::Projects::ReleasesFollowService).to receive(:new) { follow_service } + allow(ActivityPub::Projects::ReleasesUnfollowService).to receive(:new) { unfollow_service } + end + + let(:verb) { :post } + let(:action) { :inbox } + let(:params) { { namespace_id: project.namespace, project_id: project } } + + let(:follow_service) do + instance_double(ActivityPub::Projects::ReleasesFollowService, execute: true, errors: ['an error']) + end + + let(:unfollow_service) do + instance_double(ActivityPub::Projects::ReleasesUnfollowService, execute: true, errors: ['an error']) + end + + context 'with a follow activity' do + before do + perform_action(verb, action, params, request_body) + end + + let(:request_body) do + { + "@context": "https://www.w3.org/ns/activitystreams", + id: "http://localhost:3001/6233e6c2-d285-4aa4-bd71-ddf1824d87f8", + type: "Follow", + actor: "http://localhost:3001/users/admin", + object: "http://127.0.0.1:3000/flightjs/Flight/-/releases" + }.to_json + end + + it_behaves_like 'common access controls' + + context 'with successful subscription initialization' do + it 'calls the subscription service' do + expect(follow_service).to have_received :execute + end + + it 'returns a successful response' do + expect(json_response['success']).to be_truthy + end + + it 'does not fill any error' do + expect(json_response).not_to have_key 'errors' + end + end + + context 'with unsuccessful subscription initialization' do + let(:follow_service) do + instance_double(ActivityPub::Projects::ReleasesFollowService, execute: false, errors: ['an error']) + end + + it 'calls the subscription service' do + expect(follow_service).to have_received :execute + end + + it 'returns a successful response' do + expect(json_response['success']).to be_falsey + end + + it 'fills an error' do + expect(json_response['errors']).to include 'an error' + end + end + end + + context 'with an unfollow activity' do + before do + perform_action(verb, action, params, request_body) + end + + let(:unfollow_service) do + instance_double(ActivityPub::Projects::ReleasesSubscriptionService, execute: true, errors: ['an error']) + end + + let(:request_body) do + { + "@context": "https://www.w3.org/ns/activitystreams", + id: "http://localhost:3001/users/admin#follows/8/undo", + type: "Undo", + actor: "http://localhost:3001/users/admin", + object: { + id: "http://localhost:3001/d4358269-71a9-4746-ac16-9a909f12ee5b", + type: "Follow", + actor: "http://localhost:3001/users/admin", + object: "http://127.0.0.1:3000/flightjs/Flight/-/releases" + } + }.to_json + end + + it_behaves_like 'common access controls' + + context 'with successful unfollow' do + it 'calls the subscription service' do + expect(unfollow_service).to have_received :execute + end + + it 'returns a successful response' do + expect(json_response['success']).to be_truthy + end + + it 'does not fill any error' do + expect(json_response).not_to have_key 'errors' + end + end + + context 'with unsuccessful unfollow' do + let(:unfollow_service) do + instance_double(ActivityPub::Projects::ReleasesUnfollowService, execute: false, errors: ['an error']) + end + + it 'calls the subscription service' do + expect(unfollow_service).to have_received :execute + end + + it 'returns a successful response' do + expect(json_response['success']).to be_falsey + end + + it 'fills an error' do + expect(json_response['errors']).to include 'an error' + end + end + end + + context 'with an unknown activity' do + before do + perform_action(verb, action, params, request_body) + end + + let(:request_body) do + { + "@context": "https://www.w3.org/ns/activitystreams", + id: "http://localhost:3001/6233e6c2-d285-4aa4-bd71-ddf1824d87f8", + type: "Like", + actor: "http://localhost:3001/users/admin", + object: "http://127.0.0.1:3000/flightjs/Flight/-/releases" + }.to_json + end + + it 'does not call the subscription service' do + expect(follow_service).not_to have_received :execute + expect(unfollow_service).not_to have_received :execute + end + + it 'returns a successful response' do + expect(json_response['success']).to be_truthy + end + + it 'does not fill any error' do + expect(json_response).not_to have_key 'errors' + end + end + + context 'with no activity' do + it 'renders a 422' do + perform_action(verb, action, params, request_body) + expect(response).to have_gitlab_http_status(:unprocessable_entity) + end + end + end +end + +def perform_action(verb, action, params, body = nil) + send(verb, action, params: params, body: body) end diff --git a/spec/models/activity_pub/releases_subscription_spec.rb b/spec/models/activity_pub/releases_subscription_spec.rb index 0c873a5c18ae1dc3077912e9cfc0331c6165917c..0633f293971b674a663c662e9700b91a22426877 100644 --- a/spec/models/activity_pub/releases_subscription_spec.rb +++ b/spec/models/activity_pub/releases_subscription_spec.rb @@ -55,23 +55,37 @@ end end - describe '.find_by_subscriber_url' do + describe '.find_by_project_and_subscriber' do let_it_be(:subscription) { create(:activity_pub_releases_subscription) } it 'returns a record if arguments match' do - result = described_class.find_by_subscriber_url(subscription.subscriber_url) + result = described_class.find_by_project_and_subscriber(subscription.project_id, + subscription.subscriber_url) expect(result).to eq(subscription) end - it 'returns a record if arguments match case insensitively' do - result = described_class.find_by_subscriber_url(subscription.subscriber_url.upcase) + it 'returns a record if subscriber url matches case insensitively' do + result = described_class.find_by_project_and_subscriber(subscription.project_id, + subscription.subscriber_url.upcase) expect(result).to eq(subscription) end + it 'returns nil if project and url do not match' do + result = described_class.find_by_project_and_subscriber(0, 'I really should not exist') + + expect(result).to be(nil) + end + it 'returns nil if project does not match' do - result = described_class.find_by_subscriber_url('I really should not exist') + result = described_class.find_by_project_and_subscriber(0, subscription.subscriber_url) + + expect(result).to be(nil) + end + + it 'returns nil if url does not match' do + result = described_class.find_by_project_and_subscriber(subscription.project_id, 'I really should not exist') expect(result).to be(nil) end diff --git a/spec/services/activity_pub/projects/releases_follow_service_spec.rb b/spec/services/activity_pub/projects/releases_follow_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..6d0d400b9c66a66d09f1e3a08dced1922200ae03 --- /dev/null +++ b/spec/services/activity_pub/projects/releases_follow_service_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActivityPub::Projects::ReleasesFollowService, feature_category: :release_orchestration do + let_it_be(:project) { create(:project, :public) } + let_it_be_with_reload(:existing_subscription) { create(:activity_pub_releases_subscription, project: project) } + + describe '#execute' do + let(:service) { described_class.new(project, payload) } + let(:payload) { nil } + + before do + allow(ActivityPub::Projects::ReleasesSubscriptionWorker).to receive(:perform_async) + end + + context 'with a valid payload' do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/new-actor#follow-1', + type: 'Follow', + actor: actor, + object: 'https://localhost/our/project/-/releases' + }.with_indifferent_access + end + + let(:actor) { 'https://example.com/new-actor' } + + context 'when there is no subscription for that actor' do + before do + allow(ActivityPub::ReleasesSubscription).to receive(:find_by_project_and_subscriber).and_return(nil) + end + + it 'sets the subscriber url' do + service.execute + expect(ActivityPub::ReleasesSubscription.last.subscriber_url).to eq 'https://example.com/new-actor' + end + + it 'sets the payload' do + service.execute + expect(ActivityPub::ReleasesSubscription.last.payload).to eq payload + end + + it 'sets the project' do + service.execute + expect(ActivityPub::ReleasesSubscription.last.project_id).to eq project.id + end + + it 'saves the subscription' do + expect { service.execute }.to change { ActivityPub::ReleasesSubscription.count }.by(1) + end + + it 'queues the subscription job' do + service.execute + expect(ActivityPub::Projects::ReleasesSubscriptionWorker).to have_received(:perform_async) + end + + it 'returns true' do + expect(service.execute).to be_truthy + end + end + + context 'when there is already a subscription for that actor' do + before do + allow(ActivityPub::ReleasesSubscription).to receive(:find_by_project_and_subscriber) { existing_subscription } + end + + it 'does not save the subscription' do + expect { service.execute }.not_to change { ActivityPub::ReleasesSubscription.count } + end + + it 'does not queue the subscription job' do + service.execute + expect(ActivityPub::Projects::ReleasesSubscriptionWorker).not_to have_received(:perform_async) + end + + it 'returns true' do + expect(service.execute).to be_truthy + end + end + end + + shared_examples 'invalid follow request' do + it 'does not save the subscription' do + expect { service.execute }.not_to change { ActivityPub::ReleasesSubscription.count } + end + + it 'does not queue the subscription job' do + service.execute + expect(ActivityPub::Projects::ReleasesSubscriptionWorker).not_to have_received(:perform_async) + end + + it 'sets an error' do + service.execute + expect(service.errors).not_to be_empty + end + + it 'returns false' do + expect(service.execute).to be_falsey + end + end + + context 'when actor is missing' do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/new-actor', + type: 'Follow', + object: 'https://localhost/our/project/-/releases' + }.with_indifferent_access + end + + it_behaves_like 'invalid follow request' + end + + context 'when actor is an object with no id attribute' do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/new-actor', + type: 'Follow', + actor: { type: 'Person' }, + object: 'https://localhost/our/project/-/releases' + }.with_indifferent_access + end + + it_behaves_like 'invalid follow request' + end + + context 'when actor is neither a string nor an object' do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/new-actor', + type: 'Follow', + actor: 27.13, + object: 'https://localhost/our/project/-/releases' + }.with_indifferent_access + end + + it_behaves_like 'invalid follow request' + end + end +end diff --git a/spec/services/activity_pub/projects/releases_unfollow_service_spec.rb b/spec/services/activity_pub/projects/releases_unfollow_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c732d82a2addc149a3b401720df8756858714ef6 --- /dev/null +++ b/spec/services/activity_pub/projects/releases_unfollow_service_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActivityPub::Projects::ReleasesUnfollowService, feature_category: :release_orchestration do + let_it_be(:project) { create(:project, :public) } + let_it_be_with_reload(:existing_subscription) { create(:activity_pub_releases_subscription, project: project) } + + describe '#execute' do + let(:service) { described_class.new(project, payload) } + let(:payload) { nil } + + context 'with a valid payload' do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/new-actor#unfollow-1', + type: 'Undo', + actor: actor, + object: { + id: 'https://example.com/new-actor#follow-1', + type: 'Follow', + actor: actor, + object: 'https://localhost/our/project/-/releases' + } + }.with_indifferent_access + end + + let(:actor) { existing_subscription.subscriber_url } + + context 'when there is a subscription for this actor' do + it 'deletes the subscription' do + service.execute + expect(ActivityPub::ReleasesSubscription.where(id: existing_subscription.id).first).to be_nil + end + + it 'returns true' do + expect(service.execute).to be_truthy + end + end + + context 'when there is no subscription for this actor' do + before do + allow(ActivityPub::ReleasesSubscription).to receive(:find_by_project_and_subscriber).and_return(nil) + end + + it 'does not delete anything' do + expect { service.execute }.not_to change { ActivityPub::ReleasesSubscription.count } + end + + it 'returns true' do + expect(service.execute).to be_truthy + end + end + end + + shared_examples 'invalid unfollow request' do + it 'does not delete anything' do + expect { service.execute }.not_to change { ActivityPub::ReleasesSubscription.count } + end + + it 'sets an error' do + service.execute + expect(service.errors).not_to be_empty + end + + it 'returns false' do + expect(service.execute).to be_falsey + end + end + + context 'when actor is missing' do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/new-actor#unfollow-1', + type: 'Undo', + object: { + id: 'https://example.com/new-actor#follow-1', + type: 'Follow', + object: 'https://localhost/our/project/-/releases' + } + }.with_indifferent_access + end + + it_behaves_like 'invalid unfollow request' + end + + context 'when actor is an object with no id attribute' do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/new-actor#unfollow-1', + actor: { type: 'Person' }, + type: 'Undo', + object: { + id: 'https://example.com/new-actor#follow-1', + type: 'Follow', + actor: { type: 'Person' }, + object: 'https://localhost/our/project/-/releases' + } + }.with_indifferent_access + end + + it_behaves_like 'invalid unfollow request' + end + + context 'when actor is neither a string nor an object' do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/new-actor#unfollow-1', + actor: 27.13, + type: 'Undo', + object: { + id: 'https://example.com/new-actor#follow-1', + type: 'Follow', + actor: 27.13, + object: 'https://localhost/our/project/-/releases' + } + }.with_indifferent_access + end + + it_behaves_like 'invalid unfollow request' + end + + context "when actor tries to delete someone else's subscription" do + let(:payload) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/actor#unfollow-1', + type: 'Undo', + actor: 'https://example.com/nasty-actor', + object: { + id: 'https://example.com/actor#follow-1', + type: 'Follow', + actor: existing_subscription.subscriber_url, + object: 'https://localhost/our/project/-/releases' + } + }.with_indifferent_access + end + + it 'does not delete anything' do + expect { service.execute }.not_to change { ActivityPub::ReleasesSubscription.count } + end + + it 'returns true' do + expect(service.execute).to be_truthy + end + end + end +end