Add shared inbox support for ActivityPub delivery (#4755)

* feat(ap): add support for shared inboxes to reduce outbound load

* feat(db): refactor ap followers db into followers repository

* fix(ap): use the updated activity library to pull out the shared inbox endpoint

* chore(deps): point at updated build of owncast/activity

* fix(ap): typeless endpoints

* feat(test): update ActivityPub test to support shared inboxes

* chore(test): remove unused variable

* fix: feedback from review. Guard against SSRF/non-HTTPS/local and handle transaction errors
This commit is contained in:
Gabe Kangas
2026-01-22 15:33:22 -08:00
committed by GitHub
parent 7a735e6902
commit de6468ad89
31 changed files with 1033 additions and 410 deletions
+218 -5
View File
@@ -1,9 +1,14 @@
package persistence
import (
"net/url"
"os"
"strings"
"testing"
"github.com/go-fed/activity/streams"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
@@ -22,16 +27,30 @@ func setup() {
_datastore = data.GetDatastore()
createFederationFollowersTable()
followersRepo := followersrepository.New(_datastore)
number := 100
for i := 0; i < number; i++ {
u := createFakeFollower()
createFollow(u.ActorIRI, u.Inbox, "https://fake.fediverse.server/some/request", u.Name, u.Username, u.Image, nil, true)
actorIRI, _ := url.Parse(u.ActorIRI)
inboxURL, _ := url.Parse(u.Inbox)
requestIRI, _ := url.Parse("https://fake.fediverse.server/some/request")
fakeRequest := streams.NewActivityStreamsFollow()
followersRepo.Add(apmodels.ActivityPubActor{
ActorIri: actorIRI,
Inbox: inboxURL,
Name: u.Name,
Username: u.Username,
FollowRequestIri: requestIRI,
RequestObject: fakeRequest,
}, true)
followers = append(followers, u)
}
}
func TestQueryFollowers(t *testing.T) {
f, total, err := GetFederationFollowers(10, 0)
followersRepo := followersrepository.New(_datastore)
f, total, err := followersRepo.GetFollowers(10, 0)
if err != nil {
t.Errorf("Error querying followers: %s", err)
}
@@ -46,7 +65,8 @@ func TestQueryFollowers(t *testing.T) {
}
func TestQueryFollowersWithOffset(t *testing.T) {
f, total, err := GetFederationFollowers(10, 10)
followersRepo := followersrepository.New(_datastore)
f, total, err := followersRepo.GetFollowers(10, 10)
if err != nil {
t.Errorf("Error querying followers: %s", err)
}
@@ -61,7 +81,8 @@ func TestQueryFollowersWithOffset(t *testing.T) {
}
func TestQueryFollowersWithOffsetAndLimit(t *testing.T) {
f, total, err := GetFederationFollowers(10, 90)
followersRepo := followersrepository.New(_datastore)
f, total, err := followersRepo.GetFollowers(10, 90)
if err != nil {
t.Errorf("Error querying followers: %s", err)
}
@@ -76,7 +97,8 @@ func TestQueryFollowersWithOffsetAndLimit(t *testing.T) {
}
func TestQueryFollowersWithPagination(t *testing.T) {
f, _, err := GetFederationFollowers(15, 10)
followersRepo := followersrepository.New(_datastore)
f, _, err := followersRepo.GetFollowers(15, 10)
if err != nil {
t.Errorf("Error querying followers: %s", err)
}
@@ -105,3 +127,194 @@ func createFakeFollower() models.Follower {
Timestamp: utils.NullTime{},
}
}
func createTestFollower(followersRepo followersrepository.FollowersRepository, actor, inbox, sharedInbox, request, name, username string) {
actorIRI, _ := url.Parse(actor)
inboxURL, _ := url.Parse(inbox)
var sharedInboxURL *url.URL
if sharedInbox != "" {
sharedInboxURL, _ = url.Parse(sharedInbox)
}
requestIRI, _ := url.Parse(request)
fakeRequest := streams.NewActivityStreamsFollow()
followersRepo.Add(apmodels.ActivityPubActor{
ActorIri: actorIRI,
Inbox: inboxURL,
SharedInbox: sharedInboxURL,
Name: name,
Username: username,
FollowRequestIri: requestIRI,
RequestObject: fakeRequest,
}, true)
}
func TestGetUniqueDeliveryInboxes(t *testing.T) {
// Set up a fresh database for this test
data.SetupPersistence(":memory:")
ds := data.GetDatastore()
_datastore = ds
createFederationFollowersTable()
followersRepo := followersrepository.New(ds)
// Create followers from server1 with a shared inbox (3 users, 1 shared inbox)
server1SharedInbox := "https://server1.example.com/inbox"
for i := 0; i < 3; i++ {
user, _ := utils.GenerateRandomString(10)
createTestFollower(
followersRepo,
"https://server1.example.com/user/"+user,
"https://server1.example.com/user/"+user+"/inbox",
server1SharedInbox,
"https://server1.example.com/follow/"+user,
user,
user,
)
}
// Create followers from server2 with a shared inbox (2 users, 1 shared inbox)
server2SharedInbox := "https://server2.example.com/inbox"
for i := 0; i < 2; i++ {
user, _ := utils.GenerateRandomString(10)
createTestFollower(
followersRepo,
"https://server2.example.com/user/"+user,
"https://server2.example.com/user/"+user+"/inbox",
server2SharedInbox,
"https://server2.example.com/follow/"+user,
user,
user,
)
}
// Create followers from server3 WITHOUT a shared inbox (2 users, 2 individual inboxes)
for i := 0; i < 2; i++ {
user, _ := utils.GenerateRandomString(10)
createTestFollower(
followersRepo,
"https://server3.example.com/user/"+user,
"https://server3.example.com/user/"+user+"/inbox",
"",
"https://server3.example.com/follow/"+user,
user,
user,
)
}
// Total: 7 followers, but should result in 4 unique delivery inboxes:
// - 1 shared inbox for server1
// - 1 shared inbox for server2
// - 2 individual inboxes for server3
inboxes, err := followersRepo.GetUniqueDeliveryInboxes()
if err != nil {
t.Fatalf("Error getting unique delivery inboxes: %s", err)
}
if len(inboxes) != 4 {
t.Errorf("Expected 4 unique delivery inboxes, got %d: %v", len(inboxes), inboxes)
}
// Verify the shared inboxes are included
hasServer1Shared := false
hasServer2Shared := false
server3IndividualCount := 0
for _, inbox := range inboxes {
if inbox == server1SharedInbox {
hasServer1Shared = true
}
if inbox == server2SharedInbox {
hasServer2Shared = true
}
if len(inbox) > 0 && inbox != server1SharedInbox && inbox != server2SharedInbox {
// Should be one of server3's individual inboxes
if !strings.Contains(inbox, "server3.example.com") {
t.Errorf("Unexpected inbox in results: %s", inbox)
}
server3IndividualCount++
}
}
if !hasServer1Shared {
t.Error("Expected server1 shared inbox to be in results")
}
if !hasServer2Shared {
t.Error("Expected server2 shared inbox to be in results")
}
if server3IndividualCount != 2 {
t.Errorf("Expected 2 individual inboxes from server3, got %d", server3IndividualCount)
}
}
func TestSharedInboxPreferredOverIndividual(t *testing.T) {
// Set up a fresh database for this test
data.SetupPersistence(":memory:")
ds := data.GetDatastore()
_datastore = ds
createFederationFollowersTable()
followersRepo := followersrepository.New(ds)
// Create a single follower with both individual and shared inbox
sharedInbox := "https://mastodon.social/inbox"
individualInbox := "https://mastodon.social/users/testuser/inbox"
createTestFollower(
followersRepo,
"https://mastodon.social/users/testuser",
individualInbox,
sharedInbox,
"https://mastodon.social/follow/123",
"Test User",
"testuser",
)
inboxes, err := followersRepo.GetUniqueDeliveryInboxes()
if err != nil {
t.Fatalf("Error getting unique delivery inboxes: %s", err)
}
if len(inboxes) != 1 {
t.Errorf("Expected 1 unique delivery inbox, got %d", len(inboxes))
}
// The shared inbox should be returned, not the individual inbox
if inboxes[0] != sharedInbox {
t.Errorf("Expected shared inbox %s, got %s", sharedInbox, inboxes[0])
}
}
func TestIndividualInboxUsedWhenNoSharedInbox(t *testing.T) {
// Set up a fresh database for this test
data.SetupPersistence(":memory:")
ds := data.GetDatastore()
_datastore = ds
createFederationFollowersTable()
followersRepo := followersrepository.New(ds)
// Create a follower without a shared inbox
individualInbox := "https://pleroma.example.com/users/testuser/inbox"
createTestFollower(
followersRepo,
"https://pleroma.example.com/users/testuser",
individualInbox,
"",
"https://pleroma.example.com/follow/123",
"Test User",
"testuser",
)
inboxes, err := followersRepo.GetUniqueDeliveryInboxes()
if err != nil {
t.Fatalf("Error getting unique delivery inboxes: %s", err)
}
if len(inboxes) != 1 {
t.Errorf("Expected 1 unique delivery inbox, got %d", len(inboxes))
}
// The individual inbox should be returned when no shared inbox exists
if inboxes[0] != individualInbox {
t.Errorf("Expected individual inbox %s, got %s", individualInbox, inboxes[0])
}
}