From 4db0e074cbc5c8bf792ed5a76c7fa7a6cc15aaab Mon Sep 17 00:00:00 2001 From: Olivier El Mekki Date: Tue, 10 Oct 2023 15:52:18 +0200 Subject: [PATCH] ADD controller and its service This is the third and last part of the merging the overarching subscription MR at https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132460 . This provides the controller, route and service used to create subscriptions. We add the `inbox` endpoint as specified by ActivityPub on our `releases` actor to receive various post requests. The only requests we accept are Follow and unfollow (Undo > Follow) ones. If we receive an other kind of activity (that could be, for example, that someone commented on our release post in the Fediverse), we silently discard it. The service's role is the create the blank subscription and to queue the backgroud job that will resolve the various URLs and prepare the subscription. --- .../activity_pub/application_controller.rb | 2 + .../projects/releases_controller.rb | 45 ++- .../activity_pub/releases_subscription.rb | 2 +- .../projects/releases_subscription_service.rb | 74 ++++ config/routes/activity_pub.rb | 1 + .../projects/releases_controller_spec.rb | 181 +++++++++- .../releases_subscription_service_spec.rb | 315 ++++++++++++++++++ 7 files changed, 611 insertions(+), 9 deletions(-) create mode 100644 app/services/activity_pub/projects/releases_subscription_service.rb create mode 100644 spec/services/activity_pub/projects/releases_subscription_service_spec.rb diff --git a/app/controllers/activity_pub/application_controller.rb b/app/controllers/activity_pub/application_controller.rb index f9c2b14fe7701c..70cf881c857a04 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 7c4c2a0322be69..e612331ffbfc59 100644 --- a/app/controllers/activity_pub/projects/releases_controller.rb +++ b/app/controllers/activity_pub/projects/releases_controller.rb @@ -5,15 +5,32 @@ 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 = ReleasesSubscriptionService.new(project, payload) + + success = if follow? || unfollow? + follow? ? service.follow : service.unfollow + else + true + end + + 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 +41,32 @@ 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 end end end diff --git a/app/models/activity_pub/releases_subscription.rb b/app/models/activity_pub/releases_subscription.rb index a6304f1fc3544b..5c730c7555a3b3 100644 --- a/app/models/activity_pub/releases_subscription.rb +++ b/app/models/activity_pub/releases_subscription.rb @@ -11,7 +11,7 @@ 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 } 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 00000000000000..6a89063a53aea2 --- /dev/null +++ b/app/services/activity_pub/projects/releases_subscription_service.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module ActivityPub + module Projects + class ReleasesSubscriptionService + attr_reader :project, :payload, :errors, :subscription + + def initialize(project, payload) + @project = project + @payload = payload + @errors = [] + end + + def follow + 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 + true + end + + def unfollow + 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 + + private + + def subscriber_url + return false 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 subscriber_inbox_url + return unless payload['actor'].is_a?(Hash) + + payload['actor']['inbox'] + end + + def previous_subscription + @previous_subscription ||= ReleasesSubscription.find_by_subscriber_url(subscriber_url) + end + + def enqueue_subscription + ReleasesSubscriptionWorker.perform_async(subscription.id) + end + end + end +end diff --git a/config/routes/activity_pub.rb b/config/routes/activity_pub.rb index f400d722e7612c..a967889a0ad4be 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 8719756b2604b3..c6c2a76234cd26 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,167 @@ end end end + + describe 'POST #inbox' do + before do + allow(ActivityPub::Projects::ReleasesSubscriptionService).to receive(:new) { service } + end + + let(:verb) { :post } + let(:action) { :inbox } + let(:params) { { namespace_id: project.namespace, project_id: project } } + let(:service) do + instance_double(ActivityPub::Projects::ReleasesSubscriptionService, follow: true, unfollow: 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(service).to have_received :follow + 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(:service) do + instance_double(ActivityPub::Projects::ReleasesSubscriptionService, follow: false, errors: ['an error']) + end + + it 'calls the subscription service' do + expect(service).to have_received :follow + 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(:service) do + instance_double(ActivityPub::Projects::ReleasesSubscriptionService, unfollow: 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(service).to have_received :unfollow + 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(:service) do + instance_double(ActivityPub::Projects::ReleasesSubscriptionService, unfollow: false, errors: ['an error']) + end + + it 'calls the subscription service' do + expect(service).to have_received :unfollow + 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(service).not_to have_received :follow + expect(service).not_to have_received :unfollow + 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/services/activity_pub/projects/releases_subscription_service_spec.rb b/spec/services/activity_pub/projects/releases_subscription_service_spec.rb new file mode 100644 index 00000000000000..7e52b386a8cce9 --- /dev/null +++ b/spec/services/activity_pub/projects/releases_subscription_service_spec.rb @@ -0,0 +1,315 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActivityPub::Projects::ReleasesSubscriptionService, 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 '#follow' 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' } + + shared_examples 'valid follow request' do + it 'sets the subscriber url' do + service.follow + expect(ActivityPub::ReleasesSubscription.last.subscriber_url).to eq 'https://example.com/new-actor' + end + + it 'sets the payload' do + service.follow + expect(ActivityPub::ReleasesSubscription.last.payload).to eq payload + end + + it 'sets the project' do + service.follow + expect(ActivityPub::ReleasesSubscription.last.project_id).to eq project.id + end + + it 'saves the subscription' do + expect { service.follow }.to change { ActivityPub::ReleasesSubscription.count }.by(1) + end + + it 'returns true' do + expect(service.follow).to be_truthy + end + end + + context 'when there is no subscription for that actor' do + before do + allow(ActivityPub::ReleasesSubscription).to receive(:find_by_subscriber_url).and_return(nil) + end + + context 'when only the actor id is provided' do + it_behaves_like 'valid follow request' + + it 'queues the subscription job' do + service.follow + expect(ActivityPub::Projects::ReleasesSubscriptionWorker).to have_received(:perform_async) + end + end + + context 'when the actor inbox is provided' do + let(:actor) { { id: 'https://example.com/new-actor', inbox: 'https://example.com/new-actor/inbox' } } + + it_behaves_like 'valid follow request' + + it 'sets the inbox url' do + service.follow + expect(ActivityPub::ReleasesSubscription.last.subscriber_inbox_url).to eq 'https://example.com/new-actor/inbox' + end + + it 'does not queue the subscription job' do + expect(ActivityPub::Projects::ReleasesSubscriptionWorker).not_to have_received(:perform_async) + end + end + end + + context 'when there is already a subscription for that actor' do + before do + allow(ActivityPub::ReleasesSubscription).to receive(:find_by_subscriber_url) { existing_subscription } + end + + it 'does not save the subscription' do + expect { service.follow }.not_to change { ActivityPub::ReleasesSubscription.count } + end + + it 'does not queue the subscription job' do + service.follow + expect(ActivityPub::Projects::ReleasesSubscriptionWorker).not_to have_received(:perform_async) + end + + it 'returns true' do + expect(service.follow).to be_truthy + end + end + end + + shared_examples 'invalid follow request' do + it 'does not save the subscription' do + expect { service.follow }.not_to change { ActivityPub::ReleasesSubscription.count } + end + + it 'does not queue the subscription job' do + service.follow + expect(ActivityPub::Projects::ReleasesSubscriptionWorker).not_to have_received(:perform_async) + end + + it 'sets an error' do + service.follow + expect(service.errors).not_to be_empty + end + + it 'returns false' do + expect(service.follow).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 + + describe '#unfollow' 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) { 'https://example.com/new-actor' } + + context 'when there is a subscription for this actor' do + before do + allow(ActivityPub::ReleasesSubscription).to receive(:find_by_subscriber_url).and_return(existing_subscription) + allow(existing_subscription).to receive(:destroy).and_return(true) + end + + it 'deletes the subscription' do + service.unfollow + expect(existing_subscription).to have_received(:destroy) + end + + it 'returns true' do + expect(service.unfollow).to be_truthy + end + end + + context 'when there is no subscription for this actor' do + before do + allow(ActivityPub::ReleasesSubscription).to receive(:find_by_subscriber_url).and_return(nil) + end + + it 'does not delete anything' do + expect { service.unfollow }.not_to change { ActivityPub::ReleasesSubscription.count } + end + + it 'returns true' do + expect(service.unfollow).to be_truthy + end + end + end + + shared_examples 'invalid unfollow request' do + it 'does not delete anything' do + expect { service.unfollow }.not_to change { ActivityPub::ReleasesSubscription.count } + end + + it 'sets an error' do + service.unfollow + expect(service.errors).not_to be_empty + end + + it 'returns false' do + expect(service.unfollow).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.unfollow }.not_to change { ActivityPub::ReleasesSubscription.count } + end + + it 'returns true' do + expect(service.unfollow).to be_truthy + end + end + end +end -- GitLab