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
+8 -4
View File
@@ -5,6 +5,7 @@ import (
"github.com/owncast/owncast/activitypub/inbox"
"github.com/owncast/owncast/activitypub/outbox"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/workerpool"
"github.com/owncast/owncast/persistence/configrepository"
@@ -42,8 +43,9 @@ func getOutboundWorkerPoolSize() int {
defaultWorkers = 20 // Default for most instances
)
followersRepo := followersrepository.Get()
var followerCount int64
fc, err := persistence.GetFollowerCount()
fc, err := followersRepo.GetCount()
if err != nil {
log.Errorln("Unable to get follower count", err)
return defaultWorkers
@@ -58,7 +60,7 @@ func getOutboundWorkerPoolSize() int {
workers = maxWorkers
}
log.Infof("Initializing ActivityPub outbound worker pool with %d workers for %d followers", workers, followerCount)
log.Debugf("Initializing ActivityPub outbound worker pool with %d workers for %d followers", workers, followerCount)
return workers
}
@@ -79,10 +81,12 @@ func SendDirectFederatedMessage(message, account string) error {
// GetFollowerCount will return the local tracked follower count.
func GetFollowerCount() (int64, error) {
return persistence.GetFollowerCount()
followersRepo := followersrepository.Get()
return followersRepo.GetCount()
}
// GetPendingFollowRequests will return the pending follow requests.
func GetPendingFollowRequests() ([]models.Follower, error) {
return persistence.GetPendingFollowRequests()
followersRepo := followersrepository.Get()
return followersRepo.GetPendingFollowRequests()
}
+65 -35
View File
@@ -26,6 +26,8 @@ type ActivityPubActor struct {
FollowRequestIri *url.URL
// Inbox is the inbox URL of the remote follower
Inbox *url.URL
// SharedInbox is the shared inbox URL of the remote server (optional)
SharedInbox *url.URL
// Image is the avatar image of the Actor.
Image *url.URL
// DisabledAt is the time, if any, this follower was blocked/removed.
@@ -74,6 +76,14 @@ func (a *ActivityPubActor) InboxString() string {
return a.Inbox.String()
}
// SharedInboxString returns the string representation of SharedInbox, or empty string if nil.
func (a *ActivityPubActor) SharedInboxString() string {
if a.SharedInbox == nil {
return ""
}
return a.SharedInbox.String()
}
// ImageString returns the string representation of Image, or empty string if nil.
func (a *ActivityPubActor) ImageString() string {
if a.Image == nil {
@@ -113,49 +123,68 @@ func NewActivityPubActor(actorIri, inbox *url.URL) (*ActivityPubActor, error) {
}, nil
}
// validateEntityRequiredFields checks that all required fields are present on the entity.
func validateEntityRequiredFields(entity ExternalEntity) error {
if entity.GetJSONLDId() == nil || entity.GetJSONLDId().Get() == nil {
return fmt.Errorf("%w: entity is missing actor IRI", ErrActorMissingRequiredField)
}
if entity.GetActivityStreamsInbox() == nil || entity.GetActivityStreamsInbox().GetIRI() == nil {
return fmt.Errorf("%w: entity is missing inbox", ErrActorMissingRequiredField)
}
if entity.GetActivityStreamsPreferredUsername() == nil || entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString() == "" {
return fmt.Errorf("%w: entity is missing preferred username", ErrActorMissingRequiredField)
}
if entity.GetW3IDSecurityV1PublicKey() == nil || entity.GetW3IDSecurityV1PublicKey().Len() == 0 {
return fmt.Errorf("%w: entity is missing public key", ErrActorMissingRequiredField)
}
return nil
}
// getNameFromEntity extracts the optional name from an entity.
func getNameFromEntity(entity ExternalEntity) string {
nameProp := entity.GetActivityStreamsName()
if nameProp == nil || nameProp.Empty() {
return ""
}
return nameProp.At(0).GetXMLSchemaString()
}
// getSharedInboxFromEntity extracts the optional shared inbox URL from an entity.
func getSharedInboxFromEntity(entity ExternalEntity) *url.URL {
endpointsProp := entity.GetActivityStreamsEndpoints()
if endpointsProp == nil || !endpointsProp.IsActivityStreamsEndpoints() {
return nil
}
endpoints := endpointsProp.Get()
if endpoints == nil {
return nil
}
sharedInboxProp := endpoints.GetActivityStreamsSharedInbox()
if sharedInboxProp == nil || !sharedInboxProp.HasAny() {
return nil
}
return sharedInboxProp.Get()
}
// NewActivityPubActorFromEntity creates a new ActivityPubActor from an external entity
// with validation of required fields.
func NewActivityPubActorFromEntity(entity ExternalEntity) (*ActivityPubActor, error) {
// ActorIri is required (must validate before GetFullUsernameFromExternalEntity which uses it)
if entity.GetJSONLDId() == nil || entity.GetJSONLDId().Get() == nil {
return nil, fmt.Errorf("%w: entity is missing actor IRI", ErrActorMissingRequiredField)
if err := validateEntityRequiredFields(entity); err != nil {
return nil, err
}
actorIri := entity.GetJSONLDId().Get()
// Inbox is required
if entity.GetActivityStreamsInbox() == nil || entity.GetActivityStreamsInbox().GetIRI() == nil {
return nil, fmt.Errorf("%w: entity is missing inbox", ErrActorMissingRequiredField)
}
inbox := entity.GetActivityStreamsInbox().GetIRI()
// Username is required (but not a part of the official ActivityPub spec)
if entity.GetActivityStreamsPreferredUsername() == nil || entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString() == "" {
return nil, fmt.Errorf("%w: entity is missing preferred username", ErrActorMissingRequiredField)
}
username := GetFullUsernameFromExternalEntity(entity)
// Key is required
if entity.GetW3IDSecurityV1PublicKey() == nil || entity.GetW3IDSecurityV1PublicKey().Len() == 0 {
return nil, fmt.Errorf("%w: entity is missing public key", ErrActorMissingRequiredField)
}
// Name is optional
var name string
if entity.GetActivityStreamsName() != nil && !entity.GetActivityStreamsName().Empty() {
name = entity.GetActivityStreamsName().At(0).GetXMLSchemaString()
}
// Image is optional
image := GetImageFromIcon(entity.GetActivityStreamsIcon())
apActor := &ActivityPubActor{
ActorIri: actorIri,
Inbox: inbox,
Name: name,
ActorIri: entity.GetJSONLDId().Get(),
Inbox: entity.GetActivityStreamsInbox().GetIRI(),
SharedInbox: getSharedInboxFromEntity(entity),
Name: getNameFromEntity(entity),
Username: entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString(),
FullUsername: username,
FullUsername: GetFullUsernameFromExternalEntity(entity),
W3IDSecurityV1PublicKey: entity.GetW3IDSecurityV1PublicKey(),
Image: image,
Image: GetImageFromIcon(entity.GetActivityStreamsIcon()),
}
return apActor, nil
@@ -174,6 +203,7 @@ type ExternalEntity interface {
GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty
GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty
GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty
GetActivityStreamsEndpoints() vocab.ActivityStreamsEndpointsProperty
}
// MakeActorPropertyWithID will return an actor property filled with the provided IRI.
+6 -4
View File
@@ -14,7 +14,7 @@ import (
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/persistence/configrepository"
)
@@ -60,7 +60,8 @@ func FollowersHandler(w http.ResponseWriter, r *http.Request) {
}
func getInitialFollowersRequest(r *http.Request) (vocab.ActivityStreamsOrderedCollection, error) {
followerCount, _ := persistence.GetFollowerCount()
followersRepo := followersrepository.Get()
followerCount, _ := followersRepo.GetCount()
collection := streams.NewActivityStreamsOrderedCollection()
idProperty := streams.NewJSONLDIdProperty()
id, err := createPageURL(r, nil)
@@ -93,12 +94,13 @@ func getFollowersPage(page string, r *http.Request) (vocab.ActivityStreamsOrdere
return nil, errors.Wrap(err, "unable to parse page number")
}
followerCount, err := persistence.GetFollowerCount()
followersRepo := followersrepository.Get()
followerCount, err := followersRepo.GetCount()
if err != nil {
return nil, errors.Wrap(err, "unable to get follower count")
}
followers, _, err := persistence.GetFederationFollowers(followersPageSize, (pageInt-1)*followersPageSize)
followers, _, err := followersRepo.GetFollowers(followersPageSize, (pageInt-1)*followersPageSize)
if err != nil {
return nil, errors.Wrap(err, "unable to get federation followers")
}
+5 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/chat/events"
@@ -20,6 +21,7 @@ import (
func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsFollow) error {
configRepository := configrepository.Get()
followersRepo := followersrepository.Get()
follow, err := resolvers.MakeFollowRequest(c, activity)
if err != nil {
@@ -35,7 +37,7 @@ func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsF
followRequest := *follow
if err := persistence.AddFollow(followRequest, approved); err != nil {
if err := followersRepo.Add(followRequest, approved); err != nil {
log.Errorln("unable to save follow request", err)
return err
}
@@ -95,5 +97,6 @@ func handleUnfollowRequest(c context.Context, activity vocab.ActivityStreamsUndo
unfollowRequest := *request
log.Traceln("unfollow request:", unfollowRequest)
return persistence.RemoveFollow(unfollowRequest)
followersRepo := followersrepository.Get()
return followersRepo.Remove(unfollowRequest)
}
+3 -2
View File
@@ -5,7 +5,7 @@ import (
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/resolvers"
log "github.com/sirupsen/logrus"
)
@@ -22,5 +22,6 @@ func handleUpdateRequest(c context.Context, activity vocab.ActivityStreamsUpdate
return err
}
return persistence.UpdateFollower(actor.ActorIriString(), actor.InboxString(), actor.Name, actor.FullUsername, actor.ImageString())
followersRepo := followersrepository.Get()
return followersRepo.Update(actor.ActorIriString(), actor.InboxString(), actor.SharedInboxString(), actor.Name, actor.FullUsername, actor.ImageString())
}
+3 -2
View File
@@ -13,7 +13,7 @@ import (
"github.com/go-fed/httpsig"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/persistence/configrepository"
@@ -148,7 +148,8 @@ func isBlockedDomain(domain string) bool {
}
func isBlockedActor(actorIRI *url.URL) (bool, error) {
blockedactor, err := persistence.GetFollower(actorIRI.String())
followersRepo := followersrepository.Get()
blockedactor, err := followersRepo.GetByIRI(actorIRI.String())
if blockedactor != nil && blockedactor.DisabledAt != nil {
return true, errors.Wrap(err, "remote actor is blocked")
+4 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/persistence/configrepository"
)
@@ -79,13 +80,14 @@ func TestBlockedDomains(t *testing.T) {
func TestBlockedActors(t *testing.T) {
person := makeFakePerson()
fakeRequest := streams.NewActivityStreamsFollow()
persistence.AddFollow(apmodels.ActivityPubActor{
followersRepo := followersrepository.Get()
followersRepo.Add(apmodels.ActivityPubActor{
ActorIri: person.GetJSONLDId().GetIRI(),
Inbox: person.GetJSONLDId().GetIRI(),
FollowRequestIri: person.GetJSONLDId().GetIRI(),
RequestObject: fakeRequest,
}, false)
persistence.BlockOrRejectFollower(person.GetJSONLDId().GetIRI().String())
followersRepo.BlockOrReject(person.GetJSONLDId().GetIRI().String())
blocked, err := isBlockedActor(person.GetJSONLDId().GetIRI())
if err != nil {
+33 -10
View File
@@ -13,6 +13,7 @@ import (
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/activitypub/webfinger"
@@ -234,15 +235,17 @@ func getHashtagLinkHTMLFromTagString(baseHashtag string) string {
}
// SendToFollowers will send an arbitrary payload to all follower inboxes.
// Requests are batched to prevent resource exhaustion when there are many followers.
// It uses shared inboxes when available to reduce the number of outbound requests.
func SendToFollowers(payload []byte) error {
configRepository := configrepository.Get()
followersRepo := followersrepository.Get()
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername())
followers, _, err := persistence.GetFederationFollowers(-1, 0)
// Get unique delivery inboxes (prefers shared inboxes over individual inboxes)
inboxes, err := followersRepo.GetUniqueDeliveryInboxes()
if err != nil {
log.Errorln("unable to fetch followers to send to", err)
return errors.New("unable to fetch followers to send payload to")
log.Errorln("unable to fetch delivery inboxes", err)
return errors.New("unable to fetch delivery inboxes to send payload to")
}
// Batch size and delay to prevent resource exhaustion during delivery.
@@ -253,10 +256,22 @@ func SendToFollowers(payload []byte) error {
queued := 0
skipped := 0
for i, follower := range followers {
inbox, err := url.Parse(follower.Inbox)
for i, inboxURL := range inboxes {
inbox, err := url.Parse(inboxURL)
if err != nil {
log.Errorln("unable to parse follower inbox URL", follower.Inbox, err)
log.Warnln("unable to parse inbox URL", inboxURL, err)
continue
}
// SSRF protection: reject non-HTTPS schemes and internal/loopback hosts.
// A malicious remote actor could set their inbox to an internal address
// to trick this server into making requests to internal services.
if inbox.Scheme != "https" {
log.Warnln("rejecting non-HTTPS inbox URL for SSRF protection:", inboxURL)
continue
}
if utils.IsHostnameInternal(inbox.Hostname()) {
log.Warnln("rejecting internal/loopback inbox URL for SSRF protection:", inboxURL)
continue
}
@@ -269,8 +284,8 @@ func SendToFollowers(payload []byte) error {
req, err := crypto.CreateSignedRequest(payload, inbox, localActor)
if err != nil {
log.Errorln("unable to create outbox request", follower.Inbox, err)
return errors.New("unable to create outbox request: " + follower.Inbox)
log.Errorln("unable to create outbox request", inboxURL, err)
continue
}
workerpool.AddToOutboundQueue(req)
@@ -280,7 +295,7 @@ func SendToFollowers(payload []byte) error {
// This helps prevent ActivityPub delivery from competing with video encoding.
// Use queued count (not loop index) to ensure consistent rate limiting
// even when followers are skipped due to circuit breaker or parse errors.
if queued%batchSize == 0 && i+1 < len(followers) {
if queued%batchSize == 0 && i+1 < len(inboxes) {
time.Sleep(batchDelay)
}
}
@@ -294,6 +309,14 @@ func SendToFollowers(payload []byte) error {
// SendToUser will send a payload to a single specific inbox.
func SendToUser(inbox *url.URL, payload []byte) error {
// SSRF protection: reject non-HTTPS schemes and internal/loopback hosts.
if inbox.Scheme != "https" {
return errors.Errorf("rejecting non-HTTPS inbox URL for SSRF protection: %s", inbox.String())
}
if utils.IsHostnameInternal(inbox.Hostname()) {
return errors.Errorf("rejecting internal/loopback inbox URL for SSRF protection: %s", inbox.String())
}
configRepository := configrepository.Get()
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername())
+1 -94
View File
@@ -1,12 +1,6 @@
package persistence
import (
"context"
"github.com/owncast/owncast/db"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
@@ -16,6 +10,7 @@ func createFederationFollowersTable() {
createTableSQL := `CREATE TABLE IF NOT EXISTS ap_followers (
"iri" TEXT NOT NULL,
"inbox" TEXT NOT NULL,
"shared_inbox" TEXT,
"name" TEXT,
"username" TEXT NOT NULL,
"image" TEXT,
@@ -29,91 +24,3 @@ func createFederationFollowersTable() {
_datastore.MustExec(`CREATE INDEX IF NOT EXISTS idx_iri ON ap_followers (iri);`)
_datastore.MustExec(`CREATE INDEX IF NOT EXISTS idx_approved_at ON ap_followers (approved_at);`)
}
// GetFollowerCount will return the number of followers we're keeping track of.
func GetFollowerCount() (int64, error) {
ctx := context.Background()
return _datastore.GetQueries().GetFollowerCount(ctx)
}
// GetFederationFollowers will return a slice of the followers we keep track of locally.
func GetFederationFollowers(limit int, offset int) ([]models.Follower, int, error) {
ctx := context.Background()
total, err := _datastore.GetQueries().GetFollowerCount(ctx)
if err != nil {
return nil, 0, errors.Wrap(err, "unable to fetch total number of followers")
}
followersResult, err := _datastore.GetQueries().GetFederationFollowersWithOffset(ctx, db.GetFederationFollowersWithOffsetParams{
Limit: limit,
Offset: offset,
})
if err != nil {
return nil, 0, err
}
followers := make([]models.Follower, 0)
for _, row := range followersResult {
singleFollower := models.Follower{
Name: row.Name.String,
Username: row.Username,
Image: row.Image.String,
ActorIRI: row.Iri,
Inbox: row.Inbox,
Timestamp: utils.NullTime(row.CreatedAt),
}
followers = append(followers, singleFollower)
}
return followers, int(total), nil
}
// GetPendingFollowRequests will return pending follow requests.
func GetPendingFollowRequests() ([]models.Follower, error) {
pendingFollowersResult, err := _datastore.GetQueries().GetFederationFollowerApprovalRequests(context.Background())
if err != nil {
return nil, err
}
followers := make([]models.Follower, 0)
for _, row := range pendingFollowersResult {
singleFollower := models.Follower{
Name: row.Name.String,
Username: row.Username,
Image: row.Image.String,
ActorIRI: row.Iri,
Inbox: row.Inbox,
Timestamp: utils.NullTime{Time: row.CreatedAt.Time, Valid: true},
}
followers = append(followers, singleFollower)
}
return followers, nil
}
// GetBlockedAndRejectedFollowers will return blocked and rejected followers.
func GetBlockedAndRejectedFollowers() ([]models.Follower, error) {
pendingFollowersResult, err := _datastore.GetQueries().GetRejectedAndBlockedFollowers(context.Background())
if err != nil {
return nil, err
}
followers := make([]models.Follower, 0)
for _, row := range pendingFollowersResult {
singleFollower := models.Follower{
Name: row.Name.String,
Username: row.Username,
Image: row.Image.String,
ActorIRI: row.Iri,
DisabledAt: utils.NullTime{Time: row.DisabledAt.Time, Valid: true},
Timestamp: utils.NullTime{Time: row.CreatedAt.Time, Valid: true},
}
followers = append(followers, singleFollower)
}
return followers, nil
}
+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])
}
}
@@ -0,0 +1,366 @@
package followersrepository
import (
"context"
"database/sql"
"encoding/json"
"net/url"
"time"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/db"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
// FollowersRepository handles persistence of ActivityPub followers.
type FollowersRepository interface {
// GetCount returns the number of followers.
GetCount() (int64, error)
// GetFollowers returns a paginated list of followers.
GetFollowers(limit int, offset int) ([]models.Follower, int, error)
// GetPendingFollowRequests returns pending follow requests.
GetPendingFollowRequests() ([]models.Follower, error)
// GetBlockedAndRejected returns blocked and rejected followers.
GetBlockedAndRejected() ([]models.Follower, error)
// GetUniqueDeliveryInboxes returns unique inbox URLs for delivery.
GetUniqueDeliveryInboxes() ([]string, error)
// GetByIRI returns a single follower by IRI.
GetByIRI(iri string) (*apmodels.ActivityPubActor, error)
// Add saves a new follow to the datastore.
Add(follow apmodels.ActivityPubActor, approved bool) error
// Remove removes a follow from the datastore.
Remove(unfollow apmodels.ActivityPubActor) error
// ApprovePreviousRequest approves a pending follow request.
ApprovePreviousRequest(iri string) error
// BlockOrReject blocks an existing follower or rejects a follow request.
BlockOrReject(iri string) error
// Update updates the details of a stored follower.
Update(actorIRI string, inbox string, sharedInbox string, name string, username string, image string) error
}
// SqlFollowersRepository is the SQL-based implementation of FollowersRepository.
type SqlFollowersRepository struct {
datastore *data.Datastore
}
// NOTE: This is temporary during the transition period.
var temporaryGlobalInstance FollowersRepository
// Get returns the followers repository singleton.
func Get() FollowersRepository {
if temporaryGlobalInstance == nil {
i := New(data.GetDatastore())
temporaryGlobalInstance = i
}
return temporaryGlobalInstance
}
// New creates a new instance of the FollowersRepository.
func New(datastore *data.Datastore) FollowersRepository {
r := SqlFollowersRepository{
datastore: datastore,
}
return &r
}
// GetCount returns the number of followers.
func (r *SqlFollowersRepository) GetCount() (int64, error) {
ctx := context.Background()
return r.datastore.GetQueries().GetFollowerCount(ctx)
}
// GetFollowers returns a paginated list of followers.
func (r *SqlFollowersRepository) GetFollowers(limit int, offset int) ([]models.Follower, int, error) {
ctx := context.Background()
total, err := r.datastore.GetQueries().GetFollowerCount(ctx)
if err != nil {
return nil, 0, errors.Wrap(err, "unable to fetch total number of followers")
}
followersResult, err := r.datastore.GetQueries().GetFederationFollowersWithOffset(ctx, db.GetFederationFollowersWithOffsetParams{
Limit: utils.SafeIntToInt32(limit),
Offset: utils.SafeIntToInt32(offset),
})
if err != nil {
return nil, 0, err
}
followers := make([]models.Follower, 0)
for _, row := range followersResult {
singleFollower := models.Follower{
Name: row.Name.String,
Username: row.Username,
Image: row.Image.String,
ActorIRI: row.Iri,
Inbox: row.Inbox,
SharedInbox: row.SharedInbox.String,
Timestamp: utils.NullTime(row.CreatedAt),
}
followers = append(followers, singleFollower)
}
return followers, int(total), nil
}
// GetPendingFollowRequests returns pending follow requests.
func (r *SqlFollowersRepository) GetPendingFollowRequests() ([]models.Follower, error) {
pendingFollowersResult, err := r.datastore.GetQueries().GetFederationFollowerApprovalRequests(context.Background())
if err != nil {
return nil, err
}
followers := make([]models.Follower, 0)
for _, row := range pendingFollowersResult {
singleFollower := models.Follower{
Name: row.Name.String,
Username: row.Username,
Image: row.Image.String,
ActorIRI: row.Iri,
Inbox: row.Inbox,
SharedInbox: row.SharedInbox.String,
Timestamp: utils.NullTime{Time: row.CreatedAt.Time, Valid: true},
}
followers = append(followers, singleFollower)
}
return followers, nil
}
// GetBlockedAndRejected returns blocked and rejected followers.
func (r *SqlFollowersRepository) GetBlockedAndRejected() ([]models.Follower, error) {
pendingFollowersResult, err := r.datastore.GetQueries().GetRejectedAndBlockedFollowers(context.Background())
if err != nil {
return nil, err
}
followers := make([]models.Follower, 0)
for _, row := range pendingFollowersResult {
singleFollower := models.Follower{
Name: row.Name.String,
Username: row.Username,
Image: row.Image.String,
ActorIRI: row.Iri,
DisabledAt: utils.NullTime{Time: row.DisabledAt.Time, Valid: true},
Timestamp: utils.NullTime{Time: row.CreatedAt.Time, Valid: true},
}
followers = append(followers, singleFollower)
}
return followers, nil
}
// GetUniqueDeliveryInboxes returns unique inbox URLs for delivery.
func (r *SqlFollowersRepository) GetUniqueDeliveryInboxes() ([]string, error) {
ctx := context.Background()
return r.datastore.GetQueries().GetUniqueDeliveryInboxes(ctx)
}
// GetByIRI returns a single follower by IRI.
func (r *SqlFollowersRepository) GetByIRI(iri string) (*apmodels.ActivityPubActor, error) {
result, err := r.datastore.GetQueries().GetFollowerByIRI(context.Background(), iri)
if err != nil {
return nil, err
}
followIRI, err := url.Parse(result.Request)
if err != nil {
return nil, errors.Wrap(err, "error parsing follow request IRI")
}
iriURL, err := url.Parse(result.Iri)
if err != nil {
return nil, errors.Wrap(err, "error parsing actor IRI")
}
inbox, err := url.Parse(result.Inbox)
if err != nil {
return nil, errors.Wrap(err, "error parsing acting inbox")
}
var sharedInbox *url.URL
if result.SharedInbox.Valid && result.SharedInbox.String != "" {
sharedInbox, err = url.Parse(result.SharedInbox.String)
if err != nil {
log.Warnln("error parsing shared inbox, ignoring:", err)
}
}
requestObjectBytes := result.RequestObject
var followRequestObject vocab.ActivityStreamsFollow
resolver, err := streams.NewJSONResolver(func(c context.Context, followObject vocab.ActivityStreamsFollow) error {
followRequestObject = followObject
return nil
})
if err != nil {
return nil, errors.Wrap(err, "error creating JSON resolver")
}
jsonMap := make(map[string]interface{})
err = json.Unmarshal(requestObjectBytes, &jsonMap)
if err != nil {
return nil, errors.Wrap(err, "error unmarshaling follow request object")
}
err = resolver.Resolve(context.Background(), jsonMap)
if err != nil {
return nil, errors.Wrap(err, "error resolving follow request object")
}
image, _ := url.Parse(result.Image.String)
var disabledAt *time.Time
if result.DisabledAt.Valid {
disabledAt = &result.DisabledAt.Time
}
follower := apmodels.ActivityPubActor{
ActorIri: iriURL,
Inbox: inbox,
SharedInbox: sharedInbox,
Name: result.Name.String,
Username: result.Username,
Image: image,
FollowRequestIri: followIRI,
DisabledAt: disabledAt,
RequestObject: followRequestObject,
}
return &follower, nil
}
// Add saves a new follow to the datastore.
func (r *SqlFollowersRepository) Add(follow apmodels.ActivityPubActor, approved bool) error {
if err := follow.Validate(); err != nil {
return errors.Wrap(err, "cannot add invalid follow")
}
log.Traceln("Saving", follow.ActorIriString(), "as a follower.")
followRequestObject, err := apmodels.Serialize(follow.RequestObject)
if err != nil {
return errors.Wrap(err, "error serializing follow request object")
}
return r.createFollow(follow.ActorIriString(), follow.InboxString(), follow.SharedInboxString(), follow.FollowRequestIriString(), follow.Name, follow.Username, follow.ImageString(), followRequestObject, approved)
}
// Remove removes a follow from the datastore.
func (r *SqlFollowersRepository) Remove(unfollow apmodels.ActivityPubActor) error {
if err := unfollow.Validate(); err != nil {
return errors.Wrap(err, "cannot remove invalid follow")
}
log.Traceln("Removing", unfollow.ActorIriString(), "as a follower.")
return r.removeFollow(unfollow.ActorIri)
}
// ApprovePreviousRequest approves a pending follow request.
func (r *SqlFollowersRepository) ApprovePreviousRequest(iri string) error {
return r.datastore.GetQueries().ApproveFederationFollower(context.Background(), db.ApproveFederationFollowerParams{
Iri: iri,
ApprovedAt: sql.NullTime{
Time: time.Now(),
Valid: true,
},
})
}
// BlockOrReject blocks an existing follower or rejects a follow request.
func (r *SqlFollowersRepository) BlockOrReject(iri string) error {
return r.datastore.GetQueries().RejectFederationFollower(context.Background(), db.RejectFederationFollowerParams{
Iri: iri,
DisabledAt: sql.NullTime{
Time: time.Now(),
Valid: true,
},
})
}
// Update updates the details of a stored follower.
func (r *SqlFollowersRepository) Update(actorIRI string, inbox string, sharedInbox string, name string, username string, image string) error {
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
tx, err := r.datastore.DB.Begin()
if err != nil {
return errors.Wrap(err, "error beginning transaction")
}
defer func() {
_ = tx.Rollback()
}()
if err = r.datastore.GetQueries().WithTx(tx).UpdateFollowerByIRI(context.Background(), db.UpdateFollowerByIRIParams{
Inbox: inbox,
SharedInbox: sql.NullString{String: sharedInbox, Valid: sharedInbox != ""},
Name: sql.NullString{String: name, Valid: true},
Username: username,
Image: sql.NullString{String: image, Valid: true},
Iri: actorIRI,
}); err != nil {
return errors.Wrap(err, "error updating follower "+actorIRI)
}
return tx.Commit()
}
func (r *SqlFollowersRepository) createFollow(actor, inbox, sharedInbox, request, name, username, image string, requestObject []byte, approved bool) error {
tx, err := r.datastore.DB.Begin()
if err != nil {
return errors.Wrap(err, "error beginning transaction")
}
defer func() {
_ = tx.Rollback()
}()
var approvedAt sql.NullTime
if approved {
approvedAt = sql.NullTime{
Time: time.Now(),
Valid: true,
}
}
if err = r.datastore.GetQueries().WithTx(tx).AddFollower(context.Background(), db.AddFollowerParams{
Iri: actor,
Inbox: inbox,
SharedInbox: sql.NullString{String: sharedInbox, Valid: sharedInbox != ""},
Name: sql.NullString{String: name, Valid: true},
Username: username,
Image: sql.NullString{String: image, Valid: true},
ApprovedAt: approvedAt,
Request: request,
RequestObject: requestObject,
}); err != nil {
log.Errorln("error creating new federation follow: ", err)
}
return tx.Commit()
}
func (r *SqlFollowersRepository) removeFollow(actor *url.URL) error {
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
tx, err := r.datastore.DB.Begin()
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
if err := r.datastore.GetQueries().WithTx(tx).RemoveFollowerByIRI(context.Background(), actor.String()); err != nil {
return err
}
return tx.Commit()
}
+7 -193
View File
@@ -3,18 +3,16 @@ package persistence
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/url"
"time"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/db"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
@@ -31,193 +29,9 @@ func Setup(datastore *data.Datastore) {
addFollowersFixtureData()
}
// AddFollow will save a follow to the datastore.
func AddFollow(follow apmodels.ActivityPubActor, approved bool) error {
if err := follow.Validate(); err != nil {
return errors.Wrap(err, "cannot add invalid follow")
}
log.Traceln("Saving", follow.ActorIriString(), "as a follower.")
followRequestObject, err := apmodels.Serialize(follow.RequestObject)
if err != nil {
return errors.Wrap(err, "error serializing follow request object")
}
return createFollow(follow.ActorIriString(), follow.InboxString(), follow.FollowRequestIriString(), follow.Name, follow.Username, follow.ImageString(), followRequestObject, approved)
}
// RemoveFollow will remove a follow from the datastore.
func RemoveFollow(unfollow apmodels.ActivityPubActor) error {
if err := unfollow.Validate(); err != nil {
return errors.Wrap(err, "cannot remove invalid follow")
}
log.Traceln("Removing", unfollow.ActorIriString(), "as a follower.")
return removeFollow(unfollow.ActorIri)
}
// GetFollower will return a single follower/request given an IRI.
func GetFollower(iri string) (*apmodels.ActivityPubActor, error) {
result, err := _datastore.GetQueries().GetFollowerByIRI(context.Background(), iri)
if err != nil {
return nil, err
}
followIRI, err := url.Parse(result.Request)
if err != nil {
return nil, errors.Wrap(err, "error parsing follow request IRI")
}
iriURL, err := url.Parse(result.Iri)
if err != nil {
return nil, errors.Wrap(err, "error parsing actor IRI")
}
inbox, err := url.Parse(result.Inbox)
if err != nil {
return nil, errors.Wrap(err, "error parsing acting inbox")
}
requestObjectBytes := result.RequestObject
var followRequestObject vocab.ActivityStreamsFollow
resolver, err := streams.NewJSONResolver(func(c context.Context, followObject vocab.ActivityStreamsFollow) error {
followRequestObject = followObject
return nil
})
if err != nil {
return nil, errors.Wrap(err, "error creating JSON resolver")
}
jsonMap := make(map[string]interface{})
err = json.Unmarshal(requestObjectBytes, &jsonMap)
if err != nil {
return nil, errors.Wrap(err, "error unmarshaling follow request object")
}
err = resolver.Resolve(context.Background(), jsonMap)
if err != nil {
return nil, errors.Wrap(err, "error resolving follow request object")
}
image, _ := url.Parse(result.Image.String)
var disabledAt *time.Time
if result.DisabledAt.Valid {
disabledAt = &result.DisabledAt.Time
}
follower := apmodels.ActivityPubActor{
ActorIri: iriURL,
Inbox: inbox,
Name: result.Name.String,
Username: result.Username,
Image: image,
FollowRequestIri: followIRI,
DisabledAt: disabledAt,
RequestObject: followRequestObject,
}
return &follower, nil
}
// ApprovePreviousFollowRequest will approve a follow request.
func ApprovePreviousFollowRequest(iri string) error {
return _datastore.GetQueries().ApproveFederationFollower(context.Background(), db.ApproveFederationFollowerParams{
Iri: iri,
ApprovedAt: sql.NullTime{
Time: time.Now(),
Valid: true,
},
})
}
// BlockOrRejectFollower will block an existing follower or reject a follow request.
func BlockOrRejectFollower(iri string) error {
return _datastore.GetQueries().RejectFederationFollower(context.Background(), db.RejectFederationFollowerParams{
Iri: iri,
DisabledAt: sql.NullTime{
Time: time.Now(),
Valid: true,
},
})
}
func createFollow(actor, inbox, request, name, username, image string, requestObject []byte, approved bool) error {
tx, err := _datastore.DB.Begin()
if err != nil {
log.Debugln(err)
}
defer func() {
_ = tx.Rollback()
}()
var approvedAt sql.NullTime
if approved {
approvedAt = sql.NullTime{
Time: time.Now(),
Valid: true,
}
}
if err = _datastore.GetQueries().WithTx(tx).AddFollower(context.Background(), db.AddFollowerParams{
Iri: actor,
Inbox: inbox,
Name: sql.NullString{String: name, Valid: true},
Username: username,
Image: sql.NullString{String: image, Valid: true},
ApprovedAt: approvedAt,
Request: request,
RequestObject: requestObject,
}); err != nil {
log.Errorln("error creating new federation follow: ", err)
}
return tx.Commit()
}
// UpdateFollower will update the details of a stored follower given an IRI.
func UpdateFollower(actorIRI string, inbox string, name string, username string, image string) error {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil {
log.Debugln(err)
}
defer func() {
_ = tx.Rollback()
}()
if err = _datastore.GetQueries().WithTx(tx).UpdateFollowerByIRI(context.Background(), db.UpdateFollowerByIRIParams{
Inbox: inbox,
Name: sql.NullString{String: name, Valid: true},
Username: username,
Image: sql.NullString{String: image, Valid: true},
Iri: actorIRI,
}); err != nil {
return fmt.Errorf("error updating follower %s %s", actorIRI, err)
}
return tx.Commit()
}
func removeFollow(actor *url.URL) error {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
if err := _datastore.GetQueries().WithTx(tx).RemoveFollowerByIRI(context.Background(), actor.String()); err != nil {
return err
}
return tx.Commit()
// GetDatastore returns the datastore instance for use by sub-repositories.
func GetDatastore() *data.Datastore {
return _datastore
}
// createFederatedActivitiesTable will create the accepted
@@ -263,7 +77,7 @@ func GetOutbox(limit int, offset int) (vocab.ActivityStreamsOrderedCollection, e
orderedItems := streams.NewActivityStreamsOrderedItemsProperty()
rows, err := _datastore.GetQueries().GetOutboxWithOffset(
context.Background(),
db.GetOutboxWithOffsetParams{Limit: limit, Offset: offset},
db.GetOutboxWithOffsetParams{Limit: utils.SafeIntToInt32(limit), Offset: utils.SafeIntToInt32(offset)},
)
if err != nil {
return collection, err
@@ -335,8 +149,8 @@ func SaveInboundFediverseActivity(objectIRI string, actorIRI string, eventType s
func GetInboundActivities(limit int, offset int) ([]models.FederatedActivity, int, error) {
ctx := context.Background()
rows, err := _datastore.GetQueries().GetInboundActivitiesWithOffset(ctx, db.GetInboundActivitiesWithOffsetParams{
Limit: limit,
Offset: offset,
Limit: utils.SafeIntToInt32(limit),
Offset: utils.SafeIntToInt32(offset),
})
if err != nil {
return nil, 0, err
+10
View File
@@ -9,12 +9,22 @@ import (
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/workerpool"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
"github.com/teris-io/shortid"
)
// SendFollowAccept will send an accept activity to a follow request from a specified local user.
func SendFollowAccept(inbox *url.URL, originalFollowActivity vocab.ActivityStreamsFollow, fromLocalAccountName string) error {
// SSRF protection: reject non-HTTPS schemes and internal/loopback hosts.
if inbox.Scheme != "https" {
return errors.Errorf("rejecting non-HTTPS inbox URL for SSRF protection: %s", inbox.String())
}
if utils.IsHostnameInternal(inbox.Hostname()) {
return errors.Errorf("rejecting internal/loopback inbox URL for SSRF protection: %s", inbox.String())
}
followAccept := makeAcceptFollow(originalFollowActivity, fromLocalAccountName)
localAccountIRI := apmodels.MakeLocalIRIForAccount(fromLocalAccountName)
+1
View File
@@ -29,6 +29,7 @@ func MakeFollowRequest(c context.Context, activity vocab.ActivityStreamsFollow)
ActorIri: person.ActorIri,
FollowRequestIri: activity.GetJSONLDId().Get(),
Inbox: person.Inbox,
SharedInbox: person.SharedInbox,
Name: person.Name,
Username: fullUsername,
Image: person.Image,