Skip to content

Draft: ADD ActivityPub subscription

kik requested to merge gitlab-community/gitlab:activitypub-releases-inbox into master

This MR is not meant to be merged. Given its size, it will be split in several other MRs. This one will act as reference to see the big scope of all smaller MRs, and it's also an opportunity to run the complete test suite on the full feature.

The feature worked on here is part of the series adding support in GitLab for the ActivityPub protocol. This will allow people on the Fediverse (a decentralized social media) to subscribe to events from GitLab, for example to be notified when a project makes a new release. Ultimately, the same protocol will be used to allow to do cross-instance merge requests between several instances of GitLab.

The issue being worked on here is about allowing to subscribe to a first "actor" (that's the ActivityPub term for "resource"), which is the "releases" actor (when a project create new releases). We need to allow people from the Fediverse to subscribe to the actor (by posting a JSON payload to an endpoint, called the actor's inbox), and then we need to send the news ("activities", in ActivityPub terminology), when a new release is created.

This MR introduces the subscription part of following releases for a project on GitLab from the Fediverse. This include both following and unfollowing.

Following

When someone want to follow an ActivityPub actor, these are the high-level steps for it to happen:

  1. The subscriber posts a Follow activity on actor's inbox
  2. The application of the actor may ask the person owning that actor if they want to accept the follow
  3. the actor's server then posts an Accept or Reject activity to the subscriber's inbox

One complication is that we don't always know the address of the subscriber inbox : the only mandatory field in the Follow activity they send if they profile URL. Gladly, if we query that URL, we get a JSON response containing the inbox URL. So the high-level process becomes this:

  1. The subscriber posts a Follow activity on actor's inbox
  2. if they don't provide their inbox URL, we query their profile URL to find it
  3. The application of the actor may ask the person owning that actor if they want to accept the follow
  4. the actor's server then posts an Accept or Reject activity to the subscriber's inbox

Which in turns give this implementation in this MR:

  1. Someone pushes a Follow activity (as a JSON post body) to the inbox endpoint.
  2. The ActivityPub::Projects::ReleasesSubscriptionService#follow is called
  3. that service saves a ReleasesSubscription record containing the subscription info
  4. if no inbox URL is provided, that service queues a background job through ActivityPub::Projects::ReleasesSubscriptionWorker to find the subscriber inbox address and send out the Accept activity
  5. The job calls the ActivityPub::InboxResolverService to query the actor profile page on the third party server to find the inbox URL and the shared inbox URL.
  6. The job calls the ActivityPub::AcceptFollowService to post an Accept activity to the subscriber inbox.
  7. And finally, the job marks the subscription as accepted, ready to be used when new releases will be created.

If the third party server can't be reached after the default amount of retrying (25 times), the subscription is deleted.

This is the typical payload Mastodon sends for a Follow activity:

{
  "@context":"https://www.w3.org/ns/activitystreams",
  "id":"http://remote-server.com/follow-activity-id",
  "type":"Follow",
  "actor":"https://remote-server.com/subscriber-profile",
  "object":"https://our-server.com/user/project/-/releases"
}

In ActivityPub specs, it's possible for the actor field to not be a string, but be an object providing several details fields for the actor. In that case, the profile URL is the id field, and the object may include actor's name, inbox, etc:

{
  "@context":"https://www.w3.org/ns/activitystreams",
  "id":"http://remote-server.com/follow-activity-id",
  "type":"Follow",
  "actor": {
    "id": "https://remote-server.com/subscriber-profile",
    "name": "Sub Scriber",
    "inbox": "https://remote-server.com/subscriber-profile/inbox",
    "outbox": "https://remote-server.com/subscriber-profile/outbox",
  },
  "object":"https://our-server.com/user/project/-/releases"
}

If the inbox is provided that way, we don't perform the roundtrip in the worker to find that URL again. This is the same mechanism that prevents us to resolve the inbox URL several times if the job is retried, for example if the request sending the Accept activity after the inbox resolving fail.

Unfollowing

Unfollowing is simpler, as we don't need to contact the remote server.

  1. Someone pushes an Undo activity (as a JSON post body) to the inbox endpoint.
  2. The ActivityPub::Projects::ReleasesSubscriptionService#unfollow is called
  3. We delete the ReleasesSubscription record containing the subscription info

This is the typical activity Mastodon is sending:

{
  "@context":"https://www.w3.org/ns/activitystreams",
  "id":"https://remote-server.com/unfollow-activity-id",
  "type":"Undo",
  "actor":"https://remote-server.com/subscriber",
  "object":{
    "id":"http://remote-server.com/follow-activity-id",
    "type":"Follow",
    "actor":"https://remote-server.com/subscriber-profile",
    "object":"https://our-server.com/user/project/-/releases"
  }
}

So, this is an Undo activity, and its object is the Follow activity sent previously.

Security

Something worth noting here : there is absolutely no way here to know the Follow or Undo activity is sent by the person subscribed. As is, with a simple POST request, I could subscribe or unsubscribe anyone, which obviously would not do.

Making sure the requester has authority to perform the action requested will be the job for HTTP signature.

How to set up and validate locally

Using the Sinatra app

A Sinatra app has been created to test communication with the application, as this is a server to server feature.

Manually

  1. make flightjs/Flight a public project
  2. activate the feature flags:
Feature.enable(:activity_pub_project)
Feature.enable(:activity_pub)
  1. post a Follow activity on the actor's inbox:
curl -H 'Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams"' \
  --request POST \
  --data '{ "@context":"https://www.w3.org/ns/activitystreams", "id":"http://remote-server.com/follow-activity-id", "type":"Follow", "actor":"https://remote-server.com/subscriber-profile", "object":"http://127.0.0.1:3000/flightjs/Flight/-/releases" }' \
  "http://127.0.0.1:3000/flightjs/Flight/-/releases/inbox"

Then you can verify in console that the subscription has been created:

ActivityPub::ReleasesSubscription.last

Worth noting: this does not test the whole process, because we're missing the part when we reply to the third party server (and actually, if you wait for two long before checking the subscription and the background job gives up contacting the third party server, it will delete the subscription). The Sinatra app allows to test that part.

Edited by kik

Merge request reports