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/inbox"
"github.com/owncast/owncast/activitypub/outbox" "github.com/owncast/owncast/activitypub/outbox"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/workerpool" "github.com/owncast/owncast/activitypub/workerpool"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/persistence/configrepository"
@@ -42,8 +43,9 @@ func getOutboundWorkerPoolSize() int {
defaultWorkers = 20 // Default for most instances defaultWorkers = 20 // Default for most instances
) )
followersRepo := followersrepository.Get()
var followerCount int64 var followerCount int64
fc, err := persistence.GetFollowerCount() fc, err := followersRepo.GetCount()
if err != nil { if err != nil {
log.Errorln("Unable to get follower count", err) log.Errorln("Unable to get follower count", err)
return defaultWorkers return defaultWorkers
@@ -58,7 +60,7 @@ func getOutboundWorkerPoolSize() int {
workers = maxWorkers 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 return workers
} }
@@ -79,10 +81,12 @@ func SendDirectFederatedMessage(message, account string) error {
// GetFollowerCount will return the local tracked follower count. // GetFollowerCount will return the local tracked follower count.
func GetFollowerCount() (int64, error) { func GetFollowerCount() (int64, error) {
return persistence.GetFollowerCount() followersRepo := followersrepository.Get()
return followersRepo.GetCount()
} }
// GetPendingFollowRequests will return the pending follow requests. // GetPendingFollowRequests will return the pending follow requests.
func GetPendingFollowRequests() ([]models.Follower, error) { 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 FollowRequestIri *url.URL
// Inbox is the inbox URL of the remote follower // Inbox is the inbox URL of the remote follower
Inbox *url.URL 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 is the avatar image of the Actor.
Image *url.URL Image *url.URL
// DisabledAt is the time, if any, this follower was blocked/removed. // DisabledAt is the time, if any, this follower was blocked/removed.
@@ -74,6 +76,14 @@ func (a *ActivityPubActor) InboxString() string {
return a.Inbox.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. // ImageString returns the string representation of Image, or empty string if nil.
func (a *ActivityPubActor) ImageString() string { func (a *ActivityPubActor) ImageString() string {
if a.Image == nil { if a.Image == nil {
@@ -113,49 +123,68 @@ func NewActivityPubActor(actorIri, inbox *url.URL) (*ActivityPubActor, error) {
}, nil }, 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 // NewActivityPubActorFromEntity creates a new ActivityPubActor from an external entity
// with validation of required fields. // with validation of required fields.
func NewActivityPubActorFromEntity(entity ExternalEntity) (*ActivityPubActor, error) { func NewActivityPubActorFromEntity(entity ExternalEntity) (*ActivityPubActor, error) {
// ActorIri is required (must validate before GetFullUsernameFromExternalEntity which uses it) if err := validateEntityRequiredFields(entity); err != nil {
if entity.GetJSONLDId() == nil || entity.GetJSONLDId().Get() == nil { return nil, err
return nil, fmt.Errorf("%w: entity is missing actor IRI", ErrActorMissingRequiredField)
} }
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{ apActor := &ActivityPubActor{
ActorIri: actorIri, ActorIri: entity.GetJSONLDId().Get(),
Inbox: inbox, Inbox: entity.GetActivityStreamsInbox().GetIRI(),
Name: name, SharedInbox: getSharedInboxFromEntity(entity),
Name: getNameFromEntity(entity),
Username: entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString(), Username: entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString(),
FullUsername: username, FullUsername: GetFullUsernameFromExternalEntity(entity),
W3IDSecurityV1PublicKey: entity.GetW3IDSecurityV1PublicKey(), W3IDSecurityV1PublicKey: entity.GetW3IDSecurityV1PublicKey(),
Image: image, Image: GetImageFromIcon(entity.GetActivityStreamsIcon()),
} }
return apActor, nil return apActor, nil
@@ -174,6 +203,7 @@ type ExternalEntity interface {
GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty
GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty
GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty
GetActivityStreamsEndpoints() vocab.ActivityStreamsEndpointsProperty
} }
// MakeActorPropertyWithID will return an actor property filled with the provided IRI. // 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/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto" "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/requests"
"github.com/owncast/owncast/persistence/configrepository" "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) { func getInitialFollowersRequest(r *http.Request) (vocab.ActivityStreamsOrderedCollection, error) {
followerCount, _ := persistence.GetFollowerCount() followersRepo := followersrepository.Get()
followerCount, _ := followersRepo.GetCount()
collection := streams.NewActivityStreamsOrderedCollection() collection := streams.NewActivityStreamsOrderedCollection()
idProperty := streams.NewJSONLDIdProperty() idProperty := streams.NewJSONLDIdProperty()
id, err := createPageURL(r, nil) 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") return nil, errors.Wrap(err, "unable to parse page number")
} }
followerCount, err := persistence.GetFollowerCount() followersRepo := followersrepository.Get()
followerCount, err := followersRepo.GetCount()
if err != nil { if err != nil {
return nil, errors.Wrap(err, "unable to get follower count") 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 { if err != nil {
return nil, errors.Wrap(err, "unable to get federation followers") 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/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/requests" "github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/activitypub/resolvers" "github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/chat/events"
@@ -20,6 +21,7 @@ import (
func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsFollow) error { func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsFollow) error {
configRepository := configrepository.Get() configRepository := configrepository.Get()
followersRepo := followersrepository.Get()
follow, err := resolvers.MakeFollowRequest(c, activity) follow, err := resolvers.MakeFollowRequest(c, activity)
if err != nil { if err != nil {
@@ -35,7 +37,7 @@ func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsF
followRequest := *follow 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) log.Errorln("unable to save follow request", err)
return err return err
} }
@@ -95,5 +97,6 @@ func handleUnfollowRequest(c context.Context, activity vocab.ActivityStreamsUndo
unfollowRequest := *request unfollowRequest := *request
log.Traceln("unfollow request:", unfollowRequest) 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/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels" "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/activitypub/resolvers"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -22,5 +22,6 @@ func handleUpdateRequest(c context.Context, activity vocab.ActivityStreamsUpdate
return err 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/go-fed/httpsig"
"github.com/owncast/owncast/activitypub/apmodels" "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/activitypub/resolvers"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/persistence/configrepository"
@@ -148,7 +148,8 @@ func isBlockedDomain(domain string) bool {
} }
func isBlockedActor(actorIRI *url.URL) (bool, error) { 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 { if blockedactor != nil && blockedactor.DisabledAt != nil {
return true, errors.Wrap(err, "remote actor is blocked") 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/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/persistence/configrepository"
) )
@@ -79,13 +80,14 @@ func TestBlockedDomains(t *testing.T) {
func TestBlockedActors(t *testing.T) { func TestBlockedActors(t *testing.T) {
person := makeFakePerson() person := makeFakePerson()
fakeRequest := streams.NewActivityStreamsFollow() fakeRequest := streams.NewActivityStreamsFollow()
persistence.AddFollow(apmodels.ActivityPubActor{ followersRepo := followersrepository.Get()
followersRepo.Add(apmodels.ActivityPubActor{
ActorIri: person.GetJSONLDId().GetIRI(), ActorIri: person.GetJSONLDId().GetIRI(),
Inbox: person.GetJSONLDId().GetIRI(), Inbox: person.GetJSONLDId().GetIRI(),
FollowRequestIri: person.GetJSONLDId().GetIRI(), FollowRequestIri: person.GetJSONLDId().GetIRI(),
RequestObject: fakeRequest, RequestObject: fakeRequest,
}, false) }, false)
persistence.BlockOrRejectFollower(person.GetJSONLDId().GetIRI().String()) followersRepo.BlockOrReject(person.GetJSONLDId().GetIRI().String())
blocked, err := isBlockedActor(person.GetJSONLDId().GetIRI()) blocked, err := isBlockedActor(person.GetJSONLDId().GetIRI())
if err != nil { if err != nil {
+33 -10
View File
@@ -13,6 +13,7 @@ import (
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto" "github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/requests" "github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/activitypub/resolvers" "github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/activitypub/webfinger" "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. // 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 { func SendToFollowers(payload []byte) error {
configRepository := configrepository.Get() configRepository := configrepository.Get()
followersRepo := followersrepository.Get()
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername()) 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 { if err != nil {
log.Errorln("unable to fetch followers to send to", err) log.Errorln("unable to fetch delivery inboxes", err)
return errors.New("unable to fetch followers to send payload to") return errors.New("unable to fetch delivery inboxes to send payload to")
} }
// Batch size and delay to prevent resource exhaustion during delivery. // Batch size and delay to prevent resource exhaustion during delivery.
@@ -253,10 +256,22 @@ func SendToFollowers(payload []byte) error {
queued := 0 queued := 0
skipped := 0 skipped := 0
for i, follower := range followers { for i, inboxURL := range inboxes {
inbox, err := url.Parse(follower.Inbox) inbox, err := url.Parse(inboxURL)
if err != nil { 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 continue
} }
@@ -269,8 +284,8 @@ func SendToFollowers(payload []byte) error {
req, err := crypto.CreateSignedRequest(payload, inbox, localActor) req, err := crypto.CreateSignedRequest(payload, inbox, localActor)
if err != nil { if err != nil {
log.Errorln("unable to create outbox request", follower.Inbox, err) log.Errorln("unable to create outbox request", inboxURL, err)
return errors.New("unable to create outbox request: " + follower.Inbox) continue
} }
workerpool.AddToOutboundQueue(req) workerpool.AddToOutboundQueue(req)
@@ -280,7 +295,7 @@ func SendToFollowers(payload []byte) error {
// This helps prevent ActivityPub delivery from competing with video encoding. // This helps prevent ActivityPub delivery from competing with video encoding.
// Use queued count (not loop index) to ensure consistent rate limiting // Use queued count (not loop index) to ensure consistent rate limiting
// even when followers are skipped due to circuit breaker or parse errors. // 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) time.Sleep(batchDelay)
} }
} }
@@ -294,6 +309,14 @@ func SendToFollowers(payload []byte) error {
// SendToUser will send a payload to a single specific inbox. // SendToUser will send a payload to a single specific inbox.
func SendToUser(inbox *url.URL, payload []byte) error { 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() configRepository := configrepository.Get()
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername()) localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername())
+1 -94
View File
@@ -1,12 +1,6 @@
package persistence package persistence
import ( 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" log "github.com/sirupsen/logrus"
) )
@@ -16,6 +10,7 @@ func createFederationFollowersTable() {
createTableSQL := `CREATE TABLE IF NOT EXISTS ap_followers ( createTableSQL := `CREATE TABLE IF NOT EXISTS ap_followers (
"iri" TEXT NOT NULL, "iri" TEXT NOT NULL,
"inbox" TEXT NOT NULL, "inbox" TEXT NOT NULL,
"shared_inbox" TEXT,
"name" TEXT, "name" TEXT,
"username" TEXT NOT NULL, "username" TEXT NOT NULL,
"image" TEXT, "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_iri ON ap_followers (iri);`)
_datastore.MustExec(`CREATE INDEX IF NOT EXISTS idx_approved_at ON ap_followers (approved_at);`) _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 package persistence
import ( import (
"net/url"
"os" "os"
"strings"
"testing" "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/core/data"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
@@ -22,16 +27,30 @@ func setup() {
_datastore = data.GetDatastore() _datastore = data.GetDatastore()
createFederationFollowersTable() createFederationFollowersTable()
followersRepo := followersrepository.New(_datastore)
number := 100 number := 100
for i := 0; i < number; i++ { for i := 0; i < number; i++ {
u := createFakeFollower() 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) followers = append(followers, u)
} }
} }
func TestQueryFollowers(t *testing.T) { 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 { if err != nil {
t.Errorf("Error querying followers: %s", err) t.Errorf("Error querying followers: %s", err)
} }
@@ -46,7 +65,8 @@ func TestQueryFollowers(t *testing.T) {
} }
func TestQueryFollowersWithOffset(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 { if err != nil {
t.Errorf("Error querying followers: %s", err) t.Errorf("Error querying followers: %s", err)
} }
@@ -61,7 +81,8 @@ func TestQueryFollowersWithOffset(t *testing.T) {
} }
func TestQueryFollowersWithOffsetAndLimit(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 { if err != nil {
t.Errorf("Error querying followers: %s", err) t.Errorf("Error querying followers: %s", err)
} }
@@ -76,7 +97,8 @@ func TestQueryFollowersWithOffsetAndLimit(t *testing.T) {
} }
func TestQueryFollowersWithPagination(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 { if err != nil {
t.Errorf("Error querying followers: %s", err) t.Errorf("Error querying followers: %s", err)
} }
@@ -105,3 +127,194 @@ func createFakeFollower() models.Follower {
Timestamp: utils.NullTime{}, 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 ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"net/url"
"time" "time"
"github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/resolvers" "github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/db" "github.com/owncast/owncast/db"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -31,193 +29,9 @@ func Setup(datastore *data.Datastore) {
addFollowersFixtureData() addFollowersFixtureData()
} }
// AddFollow will save a follow to the datastore. // GetDatastore returns the datastore instance for use by sub-repositories.
func AddFollow(follow apmodels.ActivityPubActor, approved bool) error { func GetDatastore() *data.Datastore {
if err := follow.Validate(); err != nil { return _datastore
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()
} }
// createFederatedActivitiesTable will create the accepted // createFederatedActivitiesTable will create the accepted
@@ -263,7 +77,7 @@ func GetOutbox(limit int, offset int) (vocab.ActivityStreamsOrderedCollection, e
orderedItems := streams.NewActivityStreamsOrderedItemsProperty() orderedItems := streams.NewActivityStreamsOrderedItemsProperty()
rows, err := _datastore.GetQueries().GetOutboxWithOffset( rows, err := _datastore.GetQueries().GetOutboxWithOffset(
context.Background(), context.Background(),
db.GetOutboxWithOffsetParams{Limit: limit, Offset: offset}, db.GetOutboxWithOffsetParams{Limit: utils.SafeIntToInt32(limit), Offset: utils.SafeIntToInt32(offset)},
) )
if err != nil { if err != nil {
return collection, err 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) { func GetInboundActivities(limit int, offset int) ([]models.FederatedActivity, int, error) {
ctx := context.Background() ctx := context.Background()
rows, err := _datastore.GetQueries().GetInboundActivitiesWithOffset(ctx, db.GetInboundActivitiesWithOffsetParams{ rows, err := _datastore.GetQueries().GetInboundActivitiesWithOffset(ctx, db.GetInboundActivitiesWithOffsetParams{
Limit: limit, Limit: utils.SafeIntToInt32(limit),
Offset: offset, Offset: utils.SafeIntToInt32(offset),
}) })
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
+10
View File
@@ -9,12 +9,22 @@ import (
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto" "github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/workerpool" "github.com/owncast/owncast/activitypub/workerpool"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
"github.com/teris-io/shortid" "github.com/teris-io/shortid"
) )
// SendFollowAccept will send an accept activity to a follow request from a specified local user. // 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 { 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) followAccept := makeAcceptFollow(originalFollowActivity, fromLocalAccountName)
localAccountIRI := apmodels.MakeLocalIRIForAccount(fromLocalAccountName) localAccountIRI := apmodels.MakeLocalIRIForAccount(fromLocalAccountName)
+1
View File
@@ -29,6 +29,7 @@ func MakeFollowRequest(c context.Context, activity vocab.ActivityStreamsFollow)
ActorIri: person.ActorIri, ActorIri: person.ActorIri,
FollowRequestIri: activity.GetJSONLDId().Get(), FollowRequestIri: activity.GetJSONLDId().Get(),
Inbox: person.Inbox, Inbox: person.Inbox,
SharedInbox: person.SharedInbox,
Name: person.Name, Name: person.Name,
Username: fullUsername, Username: fullUsername,
Image: person.Image, Image: person.Image,
+1 -1
View File
@@ -19,7 +19,7 @@ import (
) )
const ( const (
schemaVersion = 7 schemaVersion = 8
) )
var ( var (
+3 -2
View File
@@ -2,14 +2,15 @@ package webhooks
import ( import (
"github.com/owncast/owncast/activitypub/events" "github.com/owncast/owncast/activitypub/events"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
) )
// SendFediverseEventFollow will send a user followed event to webhook // SendFediverseEventFollow will send a user followed event to webhook
// destinations. // destinations.
func SendFediverseEngagementFollowEvent(iri string) { func SendFediverseEngagementFollowEvent(iri string) {
follower, err := persistence.GetFollower(iri) followersRepo := followersrepository.Get()
follower, err := followersRepo.GetByIRI(iri)
if err != nil { if err != nil {
return return
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.15.0 // sqlc v1.30.0
package db package db
+2 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.15.0 // sqlc v1.30.0
package db package db
@@ -20,6 +20,7 @@ type ApAcceptedActivity struct {
type ApFollower struct { type ApFollower struct {
Iri string Iri string
Inbox string Inbox string
SharedInbox sql.NullString
Name sql.NullString Name sql.NullString
Username string Username string
Image sql.NullString Image sql.NullString
+8 -5
View File
@@ -9,13 +9,13 @@ SElECT count(*) FROM ap_followers WHERE approved_at is not null;
SElECT count(*) FROM ap_outbox; SElECT count(*) FROM ap_outbox;
-- name: GetFederationFollowersWithOffset :many -- name: GetFederationFollowersWithOffset :many
SELECT iri, inbox, name, username, image, created_at FROM ap_followers WHERE approved_at is not null ORDER BY created_at DESC LIMIT $1 OFFSET $2; SELECT iri, inbox, shared_inbox, name, username, image, created_at FROM ap_followers WHERE approved_at is not null ORDER BY created_at DESC LIMIT $1 OFFSET $2;
-- name: GetRejectedAndBlockedFollowers :many -- name: GetRejectedAndBlockedFollowers :many
SELECT iri, name, username, image, created_at, disabled_at FROM ap_followers WHERE disabled_at is not null; SELECT iri, name, username, image, created_at, disabled_at FROM ap_followers WHERE disabled_at is not null;
-- name: GetFederationFollowerApprovalRequests :many -- name: GetFederationFollowerApprovalRequests :many
SELECT iri, inbox, name, username, image, created_at FROM ap_followers WHERE approved_at IS null AND disabled_at is null; SELECT iri, inbox, shared_inbox, name, username, image, created_at FROM ap_followers WHERE approved_at IS null AND disabled_at is null;
-- name: ApproveFederationFollower :exec -- name: ApproveFederationFollower :exec
UPDATE ap_followers SET approved_at = $1, disabled_at = null WHERE iri = $2; UPDATE ap_followers SET approved_at = $1, disabled_at = null WHERE iri = $2;
@@ -24,7 +24,7 @@ UPDATE ap_followers SET approved_at = $1, disabled_at = null WHERE iri = $2;
UPDATE ap_followers SET approved_at = null, disabled_at = $1 WHERE iri = $2; UPDATE ap_followers SET approved_at = null, disabled_at = $1 WHERE iri = $2;
-- name: GetFollowerByIRI :one -- name: GetFollowerByIRI :one
SELECT iri, inbox, name, username, image, request, request_object, created_at, approved_at, disabled_at FROM ap_followers WHERE iri = $1; SELECT iri, inbox, shared_inbox, name, username, image, request, request_object, created_at, approved_at, disabled_at FROM ap_followers WHERE iri = $1;
-- name: GetOutboxWithOffset :many -- name: GetOutboxWithOffset :many
SELECT value FROM ap_outbox LIMIT $1 OFFSET $2; SELECT value FROM ap_outbox LIMIT $1 OFFSET $2;
@@ -37,7 +37,7 @@ SELECT value, live_notification, created_at FROM ap_outbox WHERE iri = $1;
DELETE FROM ap_followers WHERE iri = $1; DELETE FROM ap_followers WHERE iri = $1;
-- name: AddFollower :exec -- name: AddFollower :exec
INSERT INTO ap_followers(iri, inbox, request, request_object, name, username, image, approved_at) values($1, $2, $3, $4, $5, $6, $7, $8); INSERT INTO ap_followers(iri, inbox, shared_inbox, request, request_object, name, username, image, approved_at) values($1, $2, $3, $4, $5, $6, $7, $8, $9);
-- name: AddToOutbox :exec -- name: AddToOutbox :exec
INSERT INTO ap_outbox(iri, value, type, live_notification) values($1, $2, $3, $4); INSERT INTO ap_outbox(iri, value, type, live_notification) values($1, $2, $3, $4);
@@ -55,7 +55,10 @@ SELECT iri, actor, type, timestamp FROM ap_accepted_activities ORDER BY timestam
SELECT count(*) FROM ap_accepted_activities WHERE iri = $1 AND actor = $2 AND TYPE = $3; SELECT count(*) FROM ap_accepted_activities WHERE iri = $1 AND actor = $2 AND TYPE = $3;
-- name: UpdateFollowerByIRI :exec -- name: UpdateFollowerByIRI :exec
UPDATE ap_followers SET inbox = $1, name = $2, username = $3, image = $4 WHERE iri = $5; UPDATE ap_followers SET inbox = $1, shared_inbox = $2, name = $3, username = $4, image = $5 WHERE iri = $6;
-- name: GetUniqueDeliveryInboxes :many
SELECT COALESCE(shared_inbox, inbox) as delivery_inbox FROM ap_followers WHERE approved_at is not null GROUP BY delivery_inbox;
-- name: BanIPAddress :exec -- name: BanIPAddress :exec
INSERT INTO ip_bans(ip_address, notes) values($1, $2); INSERT INTO ip_bans(ip_address, notes) values($1, $2);
+66 -30
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.19.1 // sqlc v1.30.0
// source: query.sql // source: query.sql
package db package db
@@ -41,12 +41,13 @@ func (q *Queries) AddAuthForUser(ctx context.Context, arg AddAuthForUserParams)
} }
const addFollower = `-- name: AddFollower :exec const addFollower = `-- name: AddFollower :exec
INSERT INTO ap_followers(iri, inbox, request, request_object, name, username, image, approved_at) values($1, $2, $3, $4, $5, $6, $7, $8) INSERT INTO ap_followers(iri, inbox, shared_inbox, request, request_object, name, username, image, approved_at) values($1, $2, $3, $4, $5, $6, $7, $8, $9)
` `
type AddFollowerParams struct { type AddFollowerParams struct {
Iri string Iri string
Inbox string Inbox string
SharedInbox sql.NullString
Request string Request string
RequestObject []byte RequestObject []byte
Name sql.NullString Name sql.NullString
@@ -59,6 +60,7 @@ func (q *Queries) AddFollower(ctx context.Context, arg AddFollowerParams) error
_, err := q.db.ExecContext(ctx, addFollower, _, err := q.db.ExecContext(ctx, addFollower,
arg.Iri, arg.Iri,
arg.Inbox, arg.Inbox,
arg.SharedInbox,
arg.Request, arg.Request,
arg.RequestObject, arg.RequestObject,
arg.Name, arg.Name,
@@ -158,7 +160,7 @@ UPDATE users SET display_color = $1 WHERE id = $2
` `
type ChangeDisplayColorParams struct { type ChangeDisplayColorParams struct {
DisplayColor int DisplayColor int32
ID string ID string
} }
@@ -206,16 +208,17 @@ func (q *Queries) DoesInboundActivityExist(ctx context.Context, arg DoesInboundA
} }
const getFederationFollowerApprovalRequests = `-- name: GetFederationFollowerApprovalRequests :many const getFederationFollowerApprovalRequests = `-- name: GetFederationFollowerApprovalRequests :many
SELECT iri, inbox, name, username, image, created_at FROM ap_followers WHERE approved_at IS null AND disabled_at is null SELECT iri, inbox, shared_inbox, name, username, image, created_at FROM ap_followers WHERE approved_at IS null AND disabled_at is null
` `
type GetFederationFollowerApprovalRequestsRow struct { type GetFederationFollowerApprovalRequestsRow struct {
Iri string Iri string
Inbox string Inbox string
Name sql.NullString SharedInbox sql.NullString
Username string Name sql.NullString
Image sql.NullString Username string
CreatedAt sql.NullTime Image sql.NullString
CreatedAt sql.NullTime
} }
func (q *Queries) GetFederationFollowerApprovalRequests(ctx context.Context) ([]GetFederationFollowerApprovalRequestsRow, error) { func (q *Queries) GetFederationFollowerApprovalRequests(ctx context.Context) ([]GetFederationFollowerApprovalRequestsRow, error) {
@@ -230,6 +233,7 @@ func (q *Queries) GetFederationFollowerApprovalRequests(ctx context.Context) ([]
if err := rows.Scan( if err := rows.Scan(
&i.Iri, &i.Iri,
&i.Inbox, &i.Inbox,
&i.SharedInbox,
&i.Name, &i.Name,
&i.Username, &i.Username,
&i.Image, &i.Image,
@@ -249,21 +253,22 @@ func (q *Queries) GetFederationFollowerApprovalRequests(ctx context.Context) ([]
} }
const getFederationFollowersWithOffset = `-- name: GetFederationFollowersWithOffset :many const getFederationFollowersWithOffset = `-- name: GetFederationFollowersWithOffset :many
SELECT iri, inbox, name, username, image, created_at FROM ap_followers WHERE approved_at is not null ORDER BY created_at DESC LIMIT $1 OFFSET $2 SELECT iri, inbox, shared_inbox, name, username, image, created_at FROM ap_followers WHERE approved_at is not null ORDER BY created_at DESC LIMIT $1 OFFSET $2
` `
type GetFederationFollowersWithOffsetParams struct { type GetFederationFollowersWithOffsetParams struct {
Limit int Limit int32
Offset int Offset int32
} }
type GetFederationFollowersWithOffsetRow struct { type GetFederationFollowersWithOffsetRow struct {
Iri string Iri string
Inbox string Inbox string
Name sql.NullString SharedInbox sql.NullString
Username string Name sql.NullString
Image sql.NullString Username string
CreatedAt sql.NullTime Image sql.NullString
CreatedAt sql.NullTime
} }
func (q *Queries) GetFederationFollowersWithOffset(ctx context.Context, arg GetFederationFollowersWithOffsetParams) ([]GetFederationFollowersWithOffsetRow, error) { func (q *Queries) GetFederationFollowersWithOffset(ctx context.Context, arg GetFederationFollowersWithOffsetParams) ([]GetFederationFollowersWithOffsetRow, error) {
@@ -278,6 +283,7 @@ func (q *Queries) GetFederationFollowersWithOffset(ctx context.Context, arg GetF
if err := rows.Scan( if err := rows.Scan(
&i.Iri, &i.Iri,
&i.Inbox, &i.Inbox,
&i.SharedInbox,
&i.Name, &i.Name,
&i.Username, &i.Username,
&i.Image, &i.Image,
@@ -297,7 +303,7 @@ func (q *Queries) GetFederationFollowersWithOffset(ctx context.Context, arg GetF
} }
const getFollowerByIRI = `-- name: GetFollowerByIRI :one const getFollowerByIRI = `-- name: GetFollowerByIRI :one
SELECT iri, inbox, name, username, image, request, request_object, created_at, approved_at, disabled_at FROM ap_followers WHERE iri = $1 SELECT iri, inbox, shared_inbox, name, username, image, request, request_object, created_at, approved_at, disabled_at FROM ap_followers WHERE iri = $1
` `
func (q *Queries) GetFollowerByIRI(ctx context.Context, iri string) (ApFollower, error) { func (q *Queries) GetFollowerByIRI(ctx context.Context, iri string) (ApFollower, error) {
@@ -306,6 +312,7 @@ func (q *Queries) GetFollowerByIRI(ctx context.Context, iri string) (ApFollower,
err := row.Scan( err := row.Scan(
&i.Iri, &i.Iri,
&i.Inbox, &i.Inbox,
&i.SharedInbox,
&i.Name, &i.Name,
&i.Username, &i.Username,
&i.Image, &i.Image,
@@ -365,8 +372,8 @@ SELECT iri, actor, type, timestamp FROM ap_accepted_activities ORDER BY timestam
` `
type GetInboundActivitiesWithOffsetParams struct { type GetInboundActivitiesWithOffsetParams struct {
Limit int Limit int32
Offset int Offset int32
} }
type GetInboundActivitiesWithOffsetRow struct { type GetInboundActivitiesWithOffsetRow struct {
@@ -514,8 +521,8 @@ SELECT value FROM ap_outbox LIMIT $1 OFFSET $2
` `
type GetOutboxWithOffsetParams struct { type GetOutboxWithOffsetParams struct {
Limit int Limit int32
Offset int Offset int32
} }
func (q *Queries) GetOutboxWithOffset(ctx context.Context, arg GetOutboxWithOffsetParams) ([][]byte, error) { func (q *Queries) GetOutboxWithOffset(ctx context.Context, arg GetOutboxWithOffsetParams) ([][]byte, error) {
@@ -584,6 +591,33 @@ func (q *Queries) GetRejectedAndBlockedFollowers(ctx context.Context) ([]GetReje
return items, nil return items, nil
} }
const getUniqueDeliveryInboxes = `-- name: GetUniqueDeliveryInboxes :many
SELECT COALESCE(shared_inbox, inbox) as delivery_inbox FROM ap_followers WHERE approved_at is not null GROUP BY delivery_inbox
`
func (q *Queries) GetUniqueDeliveryInboxes(ctx context.Context) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getUniqueDeliveryInboxes)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var delivery_inbox string
if err := rows.Scan(&delivery_inbox); err != nil {
return nil, err
}
items = append(items, delivery_inbox)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getUserByAccessToken = `-- name: GetUserByAccessToken :one const getUserByAccessToken = `-- name: GetUserByAccessToken :one
SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id
` `
@@ -758,20 +792,22 @@ func (q *Queries) SetUserAsAuthenticated(ctx context.Context, id string) error {
} }
const updateFollowerByIRI = `-- name: UpdateFollowerByIRI :exec const updateFollowerByIRI = `-- name: UpdateFollowerByIRI :exec
UPDATE ap_followers SET inbox = $1, name = $2, username = $3, image = $4 WHERE iri = $5 UPDATE ap_followers SET inbox = $1, shared_inbox = $2, name = $3, username = $4, image = $5 WHERE iri = $6
` `
type UpdateFollowerByIRIParams struct { type UpdateFollowerByIRIParams struct {
Inbox string Inbox string
Name sql.NullString SharedInbox sql.NullString
Username string Name sql.NullString
Image sql.NullString Username string
Iri string Image sql.NullString
Iri string
} }
func (q *Queries) UpdateFollowerByIRI(ctx context.Context, arg UpdateFollowerByIRIParams) error { func (q *Queries) UpdateFollowerByIRI(ctx context.Context, arg UpdateFollowerByIRIParams) error {
_, err := q.db.ExecContext(ctx, updateFollowerByIRI, _, err := q.db.ExecContext(ctx, updateFollowerByIRI,
arg.Inbox, arg.Inbox,
arg.SharedInbox,
arg.Name, arg.Name,
arg.Username, arg.Username,
arg.Image, arg.Image,
+1
View File
@@ -4,6 +4,7 @@
CREATE TABLE IF NOT EXISTS ap_followers ( CREATE TABLE IF NOT EXISTS ap_followers (
"iri" TEXT NOT NULL, "iri" TEXT NOT NULL,
"inbox" TEXT NOT NULL, "inbox" TEXT NOT NULL,
"shared_inbox" TEXT,
"name" TEXT, "name" TEXT,
"username" TEXT NOT NULL, "username" TEXT NOT NULL,
"image" TEXT, "image" TEXT,
+2 -2
View File
@@ -15,6 +15,7 @@ require (
github.com/go-fed/httpsig v1.1.0 github.com/go-fed/httpsig v1.1.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/grafov/m3u8 v0.12.1 github.com/grafov/m3u8 v0.12.1
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/jellydator/ttlcache/v3 v3.4.0 github.com/jellydator/ttlcache/v3 v3.4.0
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
github.com/mattn/go-sqlite3 v1.14.33 github.com/mattn/go-sqlite3 v1.14.33
@@ -55,7 +56,6 @@ require (
github.com/google/uuid v1.5.0 // indirect github.com/google/uuid v1.5.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
@@ -79,4 +79,4 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
replace github.com/go-fed/activity => github.com/owncast/activity v1.0.1-0.20260121052038-3b267f104359 replace github.com/go-fed/activity => github.com/owncast/activity v1.0.1-0.20260122170223-675f6eb53e71
+10 -2
View File
@@ -26,6 +26,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-fed/httpsig v0.1.1-0.20190914113940-c2de3672e5b5/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= github.com/go-fed/httpsig v0.1.1-0.20190914113940-c2de3672e5b5/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
@@ -54,6 +56,8 @@ github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s=
github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
@@ -83,6 +87,10 @@ github.com/lestrrat-go/strftime v1.1.1 h1:zgf8QCsgj27GlKBy3SU9/8MMgegZ8UCzlCyHYr
github.com/lestrrat-go/strftime v1.1.1/go.mod h1:YDrzHJAODYQ+xxvrn5SG01uFIQAeDTzpxNVppCz7Nmw= github.com/lestrrat-go/strftime v1.1.1/go.mod h1:YDrzHJAODYQ+xxvrn5SG01uFIQAeDTzpxNVppCz7Nmw=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
@@ -102,8 +110,8 @@ github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNs
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
github.com/owncast/activity v1.0.1-0.20260121052038-3b267f104359 h1:APHNdGSl1tpxnjT0t+qWhWLhp0zdfIQYKHHBimLQiRY= github.com/owncast/activity v1.0.1-0.20260122170223-675f6eb53e71 h1:dJ6DdVqcZjz0P7UIKPCARucmyIGNXiL7BxNfg1Ul8Gs=
github.com/owncast/activity v1.0.1-0.20260121052038-3b267f104359/go.mod h1:v4QoPaAzjWZ8zN2VFVGL5ep9C02mst0hQYHUpQwso4Q= github.com/owncast/activity v1.0.1-0.20260122170223-675f6eb53e71/go.mod h1:v4QoPaAzjWZ8zN2VFVGL5ep9C02mst0hQYHUpQwso4Q=
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+2
View File
@@ -8,6 +8,8 @@ type Follower struct {
ActorIRI string `json:"link"` ActorIRI string `json:"link"`
// Inbox is the inbox URL of the remote follower // Inbox is the inbox URL of the remote follower
Inbox string `json:"-"` Inbox string `json:"-"`
// SharedInbox is the shared inbox URL of the remote follower's server (optional)
SharedInbox string `json:"-"`
// Name is the display name of the follower. // Name is the display name of the follower.
Name string `json:"name"` Name string `json:"name"`
// Username is the account username of the remote actor. // Username is the account username of the remote actor.
+19
View File
@@ -33,6 +33,8 @@ func MigrateDatabaseSchema(db *sql.DB, from, to int) error {
migrateToSchema6(db) migrateToSchema6(db)
case 6: case 6:
migrateToSchema7(db) migrateToSchema7(db)
case 7:
migrateToSchema8(db)
default: default:
log.Fatalln("missing database migration step") log.Fatalln("missing database migration step")
} }
@@ -46,6 +48,23 @@ func MigrateDatabaseSchema(db *sql.DB, from, to int) error {
return nil return nil
} }
func migrateToSchema8(db *sql.DB) {
// Add shared_inbox column to ap_followers for ActivityPub shared inbox support.
// This allows delivering messages to a server's shared inbox instead of each
// user's individual inbox, reducing the number of outbound requests.
stmt, err := db.Prepare("ALTER TABLE ap_followers ADD COLUMN shared_inbox TEXT")
if err != nil {
log.Errorln("Error running migration. This may be because you have already been running a dev version.", err)
return
}
defer stmt.Close()
_, err = stmt.Exec()
if err != nil {
log.Warnln(err)
}
}
func migrateToSchema7(db *sql.DB) { func migrateToSchema7(db *sql.DB) {
log.Println("Migrating users. This may take time if you have lots of users...") log.Println("Migrating users. This may take time if you have lots of users...")
+1 -1
View File
@@ -144,7 +144,7 @@ func (r *SqlUserRepository) ChangeUserColor(userID string, color int) error {
defer r.datastore.DbLock.Unlock() defer r.datastore.DbLock.Unlock()
if err := r.datastore.GetQueries().ChangeDisplayColor(context.Background(), db.ChangeDisplayColorParams{ if err := r.datastore.GetQueries().ChangeDisplayColor(context.Background(), db.ChangeDisplayColorParams{
DisplayColor: color, DisplayColor: utils.SafeIntToInt32(color),
ID: userID, ID: userID,
}); err != nil { }); err != nil {
return errors.Wrap(err, "unable to change display color") return errors.Wrap(err, "unable to change display color")
+141 -4
View File
@@ -37,6 +37,7 @@ OWNCAST_HOSTNAME="owncast.local"
ADMIN_USER="admin" ADMIN_USER="admin"
ADMIN_PASS="abc123" ADMIN_PASS="abc123"
FEDERATION_USERNAME="streamer" FEDERATION_USERNAME="streamer"
CLEAR_SHARED_INBOX_PERCENT="${CLEAR_SHARED_INBOX_PERCENT:-30}" # Percentage of followers to clear shared_inbox (0 = none)
# URLs (HTTPS via proxy) # URLs (HTTPS via proxy)
SNAC_URL="https://${SNAC_HOSTNAME}:${PROXY_PORT}" SNAC_URL="https://${SNAC_HOSTNAME}:${PROXY_PORT}"
@@ -59,13 +60,13 @@ SNAC_USERNAMES=()
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
BLUE='\033[0;34m' CYAN='\033[0;36m'
NC='\033[0m' NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_test() { echo -e "${BLUE}[TEST]${NC} $1"; } log_test() { echo -e "${CYAN}[TEST]${NC} $1"; }
kill_leftover_processes() { kill_leftover_processes() {
# Kill any leftover test processes from previous runs # Kill any leftover test processes from previous runs
@@ -213,6 +214,44 @@ init_snac2() {
log_info "snac2 initialized" log_info "snac2 initialized"
} }
enable_snac_shared_inbox() {
log_info "Enabling shared_inboxes in snac2 configuration..."
local server_json="${SNAC_DATA_DIR}/server.json"
if [[ ! -f "${server_json}" ]]; then
log_error "server.json not found at ${server_json}"
return 1
fi
# Use jq if available, otherwise use Python as a fallback
if command -v jq &> /dev/null; then
local tmp_file
tmp_file=$(mktemp)
jq '. + {"shared_inboxes": true}' "${server_json}" > "${tmp_file}" && mv "${tmp_file}" "${server_json}"
elif command -v python3 &> /dev/null; then
python3 -c "
import json
with open('${server_json}', 'r') as f:
data = json.load(f)
data['shared_inboxes'] = True
with open('${server_json}', 'w') as f:
json.dump(data, f, indent=2)
"
else
log_error "Neither jq nor python3 available to modify server.json"
return 1
fi
# Verify the setting was applied
if grep -q '"shared_inboxes"' "${server_json}"; then
log_info "shared_inboxes enabled in snac2 configuration"
else
log_error "Failed to enable shared_inboxes"
return 1
fi
}
create_snac_users() { create_snac_users() {
log_info "Creating ${USER_COUNT} users in snac2 using snac adduser..." log_info "Creating ${USER_COUNT} users in snac2 using snac adduser..."
@@ -263,7 +302,9 @@ start_proxy() {
export CERT_FILE="${CERT_DIR}/cert.pem" export CERT_FILE="${CERT_DIR}/cert.pem"
export KEY_FILE="${CERT_DIR}/key.pem" export KEY_FILE="${CERT_DIR}/key.pem"
caddy run --config "${SCRIPT_DIR}/Caddyfile" --adapter caddyfile & # Redirect caddy output to a log file to reduce test noise
local caddy_log="${TEMP_DIR}/caddy.log"
caddy run --config "${SCRIPT_DIR}/Caddyfile" --adapter caddyfile > "${caddy_log}" 2>&1 &
PROXY_PID=$! PROXY_PID=$!
log_info "Caddy started with PID ${PROXY_PID}" log_info "Caddy started with PID ${PROXY_PID}"
@@ -295,8 +336,11 @@ start_snac2() {
log_info "Starting snac2 server..." log_info "Starting snac2 server..."
# mkcert certificates are trusted system-wide, no special env vars needed # mkcert certificates are trusted system-wide, no special env vars needed
"${SNAC_BIN}" httpd "${SNAC_DATA_DIR}" & # Redirect snac2 output to a log file to reduce test noise
local snac_log="${TEMP_DIR}/snac2.log"
DEBUG=0 "${SNAC_BIN}" httpd "${SNAC_DATA_DIR}" > "${snac_log}" 2>&1 &
SNAC_PID=$! SNAC_PID=$!
log_info "snac2 logs available at: ${snac_log}"
log_info "snac2 started with PID ${SNAC_PID}" log_info "snac2 started with PID ${SNAC_PID}"
@@ -317,6 +361,32 @@ start_snac2() {
return 1 return 1
} }
verify_snac_shared_inbox() {
log_info "Verifying snac2 actors have sharedInbox endpoint..."
# Get the first test user to check their actor object
if [[ ${#SNAC_USERNAMES[@]} -eq 0 ]]; then
log_warn "No snac2 users created yet, skipping sharedInbox verification"
return 0
fi
local test_user="${SNAC_USERNAMES[0]}"
local actor_url="${SNAC_URL}/${test_user}"
# Fetch the actor object and check for sharedInbox
local actor_response
actor_response=$(curl -s --max-time 10 -H "Accept: application/activity+json" "${actor_url}" 2>&1)
if echo "${actor_response}" | grep -q '"sharedInbox"'; then
log_info "sharedInbox endpoint found in snac2 actor objects"
return 0
else
log_error "sharedInbox endpoint NOT found in snac2 actor objects"
log_error "Actor response: ${actor_response}"
return 1
fi
}
build_owncast() { build_owncast() {
log_info "Building Owncast..." log_info "Building Owncast..."
@@ -567,6 +637,51 @@ check_snac_inboxes() {
echo "${users_with_messages}" echo "${users_with_messages}"
} }
# Count followers with shared_inbox set vs those with individual inboxes only
count_shared_inbox_followers() {
local shared_count
shared_count=$(sqlite3 "${OWNCAST_DB}" "SELECT COUNT(*) FROM ap_followers WHERE shared_inbox IS NOT NULL AND shared_inbox != '';" 2>/dev/null || echo "0")
echo "${shared_count}"
}
count_individual_inbox_followers() {
local individual_count
individual_count=$(sqlite3 "${OWNCAST_DB}" "SELECT COUNT(*) FROM ap_followers WHERE shared_inbox IS NULL OR shared_inbox = '';" 2>/dev/null || echo "0")
echo "${individual_count}"
}
# Clear shared_inbox from a percentage of followers to test mixed delivery
clear_some_shared_inboxes() {
local percentage=${1:-50} # Default to 50% of followers
log_info "Clearing shared_inbox from ${percentage}% of followers to test mixed delivery..."
# Get total follower count
local total_followers
total_followers=$(sqlite3 "${OWNCAST_DB}" "SELECT COUNT(*) FROM ap_followers;" 2>/dev/null || echo "0")
if [[ "${total_followers}" -eq 0 ]]; then
log_warn "No followers found in database"
return 1
fi
# Calculate how many to clear
local clear_count=$((total_followers * percentage / 100))
log_info "Total followers: ${total_followers}, clearing shared_inbox from ${clear_count} followers"
# Clear shared_inbox from a random subset of followers
# SQLite's RANDOM() function helps select random rows
sqlite3 "${OWNCAST_DB}" "UPDATE ap_followers SET shared_inbox = NULL WHERE iri IN (SELECT iri FROM ap_followers ORDER BY RANDOM() LIMIT ${clear_count});"
local shared_remaining
shared_remaining=$(count_shared_inbox_followers)
local individual_count
individual_count=$(count_individual_inbox_followers)
log_info "After clearing: ${shared_remaining} followers with shared_inbox, ${individual_count} with individual inbox only"
}
verify_all_followers_received_message() { verify_all_followers_received_message() {
local followers=$1 local followers=$1
local max_wait=${2:-60} local max_wait=${2:-60}
@@ -624,12 +739,20 @@ print_results() {
local delivered=$2 local delivered=$2
local delivery_time=$3 local delivery_time=$3
# Get shared inbox vs individual inbox counts
local shared_inbox_count
shared_inbox_count=$(count_shared_inbox_followers)
local individual_inbox_count
individual_inbox_count=$(count_individual_inbox_followers)
echo "" echo ""
echo "========================================" echo "========================================"
echo "ActivityPub Federation Test Results" echo "ActivityPub Federation Test Results"
echo "========================================" echo "========================================"
echo "Test Users Created: ${USER_COUNT}" echo "Test Users Created: ${USER_COUNT}"
echo "Followers Registered: ${followers}" echo "Followers Registered: ${followers}"
echo " - Shared Inbox: ${shared_inbox_count}"
echo " - Individual Inbox: ${individual_inbox_count}"
echo "Messages Delivered: ${delivered}" echo "Messages Delivered: ${delivered}"
if [[ -n "${delivery_time}" ]] && [[ "${delivery_time}" -gt 0 ]]; then if [[ -n "${delivery_time}" ]] && [[ "${delivery_time}" -gt 0 ]]; then
echo "Delivery Time: ${delivery_time}s" echo "Delivery Time: ${delivery_time}s"
@@ -676,6 +799,9 @@ main() {
log_info "Configuration: ${USER_COUNT} test users" log_info "Configuration: ${USER_COUNT} test users"
log_info "Owncast URL: ${OWNCAST_URL}" log_info "Owncast URL: ${OWNCAST_URL}"
log_info "snac2 URL: ${SNAC_URL}" log_info "snac2 URL: ${SNAC_URL}"
if [[ "${CLEAR_SHARED_INBOX_PERCENT}" -gt 0 ]]; then
log_info "Mixed delivery test: ${CLEAR_SHARED_INBOX_PERCENT}% of followers will use individual inbox"
fi
echo "" echo ""
# ========================================== # ==========================================
@@ -689,9 +815,11 @@ main() {
install_snac2 install_snac2
check_certs check_certs
init_snac2 init_snac2
enable_snac_shared_inbox
create_snac_users create_snac_users
start_proxy start_proxy
start_snac2 start_snac2
verify_snac_shared_inbox
echo "" echo ""
# ========================================== # ==========================================
@@ -744,6 +872,15 @@ main() {
else else
log_info "All ${followers} followers registered" log_info "All ${followers} followers registered"
fi fi
# Optionally clear shared_inbox from some followers to test mixed delivery
if [[ "${CLEAR_SHARED_INBOX_PERCENT}" -gt 0 ]]; then
echo ""
echo "----------------------------------------"
echo "STEP 3.5: Clear shared_inbox from ${CLEAR_SHARED_INBOX_PERCENT}% of followers"
echo "----------------------------------------"
clear_some_shared_inboxes "${CLEAR_SHARED_INBOX_PERCENT}"
fi
echo "" echo ""
# ========================================== # ==========================================
+21
View File
@@ -2,13 +2,34 @@ package utils
import ( import (
"net" "net"
"os"
"sync"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
var (
allowInternalFederation bool
allowInternalFederationOnce sync.Once
)
// AllowInternalFederation returns true if the OWNCAST_ALLOW_INTERNAL_FEDERATION
// environment variable is set to "true". This is used for testing purposes only.
func AllowInternalFederation() bool {
allowInternalFederationOnce.Do(func() {
allowInternalFederation = os.Getenv("OWNCAST_ALLOW_INTERNAL_FEDERATION") == "true"
})
return allowInternalFederation
}
// IsHostnameInternal will attempt to determine if the hostname is internal to // IsHostnameInternal will attempt to determine if the hostname is internal to
// this server's network or is the loopback address. // this server's network or is the loopback address.
// Returns false if OWNCAST_ALLOW_INTERNAL_FEDERATION is set to "true".
func IsHostnameInternal(hostname string) bool { func IsHostnameInternal(hostname string) bool {
// Allow internal federation for testing purposes.
if AllowInternalFederation() {
return false
}
// If this is already an IP address don't try to resolve it // If this is already an IP address don't try to resolve it
if ip := net.ParseIP(hostname); ip != nil { if ip := net.ParseIP(hostname); ip != nil {
return isIPAddressInternal(ip) return isIPAddressInternal(ip)
+12
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"math/rand" "math/rand"
"net/url" "net/url"
"os" "os"
@@ -422,6 +423,17 @@ func IntPercentage(x, total int) int {
return int(float64(x) / float64(total) * 100) return int(float64(x) / float64(total) * 100)
} }
// SafeIntToInt32 safely converts an int to int32, clamping to int32 bounds.
func SafeIntToInt32(n int) int32 {
if n > math.MaxInt32 {
return math.MaxInt32
}
if n < math.MinInt32 {
return math.MinInt32
}
return int32(n)
}
// DecodeBase64Image decodes a base64 image string into a byte array, returning the extension (including dot) for the content type. // DecodeBase64Image decodes a base64 image string into a byte array, returning the extension (including dot) for the content type.
func DecodeBase64Image(url string) (bytes []byte, extension string, err error) { func DecodeBase64Image(url string) (bytes []byte, extension string, err error) {
s := strings.SplitN(url, ",", 2) s := strings.SplitN(url, ",", 2)
+10 -6
View File
@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/requests" "github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/core/webhooks" "github.com/owncast/owncast/core/webhooks"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/persistence/configrepository"
@@ -30,9 +30,11 @@ func ApproveFollower(w http.ResponseWriter, r *http.Request) {
return return
} }
followersRepo := followersrepository.Get()
if *approval.Approved { if *approval.Approved {
// Approve a follower // Approve a follower
if err := persistence.ApprovePreviousFollowRequest(*approval.ActorIRI); err != nil { if err := followersRepo.ApprovePreviousRequest(*approval.ActorIRI); err != nil {
webutils.WriteSimpleResponse(w, false, err.Error()) webutils.WriteSimpleResponse(w, false, err.Error())
return return
} }
@@ -43,7 +45,7 @@ func ApproveFollower(w http.ResponseWriter, r *http.Request) {
configRepository := configrepository.Get() configRepository := configrepository.Get()
localAccountName := configRepository.GetDefaultFederationUsername() localAccountName := configRepository.GetDefaultFederationUsername()
followRequest, err := persistence.GetFollower(*approval.ActorIRI) followRequest, err := followersRepo.GetByIRI(*approval.ActorIRI)
if err != nil { if err != nil {
webutils.WriteSimpleResponse(w, false, err.Error()) webutils.WriteSimpleResponse(w, false, err.Error())
return return
@@ -56,7 +58,7 @@ func ApproveFollower(w http.ResponseWriter, r *http.Request) {
} }
} else { } else {
// Remove/block a follower // Remove/block a follower
if err := persistence.BlockOrRejectFollower(*approval.ActorIRI); err != nil { if err := followersRepo.BlockOrReject(*approval.ActorIRI); err != nil {
webutils.WriteSimpleResponse(w, false, err.Error()) webutils.WriteSimpleResponse(w, false, err.Error())
return return
} }
@@ -67,7 +69,8 @@ func ApproveFollower(w http.ResponseWriter, r *http.Request) {
// GetPendingFollowRequests will return a list of pending follow requests. // GetPendingFollowRequests will return a list of pending follow requests.
func GetPendingFollowRequests(w http.ResponseWriter, r *http.Request) { func GetPendingFollowRequests(w http.ResponseWriter, r *http.Request) {
requests, err := persistence.GetPendingFollowRequests() followersRepo := followersrepository.Get()
requests, err := followersRepo.GetPendingFollowRequests()
if err != nil { if err != nil {
webutils.WriteSimpleResponse(w, false, err.Error()) webutils.WriteSimpleResponse(w, false, err.Error())
return return
@@ -78,7 +81,8 @@ func GetPendingFollowRequests(w http.ResponseWriter, r *http.Request) {
// GetBlockedAndRejectedFollowers will return blocked and rejected followers. // GetBlockedAndRejectedFollowers will return blocked and rejected followers.
func GetBlockedAndRejectedFollowers(w http.ResponseWriter, r *http.Request) { func GetBlockedAndRejectedFollowers(w http.ResponseWriter, r *http.Request) {
rejections, err := persistence.GetBlockedAndRejectedFollowers() followersRepo := followersrepository.Get()
rejections, err := followersRepo.GetBlockedAndRejected()
if err != nil { if err != nil {
webutils.WriteSimpleResponse(w, false, err.Error()) webutils.WriteSimpleResponse(w, false, err.Error())
return return
+3 -2
View File
@@ -3,13 +3,14 @@ package handlers
import ( import (
"net/http" "net/http"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/activitypub/persistence/followersrepository"
webutils "github.com/owncast/owncast/webserver/utils" webutils "github.com/owncast/owncast/webserver/utils"
) )
// GetFollowers will handle an API request to fetch the list of followers (non-activitypub response). // GetFollowers will handle an API request to fetch the list of followers (non-activitypub response).
func GetFollowers(offset int, limit int, w http.ResponseWriter, r *http.Request) { func GetFollowers(offset int, limit int, w http.ResponseWriter, r *http.Request) {
followers, total, err := persistence.GetFederationFollowers(limit, offset) followersRepo := followersrepository.Get()
followers, total, err := followersRepo.GetFollowers(limit, offset)
if err != nil { if err != nil {
webutils.WriteSimpleResponse(w, false, "unable to fetch followers") webutils.WriteSimpleResponse(w, false, "unable to fetch followers")
return return