From 5c6a601db2cb0efefb039a7cf27c53a4ee9b8d85 Mon Sep 17 00:00:00 2001 From: kik <olivier@el-mekki.com> Date: Fri, 17 Nov 2023 08:57:58 +0000 Subject: [PATCH] ADD controller for ActivityPub subscription to releases actor 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 to create the blank subscription and to queue the background job that will resolve the various URLs and prepare the subscription. --- .../activity_pub/application_controller.rb | 2 + .../projects/releases_controller.rb | 47 ++++- .../activity_pub/releases_subscription.rb | 6 +- .../projects/releases_follow_service.rb | 43 ++++ .../projects/releases_subscription_service.rb | 35 ++++ .../projects/releases_unfollow_service.rb | 18 ++ config/routes/activity_pub.rb | 1 + .../projects/releases_controller_spec.rb | 186 +++++++++++++++++- .../releases_subscription_spec.rb | 24 ++- .../projects/releases_follow_service_spec.rb | 145 ++++++++++++++ .../releases_unfollow_service_spec.rb | 152 ++++++++++++++ 11 files changed, 643 insertions(+), 16 deletions(-) create mode 100644 app/services/activity_pub/projects/releases_follow_service.rb create mode 100644 app/services/activity_pub/projects/releases_subscription_service.rb create mode 100644 app/services/activity_pub/projects/releases_unfollow_service.rb create mode 100644 spec/services/activity_pub/projects/releases_follow_service_spec.rb create mode 100644 spec/services/activity_pub/projects/releases_unfollow_service_spec.rb diff --git a/app/controllers/activity_pub/application_controller.rb b/app/controllers/activity_pub/application_controller.rb index f9c2b14fe7701..70cf881c857a0 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 7c4c2a0322be6..eeff96a5ef704 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 a6304f1fc3544..0a4293b2bdea7 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 0000000000000..3d877a1d08377 --- /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 0000000000000..27d0e19a17277 --- /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 0000000000000..df5dcefbb872c --- /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 f400d722e7612..a967889a0ad4b 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 8719756b2604b..4102789ee431a 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 0c873a5c18ae1..0633f293971b6 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 0000000000000..6d0d400b9c66a --- /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 0000000000000..c732d82a2addc --- /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 -- GitLab