diff --git a/app/models/activity_pub.rb b/app/models/activity_pub.rb new file mode 100644 index 0000000000000000000000000000000000000000..9131d8be776705b34ab0b40a9818b9ec726f9fed --- /dev/null +++ b/app/models/activity_pub.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ActivityPub + def self.table_name_prefix + "activity_pub_" + end +end diff --git a/app/models/activity_pub/releases_subscription.rb b/app/models/activity_pub/releases_subscription.rb new file mode 100644 index 0000000000000000000000000000000000000000..a6304f1fc3544beac741180cf6afc3469e4a81e9 --- /dev/null +++ b/app/models/activity_pub/releases_subscription.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ActivityPub + class ReleasesSubscription < ApplicationRecord + belongs_to :project, optional: false + + enum :status, [:requested, :accepted], default: :requested + + attribute :payload, Gitlab::Database::Type::JsonPgSafe.new + + 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 }, + 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) + end + end +end diff --git a/app/validators/json_schemas/activity_pub_follow_payload.json b/app/validators/json_schemas/activity_pub_follow_payload.json new file mode 100644 index 0000000000000000000000000000000000000000..1f453ce840f44a20f9b7d69188bfda54d4a9d964 --- /dev/null +++ b/app/validators/json_schemas/activity_pub_follow_payload.json @@ -0,0 +1,53 @@ +{ + "description": "ActivityPub Follow activity payload", + "type": "object", + "required": [ + "@context", + "id", + "type", + "actor", + "object" + ], + "properties": { + "@context": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array" + } + ] + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "actor": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "required": [ + "id" + ], + "id": { + "type": "string" + }, + "inbox": { + "type": "string" + }, + "additionalProperties": true + } + ] + }, + "object": { + "type": "string" + }, + "additionalProperties": true + } +} diff --git a/db/docs/activity_pub_releases_subscriptions.yml b/db/docs/activity_pub_releases_subscriptions.yml new file mode 100644 index 0000000000000000000000000000000000000000..8a27a51f9f3f0b680d7b6c134918088eaab2ed0a --- /dev/null +++ b/db/docs/activity_pub_releases_subscriptions.yml @@ -0,0 +1,10 @@ +--- +table_name: activity_pub_releases_subscriptions +classes: +- ActivityPub::ReleasesSubscription +feature_categories: +- release_orchestration +description: Stores subscriptions from external users through ActivityPub for project + releases +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132889 +gitlab_schema: gitlab_main diff --git a/db/migrate/20231017095738_create_activity_pub_releases_subscriptions.rb b/db/migrate/20231017095738_create_activity_pub_releases_subscriptions.rb new file mode 100644 index 0000000000000000000000000000000000000000..19693c29a33c0a0461987968546ea1cde3397cc0 --- /dev/null +++ b/db/migrate/20231017095738_create_activity_pub_releases_subscriptions.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class CreateActivityPubReleasesSubscriptions < Gitlab::Database::Migration[2.1] + enable_lock_retries! + + def up + create_table :activity_pub_releases_subscriptions do |t| + t.references :project, index: false, foreign_key: { on_delete: :cascade }, null: false + t.timestamps_with_timezone null: false + t.integer :status, null: false, limit: 2, default: 1 + t.text :shared_inbox_url, limit: 1024 + t.text :subscriber_inbox_url, limit: 1024 + t.text :subscriber_url, limit: 1024, null: false + t.jsonb :payload, null: true + t.index 'project_id, LOWER(subscriber_url)', name: :index_activity_pub_releases_sub_on_project_id_sub_url, + unique: true + t.index 'project_id, LOWER(subscriber_inbox_url)', + name: :index_activity_pub_releases_sub_on_project_id_inbox_url, unique: true + end + end + + def down + drop_table :activity_pub_releases_subscriptions + end +end diff --git a/db/schema_migrations/20231017095738 b/db/schema_migrations/20231017095738 new file mode 100644 index 0000000000000000000000000000000000000000..20feb63b199841659319008c8666d5ab0dec80f8 --- /dev/null +++ b/db/schema_migrations/20231017095738 @@ -0,0 +1 @@ +730b861c660b96556969054402a7776f622d42ed98055b0f7099c940ecf03c32 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index d7d5d469d9eb12a7f5199c0ecd6758671a271089..99141edd313ecfbd99c9e31c6758cc8243197aef 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -10880,6 +10880,30 @@ CREATE SEQUENCE achievements_id_seq ALTER SEQUENCE achievements_id_seq OWNED BY achievements.id; +CREATE TABLE activity_pub_releases_subscriptions ( + id bigint NOT NULL, + project_id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + status smallint DEFAULT 1 NOT NULL, + shared_inbox_url text, + subscriber_inbox_url text, + subscriber_url text NOT NULL, + payload jsonb, + CONSTRAINT check_0ebf38bcaa CHECK ((char_length(subscriber_inbox_url) <= 1024)), + CONSTRAINT check_2afd35ba17 CHECK ((char_length(subscriber_url) <= 1024)), + CONSTRAINT check_61b77ced49 CHECK ((char_length(shared_inbox_url) <= 1024)) +); + +CREATE SEQUENCE activity_pub_releases_subscriptions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE activity_pub_releases_subscriptions_id_seq OWNED BY activity_pub_releases_subscriptions.id; + CREATE TABLE agent_activity_events ( id bigint NOT NULL, agent_id bigint NOT NULL, @@ -25864,6 +25888,8 @@ ALTER TABLE ONLY abuse_trust_scores ALTER COLUMN id SET DEFAULT nextval('abuse_t ALTER TABLE ONLY achievements ALTER COLUMN id SET DEFAULT nextval('achievements_id_seq'::regclass); +ALTER TABLE ONLY activity_pub_releases_subscriptions ALTER COLUMN id SET DEFAULT nextval('activity_pub_releases_subscriptions_id_seq'::regclass); + ALTER TABLE ONLY agent_activity_events ALTER COLUMN id SET DEFAULT nextval('agent_activity_events_id_seq'::regclass); ALTER TABLE ONLY agent_group_authorizations ALTER COLUMN id SET DEFAULT nextval('agent_group_authorizations_id_seq'::regclass); @@ -27632,6 +27658,9 @@ ALTER TABLE ONLY abuse_trust_scores ALTER TABLE ONLY achievements ADD CONSTRAINT achievements_pkey PRIMARY KEY (id); +ALTER TABLE ONLY activity_pub_releases_subscriptions + ADD CONSTRAINT activity_pub_releases_subscriptions_pkey PRIMARY KEY (id); + ALTER TABLE ONLY agent_activity_events ADD CONSTRAINT agent_activity_events_pkey PRIMARY KEY (id); @@ -31229,6 +31258,10 @@ CREATE INDEX index_abuse_trust_scores_on_user_id_and_source_and_created_at ON ab CREATE UNIQUE INDEX "index_achievements_on_namespace_id_LOWER_name" ON achievements USING btree (namespace_id, lower(name)); +CREATE UNIQUE INDEX index_activity_pub_releases_sub_on_project_id_inbox_url ON activity_pub_releases_subscriptions USING btree (project_id, lower(subscriber_inbox_url)); + +CREATE UNIQUE INDEX index_activity_pub_releases_sub_on_project_id_sub_url ON activity_pub_releases_subscriptions USING btree (project_id, lower(subscriber_url)); + CREATE INDEX index_agent_activity_events_on_agent_id_and_recorded_at_and_id ON agent_activity_events USING btree (agent_id, recorded_at, id); CREATE INDEX index_agent_activity_events_on_agent_token_id ON agent_activity_events USING btree (agent_token_id) WHERE (agent_token_id IS NOT NULL); @@ -38269,6 +38302,9 @@ ALTER TABLE ONLY batched_background_migration_jobs ALTER TABLE ONLY operations_strategies_user_lists ADD CONSTRAINT fk_rails_43241e8d29 FOREIGN KEY (strategy_id) REFERENCES operations_strategies(id) ON DELETE CASCADE; +ALTER TABLE ONLY activity_pub_releases_subscriptions + ADD CONSTRAINT fk_rails_4337598314 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; + ALTER TABLE ONLY analytics_cycle_analytics_value_stream_settings ADD CONSTRAINT fk_rails_4360d37256 FOREIGN KEY (value_stream_id) REFERENCES analytics_cycle_analytics_group_value_streams(id) ON DELETE CASCADE; diff --git a/spec/factories/activity_pub/releases_subscriptions.rb b/spec/factories/activity_pub/releases_subscriptions.rb new file mode 100644 index 0000000000000000000000000000000000000000..b789188528a67116013646ec402e79f69e9f90e1 --- /dev/null +++ b/spec/factories/activity_pub/releases_subscriptions.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :activity_pub_releases_subscription, class: 'ActivityPub::ReleasesSubscription' do + project + subscriber_url { 'https://example.com/actor' } + status { :requested } + payload do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/actor#follow/1', + type: 'Follow', + actor: 'https://example.com/actor', + object: 'http://localhost/user/project/-/releases' + } + end + + trait :inbox do + subscriber_inbox_url { 'https://example.com/actor/inbox' } + end + + trait :shared_inbox do + shared_inbox_url { 'https://example.com/shared-inbox' } + end + end +end diff --git a/spec/models/activity_pub/releases_subscription_spec.rb b/spec/models/activity_pub/releases_subscription_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0c873a5c18ae1dc3077912e9cfc0331c6165917c --- /dev/null +++ b/spec/models/activity_pub/releases_subscription_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActivityPub::ReleasesSubscription, type: :model, feature_category: :release_orchestration do + describe 'factory' do + subject { build(:activity_pub_releases_subscription) } + + it { is_expected.to be_valid } + end + + describe 'associations' do + it { is_expected.to belong_to(:project).optional(false) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:subscriber_url) } + + describe 'subscriber_url' do + subject { build(:activity_pub_releases_subscription) } + + it { is_expected.to validate_uniqueness_of(:subscriber_url).case_insensitive.scoped_to([:project_id]) } + it { is_expected.to allow_value("http://example.com/actor").for(:subscriber_url) } + it { is_expected.not_to allow_values("I'm definitely not a URL").for(:subscriber_url) } + end + + describe 'subscriber_inbox_url' do + subject { build(:activity_pub_releases_subscription) } + + it { is_expected.to validate_uniqueness_of(:subscriber_inbox_url).case_insensitive.scoped_to([:project_id]) } + it { is_expected.to allow_value("http://example.com/actor").for(:subscriber_inbox_url) } + it { is_expected.not_to allow_values("I'm definitely not a URL").for(:subscriber_inbox_url) } + end + + describe 'shared_inbox_url' do + subject { build(:activity_pub_releases_subscription) } + + it { is_expected.to allow_value("http://example.com/actor").for(:shared_inbox_url) } + it { is_expected.not_to allow_values("I'm definitely not a URL").for(:shared_inbox_url) } + end + + describe 'payload' do + it { is_expected.not_to allow_value("string").for(:payload) } + it { is_expected.not_to allow_value(1.0).for(:payload) } + + it do + is_expected.to allow_value({ + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/actor#follow/1', + type: 'Follow', + actor: 'https://example.com/actor', + object: 'http://localhost/user/project/-/releases' + }).for(:payload) + end + end + end + + describe '.find_by_subscriber_url' 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) + + 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) + + expect(result).to eq(subscription) + end + + it 'returns nil if project does not match' do + result = described_class.find_by_subscriber_url('I really should not exist') + + expect(result).to be(nil) + end + end +end