Browse Source

WIP

backend-refactor
Gabe Kangas 2 years ago
parent
commit
c572fb76ce
No known key found for this signature in database
GPG Key ID: 4345B2060657F330
  1. 61
      activitypub/activitypub.go
  2. 64
      activitypub/inbox/chat.go
  3. 25
      activitypub/inbox/update.go
  4. 35
      activitypub/router.go
  5. 48
      cmd/application.go
  6. 20
      cmd/backuprestore.go
  7. 14
      cmd/concurrentConnections.go
  8. 2
      cmd/concurrentConnections_freebsd.go
  9. 2
      cmd/concurrentConnections_windows.go
  10. 71
      cmd/config.go
  11. 7
      cmd/console.go
  12. 4
      cmd/data.go
  13. 23
      cmd/flags.go
  14. 4
      cmd/services.go
  15. 162
      cmd/setup.go
  16. 0
      cmd/utils_windows.go
  17. 187
      core/chat/chat.go
  18. 17
      core/chat/events/userJoinedEvent.go
  19. 22
      core/chat/utils.go
  20. 75
      core/core.go
  21. 6
      core/offlineState.go
  22. 7
      core/stats.go
  23. 46
      core/status.go
  24. 203
      core/streamState.go
  25. 12
      logging/logging.go
  26. 18
      logging/paths.go
  27. 188
      main.go
  28. 2
      models/actionEvent.go
  29. 12
      models/chatAccessScopes.go
  30. 4
      models/chatEventTypes.go
  31. 6
      models/connectedClientInfo.go
  32. 29
      models/eventType.go
  33. 10
      models/fediverseEngagementEvent.go
  34. 5
      models/messageEvents.go
  35. 2
      models/nameChangeEvent.go
  36. 2
      models/setMessageVisibilityEvent.go
  37. 2
      models/stats.go
  38. 5
      models/systemMessageEvent.go
  39. 2
      models/userDisabledEvent.go
  40. 20
      models/userJoinedEvent.go
  41. 2
      models/userMessageEvent.go
  42. 82
      services/apfederation/activitypub.go
  43. 0
      services/apfederation/apmodels/activity.go
  44. 2
      services/apfederation/apmodels/actor.go
  45. 0
      services/apfederation/apmodels/actor_test.go
  46. 0
      services/apfederation/apmodels/hashtag.go
  47. 0
      services/apfederation/apmodels/inboxRequest.go
  48. 0
      services/apfederation/apmodels/message.go
  49. 0
      services/apfederation/apmodels/utils.go
  50. 0
      services/apfederation/apmodels/webfinger.go
  51. 0
      services/apfederation/crypto/keys.go
  52. 0
      services/apfederation/crypto/publicKey.go
  53. 2
      services/apfederation/crypto/sign.go
  54. 13
      services/apfederation/inbox/announce.go
  55. 59
      services/apfederation/inbox/chat.go
  56. 0
      services/apfederation/inbox/constants.go
  57. 2
      services/apfederation/inbox/create.go
  58. 32
      services/apfederation/inbox/follow.go
  59. 37
      services/apfederation/inbox/inbox.go
  60. 13
      services/apfederation/inbox/like.go
  61. 4
      services/apfederation/inbox/undo.go
  62. 23
      services/apfederation/inbox/update.go
  63. 26
      services/apfederation/inbox/worker.go
  64. 16
      services/apfederation/inbox/worker_test.go
  65. 9
      services/apfederation/inbox/workerpool.go
  66. 15
      services/apfederation/outbox/acceptFollow.go
  67. 124
      services/apfederation/outbox/outbox.go
  68. 16
      services/apfederation/requests/http.go
  69. 20
      services/apfederation/requests/requests.go
  70. 14
      services/apfederation/resolvers/follow.go
  71. 22
      services/apfederation/resolvers/resolve.go
  72. 24
      services/apfederation/resolvers/resolvers.go
  73. 17
      services/apfederation/webfinger/webfinger.go
  74. 35
      services/apfederation/workerpool/outbound.go
  75. 240
      services/chat/chat.go
  76. 11
      services/chat/chatclient.go
  77. 35
      services/chat/events.go
  78. 3
      services/chat/messageRendering_test.go
  79. 13
      services/chat/messages.go
  80. 162
      services/chat/server.go
  81. 11
      services/config/config.go
  82. 17
      services/metrics/healthOverview.go
  83. 9
      services/metrics/metrics.go
  84. 5
      services/metrics/playback.go
  85. 13
      services/metrics/viewers.go
  86. 7
      services/notifications/notifications.go
  87. 61
      services/status/status.go
  88. 9
      services/webhooks/chat.go
  89. 42
      services/webhooks/chat_test.go
  90. 6
      services/webhooks/manager.go
  91. 4
      services/webhooks/stream.go
  92. 6
      services/webhooks/stream_test.go
  93. 3
      services/webhooks/webhooks.go
  94. 27
      services/webhooks/webhooks_test.go
  95. 3
      services/webhooks/workerpool.go
  96. 8
      services/yp/yp.go
  97. 89
      static/emoji.go
  98. 4
      storage/chatrepository/chatrepository.go
  99. 133
      storage/chatrepository/persistence.go
  100. 23
      storage/chatrepository/pruner.go
  101. Some files were not shown because too many files have changed in this diff Show More

61
activitypub/activitypub.go

@ -1,61 +0,0 @@
package activitypub
import (
"net/http"
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/inbox"
"github.com/owncast/owncast/activitypub/outbox"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/workerpool"
"github.com/owncast/owncast/storage/configrepository"
"github.com/owncast/owncast/storage/data"
"github.com/owncast/owncast/models"
log "github.com/sirupsen/logrus"
)
var configRepository = configrepository.Get()
// Start will initialize and start the federation support.
func Start(datastore *data.Store, router *http.ServeMux) {
persistence.Setup(datastore)
workerpool.InitOutboundWorkerPool()
inbox.InitInboxWorkerPool()
StartRouter(router)
// Generate the keys for signing federated activity if needed.
if configRepository.GetPrivateKey() == "" {
privateKey, publicKey, err := crypto.GenerateKeys()
_ = configRepository.SetPrivateKey(string(privateKey))
_ = configRepository.SetPublicKey(string(publicKey))
if err != nil {
log.Errorln("Unable to get private key", err)
}
}
}
// SendLive will send a "Go Live" message to followers.
func SendLive() error {
return outbox.SendLive()
}
// SendPublicFederatedMessage will send an arbitrary provided message to followers.
func SendPublicFederatedMessage(message string) error {
return outbox.SendPublicMessage(message)
}
// SendDirectFederatedMessage will send a direct message to a single account.
func SendDirectFederatedMessage(message, account string) error {
return outbox.SendDirectMessageToAccount(message, account)
}
// GetFollowerCount will return the local tracked follower count.
func GetFollowerCount() (int64, error) {
return persistence.GetFollowerCount()
}
// GetPendingFollowRequests will return the pending follow requests.
func GetPendingFollowRequests() ([]models.Follower, error) {
return persistence.GetPendingFollowRequests()
}

64
activitypub/inbox/chat.go

@ -1,64 +0,0 @@
package inbox
import (
"fmt"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/storage/configrepository"
)
var configRepository = configrepository.Get()
func handleEngagementActivity(eventType events.EventType, isLiveNotification bool, actorReference vocab.ActivityStreamsActorProperty, action string) error {
// Do nothing if displaying engagement actions has been turned off.
if !configRepository.GetFederationShowEngagement() {
return nil
}
// Do nothing if chat is disabled
if configRepository.GetChatDisabled() {
return nil
}
// Get actor of the action
actor, _ := resolvers.GetResolvedActorFromActorProperty(actorReference)
// Send chat message
actorName := actor.Name
if actorName == "" {
actorName = actor.Username
}
actorIRI := actorReference.Begin().GetIRI().String()
userPrefix := fmt.Sprintf("%s ", actorName)
var suffix string
if isLiveNotification && action == events.FediverseEngagementLike {
suffix = "liked that this stream went live."
} else if action == events.FediverseEngagementLike {
suffix = fmt.Sprintf("liked a post from %s.", configRepository.GetServerName())
} else if isLiveNotification && action == events.FediverseEngagementRepost {
suffix = "shared this stream with their followers."
} else if action == events.FediverseEngagementRepost {
suffix = fmt.Sprintf("shared a post from %s.", configRepository.GetServerName())
} else if action == events.FediverseEngagementFollow {
suffix = "followed this stream."
} else {
return fmt.Errorf("could not handle event for sending to chat: %s", action)
}
body := fmt.Sprintf("%s %s", userPrefix, suffix)
var image *string
if actor.Image != nil {
s := actor.Image.String()
image = &s
}
if err := chat.SendFediverseAction(eventType, actor.FullUsername, image, body, actorIRI); err != nil {
return err
}
return nil
}

25
activitypub/inbox/update.go

@ -1,25 +0,0 @@
package inbox
import (
"context"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/resolvers"
log "github.com/sirupsen/logrus"
)
func handleUpdateRequest(c context.Context, activity vocab.ActivityStreamsUpdate) error {
// We only care about update events to followers.
if !activity.GetActivityStreamsObject().At(0).IsActivityStreamsPerson() {
return nil
}
actor, err := resolvers.GetResolvedActorFromActorProperty(activity.GetActivityStreamsActor())
if err != nil {
log.Errorln(err)
return err
}
return persistence.UpdateFollower(actor.ActorIri.String(), actor.Inbox.String(), actor.Name, actor.FullUsername, actor.Image.String())
}

35
activitypub/router.go

@ -1,35 +0,0 @@
package activitypub
import (
"net/http"
"github.com/owncast/owncast/activitypub/controllers"
"github.com/owncast/owncast/webserver/middleware"
)
// StartRouter will start the federation specific http router.
func StartRouter(router *http.ServeMux) {
// WebFinger
router.HandleFunc("/.well-known/webfinger", controllers.WebfingerHandler)
// Host Metadata
router.HandleFunc("/.well-known/host-meta", controllers.HostMetaController)
// Nodeinfo v1
router.HandleFunc("/.well-known/nodeinfo", controllers.NodeInfoController)
// x-nodeinfo v2
router.HandleFunc("/.well-known/x-nodeinfo2", controllers.XNodeInfo2Controller)
// Nodeinfo v2
router.HandleFunc("/nodeinfo/2.0", controllers.NodeInfoV2Controller)
// Instance details
router.HandleFunc("/api/v1/instance", controllers.InstanceV1Controller)
// Single ActivityPub Actor
router.HandleFunc("/federation/user/", middleware.RequireActivityPubOrRedirect(controllers.ActorHandler))
// Single AP object
router.HandleFunc("/federation/", middleware.RequireActivityPubOrRedirect(controllers.ObjectHandler))
}

48
cmd/application.go

@ -0,0 +1,48 @@
package cmd
import (
"github.com/owncast/owncast/services/config"
"github.com/owncast/owncast/services/metrics"
"github.com/owncast/owncast/storage/configrepository"
log "github.com/sirupsen/logrus"
)
type Application struct {
configservice *config.Config
metricsservice *metrics.Metrics
configRepository *configrepository.SqlConfigRepository
maximumConcurrentConnectionLimit int64
}
/*
The order of this setup matters.
- Parse flags
- Set the session runtime values
- Use the session values to configure data persistence
*/
func (app *Application) Start() {
app.configservice = config.Get()
app.parseFlags()
app.configureLogging(*enableDebugOptions, *enableVerboseLogging, app.configservice.LogDirectory)
app.showStartupMessage()
app.setSessionConfig()
app.createDirectories()
app.maximumConcurrentConnectionLimit = getMaximumConcurrentConnectionLimit()
setSystemConcurrentConnectionLimit(app.maximumConcurrentConnectionLimit)
// If we're restoring a backup, do that and exit.
if *restoreDatabaseFile != "" {
app.handleRestoreBackup(restoreDatabaseFile)
log.Exit(0)
}
if *backupDirectory != "" {
app.configservice.BackupDirectory = *backupDirectory
}
app.startServices()
}

20
cmd/backuprestore.go

@ -0,0 +1,20 @@
package cmd
import (
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus"
)
func (app *Application) handleRestoreBackup(restoreDatabaseFile *string) {
// Allows a user to restore a specific database backup
databaseFile := app.configservice.DatabaseFilePath
if *dbFile != "" {
databaseFile = *dbFile
}
if err := utils.Restore(*restoreDatabaseFile, databaseFile); err != nil {
log.Fatalln(err)
}
log.Println("Database has been restored. Restart Owncast.")
}

14
core/chat/concurrentConnections.go → cmd/concurrentConnections.go

@ -2,7 +2,7 @@
//go:build !freebsd && !windows //go:build !freebsd && !windows
// +build !freebsd,!windows // +build !freebsd,!windows
package chat package cmd
import ( import (
"syscall" "syscall"
@ -24,3 +24,15 @@ func setSystemConcurrentConnectionLimit(limit int64) {
log.Traceln("Max process connection count changed from system limit of", originalLimit, "to", limit) log.Traceln("Max process connection count changed from system limit of", originalLimit, "to", limit)
} }
func getMaximumConcurrentConnectionLimit() int64 {
var rLimit syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit); err != nil {
log.Fatalln(err)
}
// Return the limit to 70% of max so the machine doesn't die even if it's maxed out for some reason.
proposedLimit := int64(float32(rLimit.Max) * 0.7)
return proposedLimit
}

2
core/chat/concurrentConnections_freebsd.go → cmd/concurrentConnections_freebsd.go

@ -1,7 +1,7 @@
//go:build freebsd //go:build freebsd
// +build freebsd // +build freebsd
package chat package cmd
import ( import (
"syscall" "syscall"

2
core/chat/concurrentConnections_windows.go → cmd/concurrentConnections_windows.go

@ -1,6 +1,6 @@
//go:build windows //go:build windows
// +build windows // +build windows
package chat package cmd
func setSystemConcurrentConnectionLimit(limit int64) {} func setSystemConcurrentConnectionLimit(limit int64) {}

71
cmd/config.go

@ -0,0 +1,71 @@
package cmd
import (
"strconv"
"github.com/owncast/owncast/storage/configrepository"
log "github.com/sirupsen/logrus"
)
func (app *Application) setSessionConfig() {
// Stream key
if *newStreamKey != "" {
log.Println("Temporary stream key is set for this session.")
app.configservice.TemporaryStreamKey = *newStreamKey
}
app.configservice.EnableDebugFeatures = *enableDebugOptions
if *dbFile != "" {
app.configservice.DatabaseFilePath = *dbFile
}
if *logDirectory != "" {
app.configservice.LogDirectory = *logDirectory
}
}
func (app *Application) saveUpdatedConfig() {
configRepository := configrepository.Get()
if *newAdminPassword != "" {
if err := configRepository.SetAdminPassword(*newAdminPassword); err != nil {
log.Errorln("Error setting your admin password.", err)
log.Exit(1)
} else {
log.Infoln("Admin password changed")
}
}
// Set the web server port
if *webServerPortOverride != "" {
portNumber, err := strconv.Atoi(*webServerPortOverride)
if err != nil {
log.Warnln(err)
return
}
log.Println("Saving new web server port number to", portNumber)
if err := configRepository.SetHTTPPortNumber(float64(portNumber)); err != nil {
log.Errorln(err)
}
}
app.configservice.WebServerPort = configRepository.GetHTTPPortNumber()
// Set the web server ip
if *webServerIPOverride != "" {
log.Println("Saving new web server listen IP address to", *webServerIPOverride)
if err := configRepository.SetHTTPListenAddress(*webServerIPOverride); err != nil {
log.Errorln(err)
}
}
app.configservice.WebServerIP = configRepository.GetHTTPListenAddress()
// Set the rtmp server port
if *rtmpPortOverride > 0 {
log.Println("Saving new RTMP server port number to", *rtmpPortOverride)
if err := configRepository.SetRTMPPortNumber(float64(*rtmpPortOverride)); err != nil {
log.Errorln(err)
}
}
}

7
cmd/console.go

@ -0,0 +1,7 @@
package cmd
import log "github.com/sirupsen/logrus"
func (app *Application) showStartupMessage() {
log.Infoln(app.configservice.GetReleaseString())
}

4
cmd/data.go

@ -0,0 +1,4 @@
package cmd
func initializeData() {
}

23
cmd/flags.go

@ -0,0 +1,23 @@
package cmd
import (
"flag"
)
var (
dbFile = flag.String("database", "", "Path to the database file.")
logDirectory = flag.String("logdir", "", "Directory where logs will be written to")
backupDirectory = flag.String("backupdir", "", "Directory where backups will be written to")
enableDebugOptions = flag.Bool("enableDebugFeatures", false, "Enable additional debugging options.")
enableVerboseLogging = flag.Bool("enableVerboseLogging", false, "Enable additional logging.")
restoreDatabaseFile = flag.String("restoreDatabase", "", "Restore an Owncast database backup")
newAdminPassword = flag.String("adminpassword", "", "Set your admin password")
newStreamKey = flag.String("streamkey", "", "Set a temporary stream key for this session")
webServerPortOverride = flag.String("webserverport", "", "Force the web server to listen on a specific port")
webServerIPOverride = flag.String("webserverip", "", "Force web server to listen on this IP address")
rtmpPortOverride = flag.Int("rtmpport", 0, "Set listen port for the RTMP server")
)
func (app *Application) parseFlags() {
flag.Parse()
}

4
cmd/services.go

@ -0,0 +1,4 @@
package cmd
func (app *Application) startServices() {
}

162
cmd/setup.go

@ -0,0 +1,162 @@
package cmd
import (
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"github.com/owncast/owncast/logging"
"github.com/owncast/owncast/services/config"
"github.com/owncast/owncast/static"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
func (app *Application) createDirectories() {
// Create the data directory if needed
if !utils.DoesFileExists("data") {
if err := os.Mkdir("./data", 0o700); err != nil {
log.Fatalln("Cannot create data directory", err)
}
}
// Recreate the temp dir
if utils.DoesFileExists(app.configservice.TempDir) {
err := os.RemoveAll(app.configservice.TempDir)
if err != nil {
log.Fatalln("Unable to remove temp dir! Check permissions.", app.configservice.TempDir, err)
}
}
if err := os.Mkdir(app.configservice.TempDir, 0o700); err != nil {
log.Fatalln("Unable to create temp dir!", err)
}
}
func (app *Application) configureLogging(enableDebugFeatures bool, enableVerboseLogging bool, logDirectory string) {
logging.Setup(enableDebugFeatures, enableVerboseLogging, logDirectory)
log.SetFormatter(&log.TextFormatter{
FullTimestamp: true,
})
}
// setupEmojiDirectory sets up the custom emoji directory by copying all built-in
// emojis if the directory does not yet exist.
func (app *Application) setupEmojiDirectory() (err error) {
type emojiDirectory struct {
path string
isDir bool
}
// Migrate old (pre 0.1.0) emoji to new location if they exist.
app.migrateCustomEmojiLocations()
if utils.DoesFileExists(app.configservice.CustomEmojiPath) {
return nil
}
if err = os.MkdirAll(app.configservice.CustomEmojiPath, 0o750); err != nil {
return fmt.Errorf("unable to create custom emoji directory: %w", err)
}
staticFS := static.GetEmoji()
files := []emojiDirectory{}
walkFunction := func(path string, d os.DirEntry, err error) error {
if path == "." {
return nil
}
if d.Name() == "LICENSE.md" {
return nil
}
files = append(files, emojiDirectory{path: path, isDir: d.IsDir()})
return nil
}
if err := fs.WalkDir(staticFS, ".", walkFunction); err != nil {
log.Errorln("unable to fetch emojis: " + err.Error())
return errors.Wrap(err, "unable to fetch embedded emoji files")
}
if err != nil {
return fmt.Errorf("unable to read built-in emoji files: %w", err)
}
// Now copy all built-in emojis to the custom emoji directory
for _, path := range files {
emojiPath := filepath.Join(app.configservice.CustomEmojiPath, path.path)
if path.isDir {
if err := os.Mkdir(emojiPath, 0o700); err != nil {
return errors.Wrap(err, "unable to create emoji directory, check permissions?: "+path.path)
}
continue
}
memFile, staticOpenErr := staticFS.Open(path.path)
if staticOpenErr != nil {
return errors.Wrap(staticOpenErr, "unable to open emoji file from embedded filesystem")
}
// nolint:gosec
diskFile, err := os.Create(emojiPath)
if err != nil {
return fmt.Errorf("unable to create custom emoji file on disk: %w", err)
}
if err != nil {
_ = diskFile.Close()
return fmt.Errorf("unable to open built-in emoji file: %w", err)
}
if _, err = io.Copy(diskFile, memFile); err != nil {
_ = diskFile.Close()
_ = os.Remove(emojiPath)
return fmt.Errorf("unable to copy built-in emoji file to disk: %w", err)
}
if err = diskFile.Close(); err != nil {
_ = os.Remove(emojiPath)
return fmt.Errorf("unable to close custom emoji file on disk: %w", err)
}
}
return nil
}
// MigrateCustomEmojiLocations migrates custom emoji from the old location to the new location.
func (app *Application) migrateCustomEmojiLocations() {
oldLocation := path.Join("webroot", "img", "emoji")
newLocation := path.Join("data", "emoji")
if !utils.DoesFileExists(oldLocation) {
return
}
log.Println("Moving custom emoji directory from", oldLocation, "to", newLocation)
if err := utils.Move(oldLocation, newLocation); err != nil {
log.Errorln("error moving custom emoji directory", err)
}
}
func (app *Application) resetDirectories() {
log.Trace("Resetting file directories to a clean slate.")
// Wipe hls data directory
utils.CleanupDirectory(app.configservice.HLSStoragePath)
// Remove the previous thumbnail
logo := app.configRepository.GetLogoPath()
if utils.DoesFileExists(logo) {
err := utils.Copy(path.Join("data", logo), filepath.Join(config.DataDirectory, "thumbnail.jpg"))
if err != nil {
log.Warnln(err)
}
}
}

0
core/chat/utils_windows.go → cmd/utils_windows.go

187
core/chat/chat.go

@ -1,187 +0,0 @@
package chat
import (
"errors"
"net/http"
"sort"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/services/config"
"github.com/owncast/owncast/storage/configrepository"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
log "github.com/sirupsen/logrus"
)
var (
getStatus func() models.Status
chatMessagesSentCounter prometheus.Gauge
)
var configRepository = configrepository.Get()
// Start begins the chat server.
func Start(getStatusFunc func() models.Status) error {
setupPersistence()
getStatus = getStatusFunc
_server = NewChat()
go _server.Run()
log.Traceln("Chat server started with max connection count of", _server.maxSocketConnectionLimit)
c := config.GetConfig()
chatMessagesSentCounter = promauto.NewGauge(prometheus.GaugeOpts{
Name: "total_chat_message_count",
Help: "The number of chat messages incremented over time.",
ConstLabels: map[string]string{
"version": c.VersionNumber,
"host": configRepository.GetServerURL(),
},
})
return nil
}
// GetClientsForUser will return chat connections that are owned by a specific user.
func GetClientsForUser(userID string) ([]*Client, error) {
_server.mu.Lock()
defer _server.mu.Unlock()
clients := map[string][]*Client{}
for _, client := range _server.clients {
clients[client.User.ID] = append(clients[client.User.ID], client)
}
if _, exists := clients[userID]; !exists {
return nil, errors.New("no connections for user found")
}
return clients[userID], nil
}
// FindClientByID will return a single connected client by ID.
func FindClientByID(clientID uint) (*Client, bool) {
client, found := _server.clients[clientID]
return client, found
}
// GetClients will return all the current chat clients connected.
func GetClients() []*Client {
clients := []*Client{}
if _server == nil {
return clients
}
// Convert the keyed map to a slice.
for _, client := range _server.clients {
clients = append(clients, client)
}
sort.Slice(clients, func(i, j int) bool {
return clients[i].ConnectedAt.Before(clients[j].ConnectedAt)
})
return clients
}
// SendSystemMessage will send a message string as a system message to all clients.
func SendSystemMessage(text string, ephemeral bool) error {
message := events.SystemMessageEvent{
MessageEvent: events.MessageEvent{
Body: text,
},
}
message.SetDefaults()
message.RenderBody()
if err := Broadcast(&message); err != nil {
log.Errorln("error sending system message", err)
}
if !ephemeral {
saveEvent(message.ID, nil, message.Body, message.GetMessageType(), nil, message.Timestamp, nil, nil, nil, nil)
}
return nil
}
// SendFediverseAction will send a message indicating some Fediverse engagement took place.
func SendFediverseAction(eventType string, userAccountName string, image *string, body string, link string) error {
message := events.FediverseEngagementEvent{
Event: events.Event{
Type: eventType,
},
MessageEvent: events.MessageEvent{
Body: body,
},
UserAccountName: userAccountName,
Image: image,
Link: link,
}
message.SetDefaults()
message.RenderBody()
if err := Broadcast(&message); err != nil {
log.Errorln("error sending system message", err)
return err
}
saveFederatedAction(message)
return nil
}
// SendSystemAction will send a system action string as an action event to all clients.
func SendSystemAction(text string, ephemeral bool) error {
message := events.ActionEvent{
MessageEvent: events.MessageEvent{
Body: text,
},
}
message.SetDefaults()
message.RenderBody()
if err := Broadcast(&message); err != nil {
log.Errorln("error sending system chat action")
}
if !ephemeral {
saveEvent(message.ID, nil, message.Body, message.GetMessageType(), nil, message.Timestamp, nil, nil, nil, nil)
}
return nil
}
// SendAllWelcomeMessage will send the chat message to all connected clients.
func SendAllWelcomeMessage() {
_server.sendAllWelcomeMessage()
}
// SendSystemMessageToClient will send a single message to a single connected chat client.
func SendSystemMessageToClient(clientID uint, text string) {
if client, foundClient := FindClientByID(clientID); foundClient {
_server.sendSystemMessageToClient(client, text)
}
}
// Broadcast will send all connected clients the outbound object provided.
func Broadcast(event events.OutboundEvent) error {
return _server.Broadcast(event.GetBroadcastPayload())
}
// HandleClientConnection handles a single inbound websocket connection.
func HandleClientConnection(w http.ResponseWriter, r *http.Request) {
_server.HandleClientConnection(w, r)
}
// DisconnectClients will forcefully disconnect all clients belonging to a user by ID.
func DisconnectClients(clients []*Client) {
_server.DisconnectClients(clients)
}

17
core/chat/events/userJoinedEvent.go

@ -1,17 +0,0 @@
package events
// UserJoinedEvent is the event fired when a user joins chat.
type UserJoinedEvent struct {
Event
UserEvent
}
// GetBroadcastPayload will return the object to send to all chat users.
func (e *UserJoinedEvent) GetBroadcastPayload() EventPayload {
return EventPayload{
"type": UserJoined,
"id": e.ID,
"timestamp": e.Timestamp,
"user": e.User,
}
}

22
core/chat/utils.go

@ -1,22 +0,0 @@
//go:build !windows
// +build !windows
package chat
import (
"syscall"
log "github.com/sirupsen/logrus"
)
func getMaximumConcurrentConnectionLimit() int64 {
var rLimit syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit); err != nil {
log.Fatalln(err)
}
// Return the limit to 70% of max so the machine doesn't die even if it's maxed out for some reason.
proposedLimit := int64(float32(rLimit.Max) * 0.7)
return proposedLimit
}

75
core/core.go

@ -9,9 +9,9 @@ import (
"github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/services/auth"
"github.com/owncast/owncast/services/config" "github.com/owncast/owncast/services/config"
"github.com/owncast/owncast/services/notifications" "github.com/owncast/owncast/services/notifications"
"github.com/owncast/owncast/services/status"
"github.com/owncast/owncast/services/webhooks" "github.com/owncast/owncast/services/webhooks"
"github.com/owncast/owncast/services/yp" "github.com/owncast/owncast/services/yp"
"github.com/owncast/owncast/storage/configrepository" "github.com/owncast/owncast/storage/configrepository"
@ -31,11 +31,10 @@ var (
fileWriter = transcoder.FileWriterReceiverService{} fileWriter = transcoder.FileWriterReceiverService{}
) )
var configRepository = configrepository.Get()
// Start starts up the core processing. // Start starts up the core processing.
func Start() error { func Start() error {
resetDirectories() resetDirectories()
configRepository := configrepository.Get()
configRepository.PopulateDefaults() configRepository.PopulateDefaults()
@ -59,7 +58,7 @@ func Start() error {
} }
// user.SetupUsers() // user.SetupUsers()
auth.Setup(data.GetDatastore()) // auth.Setup(data.GetDatastore())
fileWriter.SetupFileWriterReceiverService(&handler) fileWriter.SetupFileWriterReceiverService(&handler)
@ -68,77 +67,29 @@ func Start() error {
return err return err
} }
_yp = yp.NewYP(GetStatus) s := status.Get()
gsf := func() *models.Status {
s := status.Get()
return &s.Status
}
_yp = yp.NewYP(gsf)
if err := chat.Start(GetStatus); err != nil { if err := chat.Start(gsf); err != nil {
log.Errorln(err) log.Errorln(err)
} }
// start the rtmp server // start the rtmp server
go rtmp.Start(setStreamAsConnected, setBroadcaster) go rtmp.Start(setStreamAsConnected, s.SetBroadcaster)
rtmpPort := configRepository.GetRTMPPortNumber() rtmpPort := configRepository.GetRTMPPortNumber()
if rtmpPort != 1935 { if rtmpPort != 1935 {
log.Infof("RTMP is accepting inbound streams on port %d.", rtmpPort) log.Infof("RTMP is accepting inbound streams on port %d.", rtmpPort)
} }
webhooks.InitTemporarySingleton(GetStatus) webhooks.InitTemporarySingleton(gsf)
notifications.Setup(data.GetDatastore()) notifications.Setup(data.GetDatastore())
return nil return nil
} }
func createInitialOfflineState() error {
transitionToOfflineVideoStreamContent()
return nil
}
// transitionToOfflineVideoStreamContent will overwrite the current stream with the
// offline video stream state only. No live stream HLS segments will continue to be
// referenced.
func transitionToOfflineVideoStreamContent() {
log.Traceln("Firing transcoder with offline stream state")
_transcoder := transcoder.NewTranscoder()
_transcoder.SetIdentifier("offline")
_transcoder.SetLatencyLevel(models.GetLatencyLevel(4))
_transcoder.SetIsEvent(true)
offlineFilePath, err := saveOfflineClipToDisk("offline.ts")
if err != nil {
log.Fatalln("unable to save offline clip:", err)
}
_transcoder.SetInput(offlineFilePath)
go _transcoder.Start(false)
// Copy the logo to be the thumbnail
logo := configRepository.GetLogoPath()
c := config.GetConfig()
dst := filepath.Join(c.TempDir, "thumbnail.jpg")
if err = utils.Copy(filepath.Join("data", logo), dst); err != nil {
log.Warnln(err)
}
// Delete the preview Gif
_ = os.Remove(path.Join(config.DataDirectory, "preview.gif"))
}
func resetDirectories() {
log.Trace("Resetting file directories to a clean slate.")
// Wipe hls data directory
c := config.GetConfig()
utils.CleanupDirectory(c.HLSStoragePath)
// Remove the previous thumbnail
logo := configRepository.GetLogoPath()
if utils.DoesFileExists(logo) {
err := utils.Copy(path.Join("data", logo), filepath.Join(config.DataDirectory, "thumbnail.jpg"))
if err != nil {
log.Warnln(err)
}
}
}

6
core/offlineState.go

@ -19,7 +19,7 @@ func appendOfflineToVariantPlaylist(index int, playlistFilePath string) {
return return
} }
c := config.GetConfig() c := config.Get()
tmpFileName := fmt.Sprintf("tmp-stream-%d.m3u8", index) tmpFileName := fmt.Sprintf("tmp-stream-%d.m3u8", index)
atomicWriteTmpPlaylistFile, err := os.CreateTemp(c.TempDir, tmpFileName) atomicWriteTmpPlaylistFile, err := os.CreateTemp(c.TempDir, tmpFileName)
if err != nil { if err != nil {
@ -50,7 +50,7 @@ func appendOfflineToVariantPlaylist(index int, playlistFilePath string) {
} }
func makeVariantIndexOffline(index int, offlineFilePath string, offlineFilename string) { func makeVariantIndexOffline(index int, offlineFilePath string, offlineFilename string) {
c := config.GetConfig() c := config.Get()
playlistFilePath := fmt.Sprintf(filepath.Join(c.HLSStoragePath, "%d/stream.m3u8"), index) playlistFilePath := fmt.Sprintf(filepath.Join(c.HLSStoragePath, "%d/stream.m3u8"), index)
segmentFilePath := fmt.Sprintf(filepath.Join(c.HLSStoragePath, "%d/%s"), index, offlineFilename) segmentFilePath := fmt.Sprintf(filepath.Join(c.HLSStoragePath, "%d/%s"), index, offlineFilename)
@ -96,7 +96,7 @@ func createEmptyOfflinePlaylist(playlistFilePath string, offlineFilename string)
func saveOfflineClipToDisk(offlineFilename string) (string, error) { func saveOfflineClipToDisk(offlineFilename string) (string, error) {
offlineFileData := static.GetOfflineSegment() offlineFileData := static.GetOfflineSegment()
c := config.GetConfig() c := config.Get()
offlineTmpFile, err := os.CreateTemp(c.TempDir, offlineFilename) offlineTmpFile, err := os.CreateTemp(c.TempDir, offlineFilename)
if err != nil { if err != nil {
log.Errorln("unable to create temp file for offline video segment", err) log.Errorln("unable to create temp file for offline video segment", err)

7
core/stats.go

@ -9,6 +9,7 @@ import (
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/services/geoip" "github.com/owncast/owncast/services/geoip"
"github.com/owncast/owncast/storage/configrepository"
) )
var ( var (
@ -44,6 +45,8 @@ func IsStreamConnected() bool {
return false return false
} }
configRepository := configrepository.Get()
// Kind of a hack. It takes a handful of seconds between a RTMP connection and when HLS data is available. // Kind of a hack. It takes a handful of seconds between a RTMP connection and when HLS data is available.
// So account for that with an artificial buffer of four segments. // So account for that with an artificial buffer of four segments.
timeSinceLastConnected := time.Since(_stats.LastConnectTime.Time).Seconds() timeSinceLastConnected := time.Since(_stats.LastConnectTime.Time).Seconds()
@ -110,6 +113,8 @@ func pruneViewerCount() {
} }
func saveStats() { func saveStats() {
configRepository := configrepository.Get()
if err := configRepository.SetPeakOverallViewerCount(_stats.OverallMaxViewerCount); err != nil { if err := configRepository.SetPeakOverallViewerCount(_stats.OverallMaxViewerCount); err != nil {
log.Errorln("error saving viewer count", err) log.Errorln("error saving viewer count", err)
} }
@ -124,6 +129,8 @@ func saveStats() {
} }
func getSavedStats() models.Stats { func getSavedStats() models.Stats {
configRepository := configrepository.Get()
savedLastDisconnectTime, _ := configRepository.GetLastDisconnectTime() savedLastDisconnectTime, _ := configRepository.GetLastDisconnectTime()
result := models.Stats{ result := models.Stats{

46
core/status.go

@ -1,46 +0,0 @@
package core
import (
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/services/config"
)
// GetStatus gets the status of the system.
func GetStatus() models.Status {
if _stats == nil {
return models.Status{}
}
viewerCount := 0
if IsStreamConnected() {
viewerCount = len(_stats.Viewers)
}
c := config.GetConfig()
return models.Status{
Online: IsStreamConnected(),
ViewerCount: viewerCount,
OverallMaxViewerCount: _stats.OverallMaxViewerCount,
SessionMaxViewerCount: _stats.SessionMaxViewerCount,
LastDisconnectTime: _stats.LastDisconnectTime,
LastConnectTime: _stats.LastConnectTime,
VersionNumber: c.VersionNumber,
StreamTitle: configRepository.GetStreamTitle(),
}
}
// GetCurrentBroadcast will return the currently active broadcast.
func GetCurrentBroadcast() *models.CurrentBroadcast {
return _currentBroadcast
}
// setBroadcaster will store the current inbound broadcasting details.
func setBroadcaster(broadcaster models.Broadcaster) {
_broadcaster = &broadcaster
}
// GetBroadcaster will return the details of the currently active broadcaster.
func GetBroadcaster() *models.Broadcaster {
return _broadcaster
}

203
core/streamState.go

@ -1,203 +0,0 @@
package core
import (
"context"
"io"
"time"
log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/activitypub"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/services/config"
"github.com/owncast/owncast/services/notifications"
"github.com/owncast/owncast/services/webhooks"
"github.com/owncast/owncast/storage/data"
"github.com/owncast/owncast/utils"
"github.com/owncast/owncast/video/rtmp"
"github.com/owncast/owncast/video/transcoder"
)
// After the stream goes offline this timer fires a full cleanup after N min.
var _offlineCleanupTimer *time.Timer
// While a stream takes place cleanup old HLS content every N min.
var _onlineCleanupTicker *time.Ticker
var _currentBroadcast *models.CurrentBroadcast
var _onlineTimerCancelFunc context.CancelFunc
var _lastNotified *time.Time
// setStreamAsConnected sets the stream as connected.
func setStreamAsConnected(rtmpOut *io.PipeReader) {
now := utils.NullTime{Time: time.Now(), Valid: true}
_stats.StreamConnected = true
_stats.LastDisconnectTime = nil
_stats.LastConnectTime = &now
_stats.SessionMaxViewerCount = 0
_currentBroadcast = &models.CurrentBroadcast{
LatencyLevel: configRepository.GetStreamLatencyLevel(),
OutputSettings: configRepository.GetStreamOutputVariants(),
}
StopOfflineCleanupTimer()
startOnlineCleanupTimer()
if _yp != nil {
go _yp.Start()
}
c := config.GetConfig()
segmentPath := c.HLSStoragePath
if err := setupStorage(); err != nil {
log.Fatalln("failed to setup the storage", err)
}
go func() {
_transcoder = transcoder.NewTranscoder()
_transcoder.TranscoderCompleted = func(error) {
SetStreamAsDisconnected()
_transcoder = nil
_currentBroadcast = nil
}
_transcoder.SetStdin(rtmpOut)
_transcoder.Start(true)
}()
webhookManager := webhooks.Get()
go webhookManager.SendStreamStatusEvent(models.StreamStarted)
transcoder.StartThumbnailGenerator(segmentPath, configRepository.FindHighestVideoQualityIndex(_currentBroadcast.OutputSettings))
_ = chat.SendSystemAction("Stay tuned, the stream is **starting**!", true)
chat.SendAllWelcomeMessage()
// Send delayed notification messages.
_onlineTimerCancelFunc = startLiveStreamNotificationsTimer()
}
// SetStreamAsDisconnected sets the stream as disconnected.
func SetStreamAsDisconnected() {
_ = chat.SendSystemAction("The stream is ending.", true)
now := utils.NullTime{Time: time.Now(), Valid: true}
if _onlineTimerCancelFunc != nil {
_onlineTimerCancelFunc()
}
_stats.StreamConnected = false
_stats.LastDisconnectTime = &now
_stats.LastConnectTime = nil
_broadcaster = nil
offlineFilename := "offline.ts"
offlineFilePath, err := saveOfflineClipToDisk(offlineFilename)
if err != nil {
log.Errorln(err)
return
}
transcoder.StopThumbnailGenerator()
rtmp.Disconnect()
if _yp != nil {
_yp.Stop()
}
// If there is no current broadcast available the previous stream
// likely failed for some reason. Don't try to append to it.
// Just transition to offline.
if _currentBroadcast == nil {
stopOnlineCleanupTimer()
transitionToOfflineVideoStreamContent()
log.Errorln("unexpected nil _currentBroadcast")
return
}
for index := range _currentBroadcast.OutputSettings {
makeVariantIndexOffline(index, offlineFilePath, offlineFilename)
}
StartOfflineCleanupTimer()
stopOnlineCleanupTimer()
saveStats()
webhookManager := webhooks.Get()
go webhookManager.SendStreamStatusEvent(models.StreamStopped)
}
// StartOfflineCleanupTimer will fire a cleanup after n minutes being disconnected.
func StartOfflineCleanupTimer() {
_offlineCleanupTimer = time.NewTimer(5 * time.Minute)
go func() {
for range _offlineCleanupTimer.C {
// Set video to offline state
resetDirectories()
transitionToOfflineVideoStreamContent()
}
}()
}
// StopOfflineCleanupTimer will stop the previous cleanup timer.
func StopOfflineCleanupTimer() {
if _offlineCleanupTimer != nil {
_offlineCleanupTimer.Stop()
}
}
func startOnlineCleanupTimer() {
_onlineCleanupTicker = time.NewTicker(1 * time.Minute)
go func() {
for range _onlineCleanupTicker.C {
if err := _storage.Cleanup(); err != nil {
log.Errorln(err)
}
}
}()
}
func stopOnlineCleanupTimer() {
if _onlineCleanupTicker != nil {
_onlineCleanupTicker.Stop()
}
}
func startLiveStreamNotificationsTimer() context.CancelFunc {
// Send delayed notification messages.
c, cancelFunc := context.WithCancel(context.Background())
_onlineTimerCancelFunc = cancelFunc
go func(c context.Context) {
select {
case <-time.After(time.Minute * 2.0):
if _lastNotified != nil && time.Since(*_lastNotified) < 10*time.Minute {
return
}
// Send Fediverse message.
if configRepository.GetFederationEnabled() {
log.Traceln("Sending Federated Go Live message.")
if err := activitypub.SendLive(); err != nil {
log.Errorln(err)
}
}
// Send notification to those who have registered for them.
if notifier, err := notifications.New(data.GetDatastore()); err != nil {
log.Errorln(err)
} else {
notifier.Notify()
}
now := time.Now()
_lastNotified = &now
case <-c.Done():
}
}(c)
return cancelFunc
}

12
logging/logging.go

@ -30,20 +30,20 @@ type OCLogger struct {
var Logger *OCLogger var Logger *OCLogger
// Setup configures our custom logging destinations. // Setup configures our custom logging destinations.
func Setup(enableDebugOptions bool, enableVerboseLogging bool) { func Setup(enableDebugOptions bool, enableVerboseLogging bool, logDirectory string) {
// Create the logging directory if needed // Create the logging directory if needed
loggingDirectory := filepath.Dir(getLogFilePath()) loggingDirectory := filepath.Dir(logDirectory)
if !utils.DoesFileExists(loggingDirectory) { if !utils.DoesFileExists(loggingDirectory) {
if err := os.Mkdir(loggingDirectory, 0700); err != nil { if err := os.Mkdir(loggingDirectory, 0o700); err != nil {
logger.Errorln("unable to create logs directory", loggingDirectory, err) logger.Errorln("unable to create logs directory", loggingDirectory, err)
} }
} }
// Write logs to a file // Write logs to a file
path := getLogFilePath() logFile := filepath.Join(logDirectory, "owncast.log")
writer, _ := rotatelogs.New( writer, _ := rotatelogs.New(
path+".%Y%m%d%H%M", logFile+".%Y%m%d%H%M",
rotatelogs.WithLinkName(path), rotatelogs.WithLinkName(logFile),
rotatelogs.WithMaxAge(time.Duration(86400)*time.Second), rotatelogs.WithMaxAge(time.Duration(86400)*time.Second),
rotatelogs.WithRotationTime(time.Duration(604800)*time.Second), rotatelogs.WithRotationTime(time.Duration(604800)*time.Second),
) )

18
logging/paths.go

@ -1,18 +0,0 @@
package logging
import (
"path/filepath"
"github.com/owncast/owncast/services/config"
)
// GetTranscoderLogFilePath returns the logging path for the transcoder log output.
func GetTranscoderLogFilePath() string {
c := config.GetConfig()
return filepath.Join(c.LogDirectory, "transcoder.log")
}
func getLogFilePath() string {
c := config.GetConfig()
return filepath.Join(c.LogDirectory, "owncast.log")
}

188
main.go

@ -1,173 +1,43 @@
package main package main
import ( import "github.com/owncast/owncast/cmd"
"flag"
"os"
"strconv"
"github.com/owncast/owncast/logging"
"github.com/owncast/owncast/storage/configrepository"
"github.com/owncast/owncast/webserver"
log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/core"
configservice "github.com/owncast/owncast/services/config"
"github.com/owncast/owncast/services/metrics"
"github.com/owncast/owncast/utils"
)
var (
dbFile = flag.String("database", "", "Path to the database file.")
logDirectory = flag.String("logdir", "", "Directory where logs will be written to")
backupDirectory = flag.String("backupdir", "", "Directory where backups will be written to")
enableDebugOptions = flag.Bool("enableDebugFeatures", false, "Enable additional debugging options.")
enableVerboseLogging = flag.Bool("enableVerboseLogging", false, "Enable additional logging.")
restoreDatabaseFile = flag.String("restoreDatabase", "", "Restore an Owncast database backup")
newAdminPassword = flag.String("adminpassword", "", "Set your admin password")
newStreamKey = flag.String("streamkey", "", "Set a temporary stream key for this session")
webServerPortOverride = flag.String("webserverport", "", "Force the web server to listen on a specific port")
webServerIPOverride = flag.String("webserverip", "", "Force web server to listen on this IP address")
rtmpPortOverride = flag.Int("rtmpport", 0, "Set listen port for the RTMP server")
config *configservice.Config
)
var configRepository = configrepository.Get()
// nolint:cyclop
func main() { func main() {
flag.Parse() app := &cmd.Application{}
app.Start()
config = configservice.NewConfig() }
if *logDirectory != "" {
config.LogDirectory = *logDirectory
}
if *backupDirectory != "" {
config.BackupDirectory = *backupDirectory
}
// Create the data directory if needed
if !utils.DoesFileExists("data") {
if err := os.Mkdir("./data", 0o700); err != nil {
log.Fatalln("Cannot create data directory", err)
}
}
// Migrate old (pre 0.1.0) emoji to new location if they exist.
utils.MigrateCustomEmojiLocations()
// Otherwise save the default emoji to the data directory.
if err := data.SetupEmojiDirectory(); err != nil {
log.Fatalln("Cannot set up emoji directory", err)
}
// Recreate the temp dir
if utils.DoesFileExists(config.TempDir) {
err := os.RemoveAll(config.TempDir)
if err != nil {
log.Fatalln("Unable to remove temp dir! Check permissions.", config.TempDir, err)
}
}
if err := os.Mkdir(config.TempDir, 0o700); err != nil {
log.Fatalln("Unable to create temp dir!", err)
}
configureLogging(*enableDebugOptions, *enableVerboseLogging)
log.Infoln(config.GetReleaseString())
// Allows a user to restore a specific database backup
if *restoreDatabaseFile != "" {
databaseFile := config.DatabaseFilePath
if *dbFile != "" {
databaseFile = *dbFile
}
if err := utils.Restore(*restoreDatabaseFile, databaseFile); err != nil {
log.Fatalln(err)
}
log.Println("Database has been restored. Restart Owncast.")
log.Exit(0)
}
config.EnableDebugFeatures = *enableDebugOptions
if *dbFile != "" {
config.DatabaseFilePath = *dbFile
}
if err := data.SetupPersistence(config.DatabaseFilePath); err != nil {
log.Fatalln("failed to open database", err)
}
handleCommandLineFlags()
// starts the core // var configRepository = configrepository.Get()
if err := core.Start(); err != nil {
log.Fatalln("failed to start the core package", err)
}
go metrics.Start(core.GetStatus) // // nolint:cyclop
// func main() {
// flag.Parse()
webserver := webserver.New() // config = configservice.NewConfig()
if err := webserver.Start(config.WebServerIP, config.WebServerPort); err != nil {
log.Fatalln("failed to start/run the web server", err)
}
}
func handleCommandLineFlags() { // // Otherwise save the default emoji to the data directory.
if *newAdminPassword != "" { // if err := data.SetupEmojiDirectory(); err != nil {
if err := configRepository.SetAdminPassword(*newAdminPassword); err != nil { // log.Fatalln("Cannot set up emoji directory", err)
log.Errorln("Error setting your admin password.", err) // }
log.Exit(1)
} else {
log.Infoln("Admin password changed")
}
}
if *newStreamKey != "" { // if err := data.SetupPersistence(config.DatabaseFilePath); err != nil {
log.Println("Temporary stream key is set for this session.") // log.Fatalln("failed to open database", err)
config.TemporaryStreamKey = *newStreamKey // }
}
// Set the web server port // handleCommandLineFlags()
if *webServerPortOverride != "" {
portNumber, err := strconv.Atoi(*webServerPortOverride)
if err != nil {
log.Warnln(err)
return
}
log.Println("Saving new web server port number to", portNumber) // // starts the core
if err := configRepository.SetHTTPPortNumber(float64(portNumber)); err != nil { // if err := core.Start(); err != nil {
log.Errorln(err) // log.Fatalln("failed to start the core package", err)
} // }
}
config.WebServerPort = configRepository.GetHTTPPortNumber()
// Set the web server ip // go metrics.Start(core.GetStatus)
if *webServerIPOverride != "" {
log.Println("Saving new web server listen IP address to", *webServerIPOverride)
if err := configRepository.SetHTTPListenAddress(*webServerIPOverride); err != nil {
log.Errorln(err)
}
}
config.WebServerIP = configRepository.GetHTTPListenAddress()
// Set the rtmp server port // webserver := webserver.New()
if *rtmpPortOverride > 0 { // if err := webserver.Start(config.WebServerIP, config.WebServerPort); err != nil {
log.Println("Saving new RTMP server port number to", *rtmpPortOverride) // log.Fatalln("failed to start/run the web server", err)
if err := configRepository.SetRTMPPortNumber(float64(*rtmpPortOverride)); err != nil { // }
log.Errorln(err) // }
}
}
}
func configureLogging(enableDebugFeatures bool, enableVerboseLogging bool) { // func handleCommandLineFlags() {
logging.Setup(enableDebugFeatures, enableVerboseLogging) // }
log.SetFormatter(&log.TextFormatter{
FullTimestamp: true,
})
}

2
core/chat/events/actionEvent.go → models/actionEvent.go

@ -1,4 +1,4 @@
package events package models
// ActionEvent represents an action that took place, not a chat message. // ActionEvent represents an action that took place, not a chat message.
type ActionEvent struct { type ActionEvent struct {

12
models/chatAccessScopes.go

@ -0,0 +1,12 @@
package models
const (
// ScopeCanSendChatMessages will allow sending chat messages as itself.
ScopeCanSendChatMessages = "CAN_SEND_MESSAGES"
// ScopeCanSendSystemMessages will allow sending chat messages as the system.
ScopeCanSendSystemMessages = "CAN_SEND_SYSTEM_MESSAGES"
// ScopeHasAdminAccess will allow performing administrative actions on the server.
ScopeHasAdminAccess = "HAS_ADMIN_ACCESS"
ModeratorScopeKey = "MODERATOR"
)

4
core/chat/events/eventtype.go → models/chatEventTypes.go

@ -1,4 +1,4 @@
package events package models
// EventType is the type of a websocket event. // EventType is the type of a websocket event.
type EventType = string type EventType = string
@ -14,6 +14,8 @@ const (
UserColorChanged EventType = "COLOR_CHANGE" UserColorChanged EventType = "COLOR_CHANGE"
// VisibiltyUpdate is the event sent when a chat message's visibility changes. // VisibiltyUpdate is the event sent when a chat message's visibility changes.
VisibiltyUpdate EventType = "VISIBILITY-UPDATE" VisibiltyUpdate EventType = "VISIBILITY-UPDATE"
// VisibiltyToggled is the event sent when a chat message's visibility changes.
VisibiltyToggled EventType = "VISIBILITY-UPDATE"
// PING is a ping message. // PING is a ping message.
PING EventType = "PING" PING EventType = "PING"
// PONG is a pong message. // PONG is a pong message.

6
core/chat/events/connectedClientInfo.go → models/connectedClientInfo.go

@ -1,9 +1,7 @@
package events package models
import "github.com/owncast/owncast/models"
// ConnectedClientInfo represents the information about a connected client. // ConnectedClientInfo represents the information about a connected client.
type ConnectedClientInfo struct { type ConnectedClientInfo struct {
Event Event
User *models.User `json:"user"` User *User `json:"user"`
} }

29
models/eventType.go

@ -1,29 +0,0 @@
package models
// EventType is the type of a websocket event.
type EventType = string
const (
// MessageSent is the event sent when a chat event takes place.
MessageSent EventType = "CHAT"
// UserJoined is the event sent when a chat user join action takes place.
UserJoined EventType = "USER_JOINED"
// UserNameChanged is the event sent when a chat username change takes place.
UserNameChanged EventType = "NAME_CHANGE"
// VisibiltyToggled is the event sent when a chat message's visibility changes.
VisibiltyToggled EventType = "VISIBILITY-UPDATE"
// PING is a ping message.
PING EventType = "PING"
// PONG is a pong message.
PONG EventType = "PONG"
// StreamStarted represents a stream started event.
StreamStarted EventType = "STREAM_STARTED"
// StreamStopped represents a stream stopped event.
StreamStopped EventType = "STREAM_STOPPED"
// StreamTitleUpdated is the event sent when a stream's title changes.
StreamTitleUpdated EventType = "STREAM_TITLE_UPDATED"
// SystemMessageSent is the event sent when a system message is sent.
SystemMessageSent EventType = "SYSTEM"
// ChatActionSent is a generic chat action that can be used for anything that doesn't need specific handling or formatting.
ChatActionSent EventType = "CHAT_ACTION"
)

10
core/chat/events/fediverseEngagementEvent.go → models/fediverseEngagementEvent.go

@ -1,8 +1,4 @@
package events package models
import (
"github.com/owncast/owncast/storage/configrepository"
)
// FediverseEngagementEvent is a message displayed in chat on representing an action on the Fediverse. // FediverseEngagementEvent is a message displayed in chat on representing an action on the Fediverse.
type FediverseEngagementEvent struct { type FediverseEngagementEvent struct {
@ -13,8 +9,6 @@ type FediverseEngagementEvent struct {
UserAccountName string `json:"title"` UserAccountName string `json:"title"`
} }
var configRepository = configrepository.Get()
// GetBroadcastPayload will return the object to send to all chat users. // GetBroadcastPayload will return the object to send to all chat users.
func (e *FediverseEngagementEvent) GetBroadcastPayload() EventPayload { func (e *FediverseEngagementEvent) GetBroadcastPayload() EventPayload {
return EventPayload{ return EventPayload{
@ -26,7 +20,7 @@ func (e *FediverseEngagementEvent) GetBroadcastPayload() EventPayload {
"title": e.UserAccountName, "title": e.UserAccountName,
"link": e.Link, "link": e.Link,
"user": EventPayload{ "user": EventPayload{
"displayName": configRepository.GetServerName(), "displayName": "Owncast",
}, },
} }
} }

5
core/chat/events/events.go → models/messageEvents.go

@ -1,4 +1,4 @@
package events package models
import ( import (
"bytes" "bytes"
@ -9,7 +9,6 @@ import (
"time" "time"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
"github.com/owncast/owncast/models"
"github.com/teris-io/shortid" "github.com/teris-io/shortid"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
emoji "github.com/yuin/goldmark-emoji" emoji "github.com/yuin/goldmark-emoji"
@ -42,7 +41,7 @@ type Event struct {
// UserEvent is an event with an associated user. // UserEvent is an event with an associated user.
type UserEvent struct { type UserEvent struct {
User *models.User `json:"user"` User *User `json:"user"`
HiddenAt *time.Time `json:"hiddenAt,omitempty"` HiddenAt *time.Time `json:"hiddenAt,omitempty"`
ClientID uint `json:"clientId,omitempty"` ClientID uint `json:"clientId,omitempty"`
} }

2
core/chat/events/nameChangeEvent.go → models/nameChangeEvent.go

@ -1,4 +1,4 @@
package events package models
// NameChangeEvent is received when a user changes their chat display name. // NameChangeEvent is received when a user changes their chat display name.
type NameChangeEvent struct { type NameChangeEvent struct {

2
core/chat/events/setMessageVisibilityEvent.go → models/setMessageVisibilityEvent.go

@ -1,4 +1,4 @@
package events package models
// SetMessageVisibilityEvent is the event fired when one or more message // SetMessageVisibilityEvent is the event fired when one or more message
// visibilities are changed. // visibilities are changed.

2
models/stats.go

@ -6,9 +6,9 @@ import (
// Stats holds the stats for the system. // Stats holds the stats for the system.
type Stats struct { type Stats struct {
LastConnectTime *utils.NullTime `json:"lastConnectTime"`
LastDisconnectTime *utils.NullTime `json:"lastDisconnectTime"` LastDisconnectTime *utils.NullTime `json:"lastDisconnectTime"`
LastConnectTime *utils.NullTime `json:"-"`
ChatClients map[string]Client `json:"-"` ChatClients map[string]Client `json:"-"`
Viewers map[string]*Viewer `json:"-"` Viewers map[string]*Viewer `json:"-"`
SessionMaxViewerCount int `json:"sessionMaxViewerCount"` SessionMaxViewerCount int `json:"sessionMaxViewerCount"`

5
core/chat/events/systemMessageEvent.go → models/systemMessageEvent.go

@ -1,9 +1,10 @@
package events package models
// SystemMessageEvent is a message displayed in chat on behalf of the server. // SystemMessageEvent is a message displayed in chat on behalf of the server.
type SystemMessageEvent struct { type SystemMessageEvent struct {
Event Event
MessageEvent MessageEvent
DisplayName string
} }
// GetBroadcastPayload will return the object to send to all chat users. // GetBroadcastPayload will return the object to send to all chat users.
@ -14,7 +15,7 @@ func (e *SystemMessageEvent) GetBroadcastPayload() EventPayload {
"body": e.Body, "body": e.Body,
"type": SystemMessageSent, "type": SystemMessageSent,
"user": EventPayload{ "user": EventPayload{
"displayName": configRepository.GetServerName(), "displayName": e.DisplayName,
}, },
} }
} }

2
core/chat/events/userDisabledEvent.go → models/userDisabledEvent.go

@ -1,4 +1,4 @@
package events package models
// UserDisabledEvent is the event fired when a user is banned/blocked and disconnected from chat. // UserDisabledEvent is the event fired when a user is banned/blocked and disconnected from chat.
type UserDisabledEvent struct { type UserDisabledEvent struct {

20
models/userJoinedEvent.go

@ -1,11 +1,17 @@
package models package models
import "time" // UserJoinedEvent is the event fired when a user joins chat.
// UserJoinedEvent represents an event when a user joins the chat.
type UserJoinedEvent struct { type UserJoinedEvent struct {
Timestamp time.Time `json:"timestamp,omitempty"` Event
Username string `json:"username"` UserEvent
Type EventType `json:"type"` }
ID string `json:"id"`
// GetBroadcastPayload will return the object to send to all chat users.
func (e *UserJoinedEvent) GetBroadcastPayload() EventPayload {
return EventPayload{
"type": UserJoined,
"id": e.ID,
"timestamp": e.Timestamp,
"user": e.User,
}
} }

2
core/chat/events/userMessageEvent.go → models/userMessageEvent.go

@ -1,4 +1,4 @@
package events package models
// UserMessageEvent is an inbound message from a user. // UserMessageEvent is an inbound message from a user.
type UserMessageEvent struct { type UserMessageEvent struct {

82
services/apfederation/activitypub.go

@ -0,0 +1,82 @@
package apfederation
import (
"github.com/owncast/owncast/services/apfederation/crypto"
"github.com/owncast/owncast/services/apfederation/outbox"
"github.com/owncast/owncast/services/apfederation/workerpool"
"github.com/owncast/owncast/storage/configrepository"
"github.com/owncast/owncast/storage/data"
"github.com/owncast/owncast/storage/federationrepository"
"github.com/owncast/owncast/models"
log "github.com/sirupsen/logrus"
)
type APFederation struct {
workers *workerpool.WorkerPool
outbox *outbox.APOutbox
}
func New() *APFederation {
ds := data.GetDatastore()
apf := &APFederation{
outbox: outbox.Get(),
}
apf.Start(ds)
return apf
}
var temporaryGlobalInstance *APFederation
func Get() *APFederation {
if temporaryGlobalInstance == nil {
temporaryGlobalInstance = New()
}
return temporaryGlobalInstance
}
// Start will initialize and start the federation support.
func (ap *APFederation) Start(datastore *data.Store) {
configRepository := configrepository.Get()
// workerpool.InitOutboundWorkerPool()
// ap.InitInboxWorkerPool()
// Generate the keys for signing federated activity if needed.
if configRepository.GetPrivateKey() == "" {
privateKey, publicKey, err := crypto.GenerateKeys()
_ = configRepository.SetPrivateKey(string(privateKey))
_ = configRepository.SetPublicKey(string(publicKey))
if err != nil {
log.Errorln("Unable to get private key", err)
}
}
}
// SendLive will send a "Go Live" message to followers.
func (ap *APFederation) SendLive() error {
return ap.SendLive()
}
// SendPublicFederatedMessage will send an arbitrary provided message to followers.
func (ap *APFederation) SendPublicFederatedMessage(message string) error {
return ap.outbox.SendPublicMessage(message)
}
// SendDirectFederatedMessage will send a direct message to a single account.
func (ap *APFederation) SendDirectFederatedMessage(message, account string) error {
return ap.outbox.SendDirectMessageToAccount(message, account)
}
// GetFollowerCount will return the local tracked follower count.
func (ap *APFederation) GetFollowerCount() (int64, error) {
federationRepository := federationrepository.Get()
return federationRepository.GetFollowerCount()
}
// GetPendingFollowRequests will return the pending follow requests.
func (ap *APFederation) GetPendingFollowRequests() ([]models.Follower, error) {
federationRepository := federationrepository.Get()
return federationRepository.GetPendingFollowRequests()
}

0
activitypub/apmodels/activity.go → services/apfederation/apmodels/activity.go

2
activitypub/apmodels/actor.go → services/apfederation/apmodels/actor.go

@ -8,8 +8,8 @@ import (
"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/crypto"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/services/apfederation/crypto"
"github.com/owncast/owncast/storage/configrepository" "github.com/owncast/owncast/storage/configrepository"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )

0
activitypub/apmodels/actor_test.go → services/apfederation/apmodels/actor_test.go

0
activitypub/apmodels/hashtag.go → services/apfederation/apmodels/hashtag.go

0
activitypub/apmodels/inboxRequest.go → services/apfederation/apmodels/inboxRequest.go

0
activitypub/apmodels/message.go → services/apfederation/apmodels/message.go

0
activitypub/apmodels/utils.go → services/apfederation/apmodels/utils.go

0
activitypub/apmodels/webfinger.go → services/apfederation/apmodels/webfinger.go

0
activitypub/crypto/keys.go → services/apfederation/crypto/keys.go

0
activitypub/crypto/publicKey.go → services/apfederation/crypto/publicKey.go

2
activitypub/crypto/sign.go → services/apfederation/crypto/sign.go

@ -77,7 +77,7 @@ func CreateSignedRequest(payload []byte, url *url.URL, fromActorIRI *url.URL) (*
req, _ := http.NewRequest("POST", url.String(), bytes.NewBuffer(payload)) req, _ := http.NewRequest("POST", url.String(), bytes.NewBuffer(payload))
c := config.GetConfig() c := config.Get()
ua := fmt.Sprintf("%s; https://owncast.online", c.GetReleaseString()) ua := fmt.Sprintf("%s; https://owncast.online", c.GetReleaseString())
req.Header.Set("User-Agent", ua) req.Header.Set("User-Agent", ua)

13
activitypub/inbox/announce.go → services/apfederation/inbox/announce.go

@ -5,23 +5,22 @@ import (
"time" "time"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/core/chat/events"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
func handleAnnounceRequest(c context.Context, activity vocab.ActivityStreamsAnnounce) error { func (api *APInbox) handleAnnounceRequest(c context.Context, activity vocab.ActivityStreamsAnnounce) error {
object := activity.GetActivityStreamsObject() object := activity.GetActivityStreamsObject()
actorReference := activity.GetActivityStreamsActor() actorReference := activity.GetActivityStreamsActor()
objectIRI := object.At(0).GetIRI().String() objectIRI := object.At(0).GetIRI().String()
actorIRI := actorReference.At(0).GetIRI().String() actorIRI := actorReference.At(0).GetIRI().String()
if hasPreviouslyhandled, err := persistence.HasPreviouslyHandledInboundActivity(objectIRI, actorIRI, events.FediverseEngagementRepost); hasPreviouslyhandled || err != nil { if hasPreviouslyhandled, err := api.federationRepository.HasPreviouslyHandledInboundActivity(objectIRI, actorIRI, models.FediverseEngagementRepost); hasPreviouslyhandled || err != nil {
return errors.Wrap(err, "inbound activity of share/re-post has already been handled") return errors.Wrap(err, "inbound activity of share/re-post has already been handled")
} }
// Shares need to match a post we had already sent. // Shares need to match a post we had already sent.
_, isLiveNotification, timestamp, err := persistence.GetObjectByIRI(objectIRI) _, isLiveNotification, timestamp, err := api.federationRepository.GetObjectByIRI(objectIRI)
if err != nil { if err != nil {
return errors.Wrap(err, "Could not find post locally") return errors.Wrap(err, "Could not find post locally")
} }
@ -32,9 +31,9 @@ func handleAnnounceRequest(c context.Context, activity vocab.ActivityStreamsAnno
} }
// Save as an accepted activity // Save as an accepted activity
if err := persistence.SaveInboundFediverseActivity(objectIRI, actorIRI, events.FediverseEngagementRepost, time.Now()); err != nil { if err := api.federationRepository.SaveInboundFediverseActivity(objectIRI, actorIRI, models.FediverseEngagementRepost, time.Now()); err != nil {
return errors.Wrap(err, "unable to save inbound share/re-post activity") return errors.Wrap(err, "unable to save inbound share/re-post activity")
} }
return handleEngagementActivity(events.FediverseEngagementRepost, isLiveNotification, actorReference, events.FediverseEngagementRepost) return api.handleEngagementActivity(models.FediverseEngagementRepost, isLiveNotification, actorReference, models.FediverseEngagementRepost)
} }

59
services/apfederation/inbox/chat.go

@ -0,0 +1,59 @@
package inbox
import (
"fmt"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/models"
)
func (api *APInbox) handleEngagementActivity(eventType models.EventType, isLiveNotification bool, actorReference vocab.ActivityStreamsActorProperty, action string) error {
// Do nothing if displaying engagement actions has been turned off.
if !api.configRepository.GetFederationShowEngagement() {
return nil
}
// Do nothing if chat is disabled
if api.configRepository.GetChatDisabled() {
return nil
}
// Get actor of the action
actor, _ := api.resolvers.GetResolvedActorFromActorProperty(actorReference)
// Send chat message
actorName := actor.Name
if actorName == "" {
actorName = actor.Username
}
actorIRI := actorReference.Begin().GetIRI().String()
userPrefix := fmt.Sprintf("%s ", actorName)
var suffix string
if isLiveNotification && action == models.FediverseEngagementLike {
suffix = "liked that this stream went live."
} else if action == models.FediverseEngagementLike {
suffix = fmt.Sprintf("liked a post from %s.", api.configRepository.GetServerName())
} else if isLiveNotification && action == models.FediverseEngagementRepost {
suffix = "shared this stream with their followers."
} else if action == models.FediverseEngagementRepost {
suffix = fmt.Sprintf("shared a post from %s.", api.configRepository.GetServerName())
} else if action == models.FediverseEngagementFollow {
suffix = "followed this stream."
} else {
return fmt.Errorf("could not handle event for sending to chat: %s", action)
}
body := fmt.Sprintf("%s %s", userPrefix, suffix)
var image *string
if actor.Image != nil {
s := actor.Image.String()
image = &s
}
if err := api.chatService.SendFediverseAction(eventType, actor.FullUsername, image, body, actorIRI); err != nil {
return err
}
return nil
}

0
activitypub/inbox/constants.go → services/apfederation/inbox/constants.go

2
activitypub/inbox/create.go → services/apfederation/inbox/create.go

@ -7,7 +7,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
) )
func handleCreateRequest(c context.Context, activity vocab.ActivityStreamsCreate) error { func (api *APInbox) handleCreateRequest(c context.Context, activity vocab.ActivityStreamsCreate) error {
iri := activity.GetJSONLDId().GetIRI().String() iri := activity.GetJSONLDId().GetIRI().String()
return errors.New("not handling create request of: " + iri) return errors.New("not handling create request of: " + iri)
} }

32
activitypub/inbox/follow.go → services/apfederation/inbox/follow.go

@ -6,17 +6,15 @@ import (
"time" "time"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/activitypub/requests" "github.com/owncast/owncast/services/apfederation/outbox"
"github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/chat/events"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsFollow) error { func (api *APInbox) handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsFollow) error {
follow, err := resolvers.MakeFollowRequest(c, activity) follow, err := api.resolvers.MakeFollowRequest(c, activity)
if err != nil { if err != nil {
log.Errorln("unable to create follow inbox request", err) log.Errorln("unable to create follow inbox request", err)
return err return err
@ -26,19 +24,21 @@ func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsF
return fmt.Errorf("unable to handle request") return fmt.Errorf("unable to handle request")
} }
approved := !configRepository.GetFederationIsPrivate() approved := !api.configRepository.GetFederationIsPrivate()
followRequest := *follow followRequest := *follow
if err := persistence.AddFollow(followRequest, approved); err != nil { if err := api.federationRepository.AddFollow(followRequest, approved); err != nil {
log.Errorln("unable to save follow request", err) log.Errorln("unable to save follow request", err)
return err return err
} }
localAccountName := configRepository.GetDefaultFederationUsername() localAccountName := api.configRepository.GetDefaultFederationUsername()
ob := outbox.Get()
if approved { if approved {
if err := requests.SendFollowAccept(follow.Inbox, activity, localAccountName); err != nil { if err := ob.SendFollowAccept(follow.Inbox, activity, localAccountName); err != nil {
log.Errorln("unable to send follow accept", err) log.Errorln("unable to send follow accept", err)
return err return err
} }
@ -54,27 +54,27 @@ func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsF
// chat due to a previous follow request, then do so. // chat due to a previous follow request, then do so.
hasPreviouslyhandled := true // Default so we don't send anything if it fails. hasPreviouslyhandled := true // Default so we don't send anything if it fails.
if approved { if approved {
hasPreviouslyhandled, err = persistence.HasPreviouslyHandledInboundActivity(objectIRI, actorIRI, events.FediverseEngagementFollow) hasPreviouslyhandled, err = api.federationRepository.HasPreviouslyHandledInboundActivity(objectIRI, actorIRI, models.FediverseEngagementFollow)
if err != nil { if err != nil {
log.Errorln("error checking for previously handled follow activity", err) log.Errorln("error checking for previously handled follow activity", err)
} }
} }
// Save this follow action to our activities table. // Save this follow action to our activities table.
if err := persistence.SaveInboundFediverseActivity(objectIRI, actorIRI, events.FediverseEngagementFollow, time.Now()); err != nil { if err := api.federationRepository.SaveInboundFediverseActivity(objectIRI, actorIRI, models.FediverseEngagementFollow, time.Now()); err != nil {
return errors.Wrap(err, "unable to save inbound share/re-post activity") return errors.Wrap(err, "unable to save inbound share/re-post activity")
} }
// Send action to chat if it has not been previously handled. // Send action to chat if it has not been previously handled.
if !hasPreviouslyhandled { if !hasPreviouslyhandled {
return handleEngagementActivity(events.FediverseEngagementFollow, false, actorReference, events.FediverseEngagementFollow) return api.handleEngagementActivity(models.FediverseEngagementFollow, false, actorReference, models.FediverseEngagementFollow)
} }
return nil return nil
} }
func handleUnfollowRequest(c context.Context, activity vocab.ActivityStreamsUndo) error { func (api *APInbox) handleUnfollowRequest(c context.Context, activity vocab.ActivityStreamsUndo) error {
request := resolvers.MakeUnFollowRequest(c, activity) request := api.resolvers.MakeUnFollowRequest(c, activity)
if request == nil { if request == nil {
log.Errorf("unable to handle unfollow request") log.Errorf("unable to handle unfollow request")
return errors.New("unable to handle unfollow request") return errors.New("unable to handle unfollow request")
@ -83,5 +83,5 @@ 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) return api.federationRepository.RemoveFollow(unfollowRequest)
} }

37
services/apfederation/inbox/inbox.go

@ -0,0 +1,37 @@
package inbox
import (
"github.com/owncast/owncast/services/apfederation/requests"
"github.com/owncast/owncast/services/apfederation/resolvers"
"github.com/owncast/owncast/services/chat"
"github.com/owncast/owncast/storage/configrepository"
"github.com/owncast/owncast/storage/federationrepository"
)
type APInbox struct {
configRepository configrepository.ConfigRepository
federationRepository *federationrepository.FederationRepository
resolvers *resolvers.APResolvers
requests *requests.Requests
chatService *chat.Chat
}
func New() *APInbox {
return &APInbox{
configRepository: configrepository.Get(),
federationRepository: federationrepository.Get(),
resolvers: resolvers.Get(),
requests: requests.Get(),
chatService: chat.Get(),
}
}
var temporaryGlobalInstance *APInbox
func Get() *APInbox {
if temporaryGlobalInstance == nil {
temporaryGlobalInstance = New()
}
return temporaryGlobalInstance
}

13
activitypub/inbox/like.go → services/apfederation/inbox/like.go

@ -5,12 +5,11 @@ import (
"time" "time"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/core/chat/events"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
func handleLikeRequest(c context.Context, activity vocab.ActivityStreamsLike) error { func (api *APInbox) handleLikeRequest(c context.Context, activity vocab.ActivityStreamsLike) error {
object := activity.GetActivityStreamsObject() object := activity.GetActivityStreamsObject()
actorReference := activity.GetActivityStreamsActor() actorReference := activity.GetActivityStreamsActor()
if object.Len() < 1 { if object.Len() < 1 {
@ -24,12 +23,12 @@ func handleLikeRequest(c context.Context, activity vocab.ActivityStreamsLike) er
objectIRI := object.At(0).GetIRI().String() objectIRI := object.At(0).GetIRI().String()
actorIRI := actorReference.At(0).GetIRI().String() actorIRI := actorReference.At(0).GetIRI().String()
if hasPreviouslyhandled, err := persistence.HasPreviouslyHandledInboundActivity(objectIRI, actorIRI, events.FediverseEngagementLike); hasPreviouslyhandled || err != nil { if hasPreviouslyhandled, err := api.federationRepository.HasPreviouslyHandledInboundActivity(objectIRI, actorIRI, models.FediverseEngagementLike); hasPreviouslyhandled || err != nil {
return errors.Wrap(err, "inbound activity of like has already been handled") return errors.Wrap(err, "inbound activity of like has already been handled")
} }
// Likes need to match a post we had already sent. // Likes need to match a post we had already sent.
_, isLiveNotification, timestamp, err := persistence.GetObjectByIRI(objectIRI) _, isLiveNotification, timestamp, err := api.federationRepository.GetObjectByIRI(objectIRI)
if err != nil { if err != nil {
return errors.Wrap(err, "Could not find post locally") return errors.Wrap(err, "Could not find post locally")
} }
@ -40,9 +39,9 @@ func handleLikeRequest(c context.Context, activity vocab.ActivityStreamsLike) er
} }
// Save as an accepted activity // Save as an accepted activity
if err := persistence.SaveInboundFediverseActivity(objectIRI, actorIRI, events.FediverseEngagementLike, time.Now()); err != nil { if err := api.federationRepository.SaveInboundFediverseActivity(objectIRI, actorIRI, models.FediverseEngagementLike, time.Now()); err != nil {
return errors.Wrap(err, "unable to save inbound like activity") return errors.Wrap(err, "unable to save inbound like activity")
} }
return handleEngagementActivity(events.FediverseEngagementLike, isLiveNotification, actorReference, events.FediverseEngagementLike) return api.handleEngagementActivity(models.FediverseEngagementLike, isLiveNotification, actorReference, models.FediverseEngagementLike)
} }

4
activitypub/inbox/undo.go → services/apfederation/inbox/undo.go

@ -8,13 +8,13 @@ import (
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
) )
func handleUndoInboxRequest(c context.Context, activity vocab.ActivityStreamsUndo) error { func (api *APInbox) handleUndoInboxRequest(c context.Context, activity vocab.ActivityStreamsUndo) error {
// Determine if this is an undo of a follow, favorite, announce, etc. // Determine if this is an undo of a follow, favorite, announce, etc.
o := activity.GetActivityStreamsObject() o := activity.GetActivityStreamsObject()
for iter := o.Begin(); iter != o.End(); iter = iter.Next() { for iter := o.Begin(); iter != o.End(); iter = iter.Next() {
if iter.IsActivityStreamsFollow() { if iter.IsActivityStreamsFollow() {
// This is an Unfollow request // This is an Unfollow request
if err := handleUnfollowRequest(c, activity); err != nil { if err := api.handleUnfollowRequest(c, activity); err != nil {
return err return err
} }
} else { } else {

23
services/apfederation/inbox/update.go

@ -0,0 +1,23 @@
package inbox
import (
"context"
"github.com/go-fed/activity/streams/vocab"
log "github.com/sirupsen/logrus"
)
func (api *APInbox) handleUpdateRequest(c context.Context, activity vocab.ActivityStreamsUpdate) error {
// We only care about update events to followers.
if !activity.GetActivityStreamsObject().At(0).IsActivityStreamsPerson() {
return nil
}
actor, err := api.resolvers.GetResolvedActorFromActorProperty(activity.GetActivityStreamsActor())
if err != nil {
log.Errorln(err)
return err
}
return api.federationRepository.UpdateFollower(actor.ActorIri.String(), actor.Inbox.String(), actor.Name, actor.FullUsername, actor.Image.String())
}

26
activitypub/inbox/worker.go → services/apfederation/inbox/worker.go

@ -12,15 +12,13 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/go-fed/httpsig" "github.com/go-fed/httpsig"
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/services/apfederation/apmodels"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/resolvers"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
func handle(request apmodels.InboxRequest) { func (api *APInbox) handle(request apmodels.InboxRequest) {
if verified, err := Verify(request.Request); err != nil { if verified, err := api.Verify(request.Request); err != nil {
log.Debugln("Error in attempting to verify request", err) log.Debugln("Error in attempting to verify request", err)
return return
} else if !verified { } else if !verified {
@ -28,7 +26,7 @@ func handle(request apmodels.InboxRequest) {
return return
} }
if err := resolvers.Resolve(context.Background(), request.Body, handleUpdateRequest, handleFollowInboxRequest, handleLikeRequest, handleAnnounceRequest, handleUndoInboxRequest, handleCreateRequest); err != nil { if err := api.resolvers.Resolve(context.Background(), request.Body, api.handleUpdateRequest, api.handleFollowInboxRequest, api.handleLikeRequest, api.handleAnnounceRequest, api.handleUndoInboxRequest, api.handleCreateRequest); err != nil {
log.Debugln("resolver error:", err) log.Debugln("resolver error:", err)
} }
} }
@ -36,7 +34,7 @@ func handle(request apmodels.InboxRequest) {
// Verify will Verify the http signature of an inbound request as well as // Verify will Verify the http signature of an inbound request as well as
// check it against the list of blocked domains. // check it against the list of blocked domains.
// nolint: cyclop // nolint: cyclop
func Verify(request *http.Request) (bool, error) { func (api *APInbox) Verify(request *http.Request) (bool, error) {
verifier, err := httpsig.NewVerifier(request) verifier, err := httpsig.NewVerifier(request)
if err != nil { if err != nil {
return false, errors.Wrap(err, "failed to create key verifier for request") return false, errors.Wrap(err, "failed to create key verifier for request")
@ -71,7 +69,7 @@ func Verify(request *http.Request) (bool, error) {
return false, errors.New("Unable to determine algorithm to verify request") return false, errors.New("Unable to determine algorithm to verify request")
} }
publicKey, err := resolvers.GetResolvedPublicKeyFromIRI(pubKeyID.String()) publicKey, err := api.resolvers.GetResolvedPublicKeyFromIRI(pubKeyID.String())
if err != nil { if err != nil {
return false, errors.Wrap(err, "failed to resolve actor from IRI to fetch key") return false, errors.Wrap(err, "failed to resolve actor from IRI to fetch key")
} }
@ -86,12 +84,12 @@ func Verify(request *http.Request) (bool, error) {
} }
// Test to see if the actor is in the list of blocked federated domains. // Test to see if the actor is in the list of blocked federated domains.
if isBlockedDomain(publicKeyActorIRI.Hostname()) { if api.isBlockedDomain(publicKeyActorIRI.Hostname()) {
return false, errors.New("domain is blocked") return false, errors.New("domain is blocked")
} }
// If actor is specifically blocked, then fail validation. // If actor is specifically blocked, then fail validation.
if blocked, err := isBlockedActor(publicKeyActorIRI); err != nil || blocked { if blocked, err := api.isBlockedActor(publicKeyActorIRI); err != nil || blocked {
return false, err return false, err
} }
@ -129,8 +127,8 @@ func Verify(request *http.Request) (bool, error) {
return false, fmt.Errorf("http signature verification error(s) for: %s: %+v", pubKeyID.String(), triedAlgos) return false, fmt.Errorf("http signature verification error(s) for: %s: %+v", pubKeyID.String(), triedAlgos)
} }
func isBlockedDomain(domain string) bool { func (api *APInbox) isBlockedDomain(domain string) bool {
blockedDomains := configRepository.GetBlockedFederatedDomains() blockedDomains := api.configRepository.GetBlockedFederatedDomains()
for _, blockedDomain := range blockedDomains { for _, blockedDomain := range blockedDomains {
if strings.Contains(domain, blockedDomain) { if strings.Contains(domain, blockedDomain) {
@ -141,8 +139,8 @@ func isBlockedDomain(domain string) bool {
return false return false
} }
func isBlockedActor(actorIRI *url.URL) (bool, error) { func (api *APInbox) isBlockedActor(actorIRI *url.URL) (bool, error) {
blockedactor, err := persistence.GetFollower(actorIRI.String()) blockedactor, err := api.federationRepository.GetFollower(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")

16
activitypub/inbox/worker_test.go → services/apfederation/inbox/worker_test.go

@ -6,10 +6,10 @@ import (
"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/services/apfederation/apmodels"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/storage/configrepository" "github.com/owncast/owncast/storage/configrepository"
"github.com/owncast/owncast/storage/data" "github.com/owncast/owncast/storage/data"
"github.com/owncast/owncast/storage/federationrepository"
) )
func makeFakePerson() vocab.ActivityStreamsPerson { func makeFakePerson() vocab.ActivityStreamsPerson {
@ -54,6 +54,7 @@ func TestMain(m *testing.M) {
panic(err) panic(err)
} }
_ = federationrepository.New(ds)
configRepository := configrepository.New(ds) configRepository := configrepository.New(ds)
configRepository.PopulateDefaults() configRepository.PopulateDefaults()
configRepository.SetServerURL("https://my.cool.site.biz") configRepository.SetServerURL("https://my.cool.site.biz")
@ -82,17 +83,20 @@ func TestBlockedDomains(t *testing.T) {
} }
func TestBlockedActors(t *testing.T) { func TestBlockedActors(t *testing.T) {
federationRepository := federationrepository.Get()
person := makeFakePerson() person := makeFakePerson()
fakeRequest := streams.NewActivityStreamsFollow() fakeRequest := streams.NewActivityStreamsFollow()
persistence.AddFollow(apmodels.ActivityPubActor{ ib := Get()
federationRepository.AddFollow(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()) federationRepository.BlockOrRejectFollower(person.GetJSONLDId().GetIRI().String())
blocked, err := isBlockedActor(person.GetJSONLDId().GetIRI()) blocked, err := ib.isBlockedActor(person.GetJSONLDId().GetIRI())
if err != nil { if err != nil {
t.Error(err) t.Error(err)
return return
@ -103,7 +107,7 @@ func TestBlockedActors(t *testing.T) {
} }
failedBlockIRI, _ := url.Parse("https://freedom.eagle/user/mrbar") failedBlockIRI, _ := url.Parse("https://freedom.eagle/user/mrbar")
failedBlock, err := isBlockedActor(failedBlockIRI) failedBlock, err := ib.isBlockedActor(failedBlockIRI)
if failedBlock { if failedBlock {
t.Error("Invalid blocking of unblocked actor IRI") t.Error("Invalid blocking of unblocked actor IRI")

9
activitypub/inbox/workerpool.go → services/apfederation/inbox/workerpool.go

@ -4,6 +4,7 @@ import (
"runtime" "runtime"
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/services/apfederation/apmodels"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@ -18,7 +19,7 @@ type Job struct {
var queue chan Job var queue chan Job
// InitInboxWorkerPool starts n go routines that await ActivityPub jobs. // InitInboxWorkerPool starts n go routines that await ActivityPub jobs.
func InitInboxWorkerPool() { func (api *APInbox) InitInboxWorkerPool() {
queue = make(chan Job) queue = make(chan Job)
// start workers // start workers
@ -28,16 +29,16 @@ func InitInboxWorkerPool() {
} }
// AddToQueue will queue up an outbound http request. // AddToQueue will queue up an outbound http request.
func AddToQueue(req apmodels.InboxRequest) { func (api *APInbox) AddToQueue(req apmodels.InboxRequest) {
log.Tracef("Queued request for ActivityPub inbox handler") log.Tracef("Queued request for ActivityPub inbox handler")
queue <- Job{req} queue <- Job{req}
} }
func worker(workerID int, queue <-chan Job) { func (api *APInbox) worker(workerID int, queue <-chan Job) {
log.Debugf("Started ActivityPub worker %d", workerID) log.Debugf("Started ActivityPub worker %d", workerID)
for job := range queue { for job := range queue {
handle(job.request) api.handle(job.request)
log.Tracef("Done with ActivityPub inbox handler using worker %d", workerID) log.Tracef("Done with ActivityPub inbox handler using worker %d", workerID)
} }

15
activitypub/requests/acceptFollow.go → services/apfederation/outbox/acceptFollow.go

@ -1,4 +1,4 @@
package requests package outbox
import ( import (
"encoding/json" "encoding/json"
@ -6,16 +6,15 @@ import (
"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/services/apfederation/apmodels"
"github.com/owncast/owncast/activitypub/crypto" "github.com/owncast/owncast/services/apfederation/crypto"
"github.com/owncast/owncast/activitypub/workerpool"
"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 (apo *APOutbox) SendFollowAccept(inbox *url.URL, originalFollowActivity vocab.ActivityStreamsFollow, fromLocalAccountName string) error {
followAccept := makeAcceptFollow(originalFollowActivity, fromLocalAccountName) followAccept := apo.makeAcceptFollow(originalFollowActivity, fromLocalAccountName)
localAccountIRI := apmodels.MakeLocalIRIForAccount(fromLocalAccountName) localAccountIRI := apmodels.MakeLocalIRIForAccount(fromLocalAccountName)
var jsonmap map[string]interface{} var jsonmap map[string]interface{}
@ -26,12 +25,12 @@ func SendFollowAccept(inbox *url.URL, originalFollowActivity vocab.ActivityStrea
return err return err
} }
workerpool.AddToOutboundQueue(req) apo.workerpool.AddToOutboundQueue(req)
return nil return nil
} }
func makeAcceptFollow(originalFollowActivity vocab.ActivityStreamsFollow, fromAccountName string) vocab.ActivityStreamsAccept { func (r *APOutbox) makeAcceptFollow(originalFollowActivity vocab.ActivityStreamsFollow, fromAccountName string) vocab.ActivityStreamsAccept {
acceptIDString := shortid.MustGenerate() acceptIDString := shortid.MustGenerate()
acceptID := apmodels.MakeLocalIRIForResource(acceptIDString) acceptID := apmodels.MakeLocalIRIForResource(acceptIDString)
actorID := apmodels.MakeLocalIRIForAccount(fromAccountName) actorID := apmodels.MakeLocalIRIForAccount(fromAccountName)

124
activitypub/outbox/outbox.go → services/apfederation/outbox/outbox.go

@ -9,6 +9,7 @@ import (
"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"
<<<<<<< HEAD:activitypub/outbox/outbox.go
"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"
@ -17,20 +18,55 @@ import (
"github.com/owncast/owncast/activitypub/webfinger" "github.com/owncast/owncast/activitypub/webfinger"
"github.com/owncast/owncast/activitypub/workerpool" "github.com/owncast/owncast/activitypub/workerpool"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
=======
>>>>>>> 4f9fbfba1 (WIP):services/apfederation/outbox/outbox.go
"github.com/owncast/owncast/storage/configrepository" "github.com/owncast/owncast/storage/configrepository"
"github.com/owncast/owncast/storage/federationrepository"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/owncast/owncast/services/apfederation/apmodels"
"github.com/owncast/owncast/services/apfederation/crypto"
"github.com/owncast/owncast/services/apfederation/requests"
"github.com/owncast/owncast/services/apfederation/resolvers"
"github.com/owncast/owncast/services/apfederation/webfinger"
"github.com/owncast/owncast/services/apfederation/workerpool"
"github.com/owncast/owncast/services/config" "github.com/owncast/owncast/services/config"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/teris-io/shortid" "github.com/teris-io/shortid"
) )
var configRepository = configrepository.Get() type APOutbox struct {
configRepository configrepository.ConfigRepository
federationRepository *federationrepository.FederationRepository
resolvers *resolvers.APResolvers
workerpool *workerpool.WorkerPool
requests *requests.Requests
}
func New() *APOutbox {
return &APOutbox{
configRepository: configrepository.Get(),
federationRepository: federationrepository.Get(),
resolvers: resolvers.Get(),
workerpool: workerpool.Get(),
requests: requests.Get(),
}
}
var temporaryGlobalInstance *APOutbox
func Get() *APOutbox {
if temporaryGlobalInstance == nil {
temporaryGlobalInstance = New()
}
return temporaryGlobalInstance
}
// SendLive will send all followers the message saying you started a live stream. // SendLive will send all followers the message saying you started a live stream.
func SendLive() error { func (apo *APOutbox) SendLive() error {
textContent := configRepository.GetFederationGoLiveMessage() textContent := apo.configRepository.GetFederationGoLiveMessage()
// If the message is empty then do not send it. // If the message is empty then do not send it.
if textContent == "" { if textContent == "" {
@ -41,11 +77,11 @@ func SendLive() error {
reg := regexp.MustCompile("[^a-zA-Z0-9]+") reg := regexp.MustCompile("[^a-zA-Z0-9]+")
tagProp := streams.NewActivityStreamsTagProperty() tagProp := streams.NewActivityStreamsTagProperty()
for _, tagString := range configRepository.GetServerMetadataTags() { for _, tagString := range apo.configRepository.GetServerMetadataTags() {
tagWithoutSpecialCharacters := reg.ReplaceAllString(tagString, "") tagWithoutSpecialCharacters := reg.ReplaceAllString(tagString, "")
hashtag := apmodels.MakeHashtag(tagWithoutSpecialCharacters) hashtag := apmodels.MakeHashtag(tagWithoutSpecialCharacters)
tagProp.AppendTootHashtag(hashtag) tagProp.AppendTootHashtag(hashtag)
tagString := getHashtagLinkHTMLFromTagString(tagWithoutSpecialCharacters) tagString := apo.getHashtagLinkHTMLFromTagString(tagWithoutSpecialCharacters)
tagStrings = append(tagStrings, tagString) tagStrings = append(tagStrings, tagString)
} }
@ -60,15 +96,19 @@ func SendLive() error {
tagsString := strings.Join(tagStrings, " ") tagsString := strings.Join(tagStrings, " ")
var streamTitle string var streamTitle string
if title := configRepository.GetStreamTitle(); title != "" { if title := apo.configRepository.GetStreamTitle(); title != "" {
streamTitle = fmt.Sprintf("<p>%s</p>", title) streamTitle = fmt.Sprintf("<p>%s</p>", title)
} }
<<<<<<< HEAD:activitypub/outbox/outbox.go
textContent = fmt.Sprintf("<p>%s</p>%s<p>%s</p><p><a href=\"%s\">%s</a></p>", textContent, streamTitle, tagsString, data.GetServerURL(), data.GetServerURL()) textContent = fmt.Sprintf("<p>%s</p>%s<p>%s</p><p><a href=\"%s\">%s</a></p>", textContent, streamTitle, tagsString, data.GetServerURL(), data.GetServerURL())
=======
textContent = fmt.Sprintf("<p>%s</p>%s<p>%s</p><a href=\"%s\">%s</a>", textContent, streamTitle, tagsString, apo.configRepository.GetServerURL(), apo.configRepository.GetServerURL())
>>>>>>> 4f9fbfba1 (WIP):services/apfederation/outbox/outbox.go
activity, _, note, noteID := createBaseOutboundMessage(textContent) activity, _, note, noteID := apo.createBaseOutboundMessage(textContent)
// To the public if we're not treating ActivityPub as "private". // To the public if we're not treating ActivityPub as "private".
if !configRepository.GetFederationIsPrivate() { if !apo.configRepository.GetFederationIsPrivate() {
note = apmodels.MakeNotePublic(note) note = apmodels.MakeNotePublic(note)
activity = apmodels.MakeActivityPublic(activity) activity = apmodels.MakeActivityPublic(activity)
} }
@ -76,11 +116,11 @@ func SendLive() error {
note.SetActivityStreamsTag(tagProp) note.SetActivityStreamsTag(tagProp)
// Attach an image along with the Federated message. // Attach an image along with the Federated message.
previewURL, err := url.Parse(configRepository.GetServerURL()) previewURL, err := url.Parse(apo.configRepository.GetServerURL())
if err == nil { if err == nil {
var imageToAttach string var imageToAttach string
var mediaType string var mediaType string
c := config.GetConfig() c := config.Get()
previewGif := filepath.Join(c.TempDir, "preview.gif") previewGif := filepath.Join(c.TempDir, "preview.gif")
thumbnailJpg := filepath.Join(c.TempDir, "thumbnail.jpg") thumbnailJpg := filepath.Join(c.TempDir, "thumbnail.jpg")
uniquenessString := shortid.MustGenerate() uniquenessString := shortid.MustGenerate()
@ -98,7 +138,7 @@ func SendLive() error {
} }
} }
if configRepository.GetNSFW() { if apo.configRepository.GetNSFW() {
// Mark content as sensitive. // Mark content as sensitive.
sensitive := streams.NewActivityStreamsSensitiveProperty() sensitive := streams.NewActivityStreamsSensitiveProperty()
sensitive.AppendXMLSchemaBoolean(true) sensitive.AppendXMLSchemaBoolean(true)
@ -111,11 +151,11 @@ func SendLive() error {
return errors.New("unable to serialize go live message activity " + err.Error()) return errors.New("unable to serialize go live message activity " + err.Error())
} }
if err := SendToFollowers(b); err != nil { if err := apo.SendToFollowers(b); err != nil {
return err return err
} }
if err := Add(note, noteID, true); err != nil { if err := apo.Add(note, noteID, true); err != nil {
return err return err
} }
@ -123,20 +163,22 @@ func SendLive() error {
} }
// SendDirectMessageToAccount will send a direct message to a single account. // SendDirectMessageToAccount will send a direct message to a single account.
func SendDirectMessageToAccount(textContent, account string) error { func (apo *APOutbox) SendDirectMessageToAccount(textContent, account string) error {
links, err := webfinger.GetWebfingerLinks(account) wf := webfinger.Get()
links, err := wf.GetWebfingerLinks(account)
if err != nil { if err != nil {
return errors.Wrap(err, "unable to get webfinger links when sending private message") return errors.Wrap(err, "unable to get webfinger links when sending private message")
} }
user := apmodels.MakeWebFingerRequestResponseFromData(links) user := apmodels.MakeWebFingerRequestResponseFromData(links)
iri := user.Self iri := user.Self
actor, err := resolvers.GetResolvedActorFromIRI(iri) actor, err := apo.resolvers.GetResolvedActorFromIRI(iri)
if err != nil { if err != nil {
return errors.Wrap(err, "unable to resolve actor to send message to") return errors.Wrap(err, "unable to resolve actor to send message to")
} }
activity, _, note, _ := createBaseOutboundMessage(textContent) activity, _, note, _ := apo.createBaseOutboundMessage(textContent)
// Set direct message visibility // Set direct message visibility
activity = apmodels.MakeActivityDirect(activity, actor.ActorIri) activity = apmodels.MakeActivityDirect(activity, actor.ActorIri)
@ -150,11 +192,11 @@ func SendDirectMessageToAccount(textContent, account string) error {
return errors.Wrap(err, "unable to serialize custom fediverse message activity") return errors.Wrap(err, "unable to serialize custom fediverse message activity")
} }
return SendToUser(actor.Inbox, b) return apo.SendToUser(actor.Inbox, b)
} }
// SendPublicMessage will send a public message to all followers. // SendPublicMessage will send a public message to all followers.
func SendPublicMessage(textContent string) error { func (apo *APOutbox) SendPublicMessage(textContent string) error {
originalContent := textContent originalContent := textContent
textContent = utils.RenderSimpleMarkdown(textContent) textContent = utils.RenderSimpleMarkdown(textContent)
@ -166,7 +208,7 @@ func SendPublicMessage(textContent string) error {
tagWithoutHashtag := strings.TrimPrefix(hashtag, "#") tagWithoutHashtag := strings.TrimPrefix(hashtag, "#")
// Replace the instances of the tag with a link to the tag page. // Replace the instances of the tag with a link to the tag page.
tagHTML := getHashtagLinkHTMLFromTagString(tagWithoutHashtag) tagHTML := apo.getHashtagLinkHTMLFromTagString(tagWithoutHashtag)
textContent = strings.ReplaceAll(textContent, hashtag, tagHTML) textContent = strings.ReplaceAll(textContent, hashtag, tagHTML)
// Create Hashtag object for the tag. // Create Hashtag object for the tag.
@ -174,10 +216,10 @@ func SendPublicMessage(textContent string) error {
tagProp.AppendTootHashtag(hashtag) tagProp.AppendTootHashtag(hashtag)
} }
activity, _, note, noteID := createBaseOutboundMessage(textContent) activity, _, note, noteID := apo.createBaseOutboundMessage(textContent)
note.SetActivityStreamsTag(tagProp) note.SetActivityStreamsTag(tagProp)
if !configRepository.GetFederationIsPrivate() { if !apo.configRepository.GetFederationIsPrivate() {
note = apmodels.MakeNotePublic(note) note = apmodels.MakeNotePublic(note)
activity = apmodels.MakeActivityPublic(activity) activity = apmodels.MakeActivityPublic(activity)
} }
@ -188,11 +230,11 @@ func SendPublicMessage(textContent string) error {
return errors.New("unable to serialize custom fediverse message activity " + err.Error()) return errors.New("unable to serialize custom fediverse message activity " + err.Error())
} }
if err := SendToFollowers(b); err != nil { if err := apo.SendToFollowers(b); err != nil {
return err return err
} }
if err := Add(note, noteID, false); err != nil { if err := apo.Add(note, noteID, false); err != nil {
return err return err
} }
@ -200,8 +242,8 @@ func SendPublicMessage(textContent string) error {
} }
// nolint: unparam // nolint: unparam
func createBaseOutboundMessage(textContent string) (vocab.ActivityStreamsCreate, string, vocab.ActivityStreamsNote, string) { func (apo *APOutbox) createBaseOutboundMessage(textContent string) (vocab.ActivityStreamsCreate, string, vocab.ActivityStreamsNote, string) {
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername()) localActor := apmodels.MakeLocalIRIForAccount(apo.configRepository.GetDefaultFederationUsername())
noteID := shortid.MustGenerate() noteID := shortid.MustGenerate()
noteIRI := apmodels.MakeLocalIRIForResource(noteID) noteIRI := apmodels.MakeLocalIRIForResource(noteID)
id := shortid.MustGenerate() id := shortid.MustGenerate()
@ -216,15 +258,15 @@ func createBaseOutboundMessage(textContent string) (vocab.ActivityStreamsCreate,
} }
// Get Hashtag HTML link for a given tag (without # prefix). // Get Hashtag HTML link for a given tag (without # prefix).
func getHashtagLinkHTMLFromTagString(baseHashtag string) string { func (apo *APOutbox) getHashtagLinkHTMLFromTagString(baseHashtag string) string {
return fmt.Sprintf("<a class=\"hashtag\" href=\"https://directory.owncast.online/tags/%s\">#%s</a>", baseHashtag, baseHashtag) return fmt.Sprintf("<a class=\"hashtag\" href=\"https://directory.owncast.online/tags/%s\">#%s</a>", baseHashtag, baseHashtag)
} }
// SendToFollowers will send an arbitrary payload to all follower inboxes. // SendToFollowers will send an arbitrary payload to all follower inboxes.
func SendToFollowers(payload []byte) error { func (apo *APOutbox) SendToFollowers(payload []byte) error {
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername()) localActor := apmodels.MakeLocalIRIForAccount(apo.configRepository.GetDefaultFederationUsername())
followers, _, err := persistence.GetFederationFollowers(-1, 0) followers, _, err := apo.federationRepository.GetFederationFollowers(-1, 0)
if err != nil { if err != nil {
log.Errorln("unable to fetch followers to send to", err) log.Errorln("unable to fetch followers to send to", err)
return errors.New("unable to fetch followers to send payload to") return errors.New("unable to fetch followers to send payload to")
@ -238,29 +280,29 @@ func SendToFollowers(payload []byte) error {
return errors.New("unable to create outbox request: " + follower.Inbox) return errors.New("unable to create outbox request: " + follower.Inbox)
} }
workerpool.AddToOutboundQueue(req) apo.workerpool.AddToOutboundQueue(req)
} }
return nil return nil
} }
// 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 (apo *APOutbox) SendToUser(inbox *url.URL, payload []byte) error {
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername()) localActor := apmodels.MakeLocalIRIForAccount(apo.configRepository.GetDefaultFederationUsername())
req, err := requests.CreateSignedRequest(payload, inbox, localActor) req, err := apo.requests.CreateSignedRequest(payload, inbox, localActor)
if err != nil { if err != nil {
return errors.Wrap(err, "unable to create outbox request") return errors.Wrap(err, "unable to create outbox request")
} }
workerpool.AddToOutboundQueue(req) apo.workerpool.AddToOutboundQueue(req)
return nil return nil
} }
// UpdateFollowersWithAccountUpdates will send an update to all followers alerting of a profile update. // UpdateFollowersWithAccountUpdates will send an update to all followers alerting of a profile update.
func UpdateFollowersWithAccountUpdates() error { func (apo *APOutbox) UpdateFollowersWithAccountUpdates() error {
// Don't do anything if federation is disabled. // Don't do anything if federation is disabled.
if !configRepository.GetFederationEnabled() { if !apo.configRepository.GetFederationEnabled() {
return nil return nil
} }
@ -269,7 +311,7 @@ func UpdateFollowersWithAccountUpdates() error {
activity := apmodels.MakeUpdateActivity(objectID) activity := apmodels.MakeUpdateActivity(objectID)
actor := streams.NewActivityStreamsPerson() actor := streams.NewActivityStreamsPerson()
actorID := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername()) actorID := apmodels.MakeLocalIRIForAccount(apo.configRepository.GetDefaultFederationUsername())
actorIDProperty := streams.NewJSONLDIdProperty() actorIDProperty := streams.NewJSONLDIdProperty()
actorIDProperty.Set(actorID) actorIDProperty.Set(actorID)
actor.SetJSONLDId(actorIDProperty) actor.SetJSONLDId(actorIDProperty)
@ -287,11 +329,11 @@ func UpdateFollowersWithAccountUpdates() error {
log.Errorln("unable to serialize send update actor activity", err) log.Errorln("unable to serialize send update actor activity", err)
return errors.New("unable to serialize send update actor activity") return errors.New("unable to serialize send update actor activity")
} }
return SendToFollowers(b) return apo.SendToFollowers(b)
} }
// Add will save an ActivityPub object to the datastore. // Add will save an ActivityPub object to the datastore.
func Add(item vocab.Type, id string, isLiveNotification bool) error { func (apo *APOutbox) Add(item vocab.Type, id string, isLiveNotification bool) error {
iri := item.GetJSONLDId().GetIRI().String() iri := item.GetJSONLDId().GetIRI().String()
typeString := item.GetTypeName() typeString := item.GetTypeName()
@ -306,5 +348,5 @@ func Add(item vocab.Type, id string, isLiveNotification bool) error {
return err return err
} }
return persistence.AddToOutbox(iri, b, typeString, isLiveNotification) return apo.federationRepository.AddToOutbox(iri, b, typeString, isLiveNotification)
} }

16
activitypub/requests/http.go → services/apfederation/requests/http.go

@ -9,14 +9,14 @@ import (
"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/crypto" "github.com/owncast/owncast/services/apfederation/crypto"
"github.com/owncast/owncast/services/config" "github.com/owncast/owncast/services/config"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// WriteStreamResponse will write a ActivityPub object to the provided ResponseWriter and sign with the provided key. // WriteStreamResponse will write a ActivityPub object to the provided ResponseWriter and sign with the provided key.
func WriteStreamResponse(item vocab.Type, w http.ResponseWriter, publicKey crypto.PublicKey) error { func (r *Requests) WriteStreamResponse(item vocab.Type, w http.ResponseWriter, publicKey crypto.PublicKey) error {
var jsonmap map[string]interface{} var jsonmap map[string]interface{}
jsonmap, _ = streams.Serialize(item) jsonmap, _ = streams.Serialize(item)
b, err := json.Marshal(jsonmap) b, err := json.Marshal(jsonmap)
@ -24,21 +24,21 @@ func WriteStreamResponse(item vocab.Type, w http.ResponseWriter, publicKey crypt
return err return err
} }
return WriteResponse(b, w, publicKey) return r.WriteResponse(b, w, publicKey)
} }
// WritePayloadResponse will write any arbitrary object to the provided ResponseWriter and sign with the provided key. // WritePayloadResponse will write any arbitrary object to the provided ResponseWriter and sign with the provided key.
func WritePayloadResponse(payload interface{}, w http.ResponseWriter, publicKey crypto.PublicKey) error { func (r *Requests) WritePayloadResponse(payload interface{}, w http.ResponseWriter, publicKey crypto.PublicKey) error {
b, err := json.Marshal(payload) b, err := json.Marshal(payload)
if err != nil { if err != nil {
return err return err
} }
return WriteResponse(b, w, publicKey) return r.WriteResponse(b, w, publicKey)
} }
// WriteResponse will write any arbitrary payload to the provided ResponseWriter and sign with the provided key. // WriteResponse will write any arbitrary payload to the provided ResponseWriter and sign with the provided key.
func WriteResponse(payload []byte, w http.ResponseWriter, publicKey crypto.PublicKey) error { func (r *Requests) WriteResponse(payload []byte, w http.ResponseWriter, publicKey crypto.PublicKey) error {
w.Header().Set("Content-Type", "application/activity+json") w.Header().Set("Content-Type", "application/activity+json")
if err := crypto.SignResponse(w, payload, publicKey); err != nil { if err := crypto.SignResponse(w, payload, publicKey); err != nil {
@ -56,11 +56,11 @@ func WriteResponse(payload []byte, w http.ResponseWriter, publicKey crypto.Publi
} }
// CreateSignedRequest will create a signed POST request of a payload to the provided destination. // CreateSignedRequest will create a signed POST request of a payload to the provided destination.
func CreateSignedRequest(payload []byte, url *url.URL, fromActorIRI *url.URL) (*http.Request, error) { func (r *Requests) CreateSignedRequest(payload []byte, url *url.URL, fromActorIRI *url.URL) (*http.Request, error) {
log.Debugln("Sending", string(payload), "to", url) log.Debugln("Sending", string(payload), "to", url)
req, _ := http.NewRequest(http.MethodPost, url.String(), bytes.NewBuffer(payload)) req, _ := http.NewRequest(http.MethodPost, url.String(), bytes.NewBuffer(payload))
c := config.GetConfig() c := config.Get()
ua := fmt.Sprintf("%s; https://owncast.online", c.GetReleaseString()) ua := fmt.Sprintf("%s; https://owncast.online", c.GetReleaseString())
req.Header.Set("User-Agent", ua) req.Header.Set("User-Agent", ua)
req.Header.Set("Content-Type", "application/activity+json") req.Header.Set("Content-Type", "application/activity+json")

20
services/apfederation/requests/requests.go

@ -0,0 +1,20 @@
package requests
import "github.com/owncast/owncast/services/apfederation/workerpool"
type Requests struct {
outboundWorkerPool *workerpool.WorkerPool
}
func New() *Requests {
return &Requests{}
}
var temporaryGlobalInstance *Requests
func Get() *Requests {
if temporaryGlobalInstance == nil {
temporaryGlobalInstance = New()
}
return temporaryGlobalInstance
}

14
activitypub/resolvers/follow.go → services/apfederation/resolvers/follow.go

@ -5,18 +5,18 @@ import (
"fmt" "fmt"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/services/apfederation/apmodels"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
func getPersonFromFollow(activity vocab.ActivityStreamsFollow) (apmodels.ActivityPubActor, error) { func (apr *APResolvers) getPersonFromFollow(activity vocab.ActivityStreamsFollow) (apmodels.ActivityPubActor, error) {
return GetResolvedActorFromActorProperty(activity.GetActivityStreamsActor()) return apr.GetResolvedActorFromActorProperty(activity.GetActivityStreamsActor())
} }
// MakeFollowRequest will convert an inbound Follow request to our internal actor model. // MakeFollowRequest will convert an inbound Follow request to our internal actor model.
func MakeFollowRequest(c context.Context, activity vocab.ActivityStreamsFollow) (*apmodels.ActivityPubActor, error) { func (apr *APResolvers) MakeFollowRequest(c context.Context, activity vocab.ActivityStreamsFollow) (*apmodels.ActivityPubActor, error) {
person, err := getPersonFromFollow(activity) person, err := apr.getPersonFromFollow(activity)
if err != nil { if err != nil {
return nil, errors.New("unable to resolve person from follow request: " + err.Error()) return nil, errors.New("unable to resolve person from follow request: " + err.Error())
} }
@ -39,8 +39,8 @@ func MakeFollowRequest(c context.Context, activity vocab.ActivityStreamsFollow)
} }
// MakeUnFollowRequest will convert an inbound Unfollow request to our internal actor model. // MakeUnFollowRequest will convert an inbound Unfollow request to our internal actor model.
func MakeUnFollowRequest(c context.Context, activity vocab.ActivityStreamsUndo) *apmodels.ActivityPubActor { func (apr *APResolvers) MakeUnFollowRequest(c context.Context, activity vocab.ActivityStreamsUndo) *apmodels.ActivityPubActor {
person, err := GetResolvedActorFromActorProperty(activity.GetActivityStreamsActor()) person, err := apr.GetResolvedActorFromActorProperty(activity.GetActivityStreamsActor())
if err != nil { if err != nil {
log.Errorln("unable to resolve person from actor iri", person.ActorIri, err) log.Errorln("unable to resolve person from actor iri", person.ActorIri, err)
return nil return nil

22
activitypub/resolvers/resolve.go → services/apfederation/resolvers/resolve.go

@ -8,15 +8,15 @@ import (
"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/services/apfederation/apmodels"
"github.com/owncast/owncast/activitypub/crypto" "github.com/owncast/owncast/services/apfederation/crypto"
"github.com/owncast/owncast/storage/configrepository" "github.com/owncast/owncast/storage/configrepository"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// Resolve will translate a raw ActivityPub payload and fire the callback associated with that activity type. // Resolve will translate a raw ActivityPub payload and fire the callback associated with that activity type.
func Resolve(c context.Context, data []byte, callbacks ...interface{}) error { func (apr *APResolvers) Resolve(c context.Context, data []byte, callbacks ...interface{}) error {
jsonResolver, err := streams.NewJSONResolver(callbacks...) jsonResolver, err := streams.NewJSONResolver(callbacks...)
if err != nil { if err != nil {
// Something in the setup was wrong. For example, a callback has an // Something in the setup was wrong. For example, a callback has an
@ -46,7 +46,7 @@ func Resolve(c context.Context, data []byte, callbacks ...interface{}) error {
} }
// ResolveIRI will resolve an IRI ahd call the correct callback for the resolved type. // ResolveIRI will resolve an IRI ahd call the correct callback for the resolved type.
func ResolveIRI(c context.Context, iri string, callbacks ...interface{}) error { func (apr *APResolvers) ResolveIRI(c context.Context, iri string, callbacks ...interface{}) error {
log.Debugln("Resolving", iri) log.Debugln("Resolving", iri)
req, _ := http.NewRequest(http.MethodGet, iri, nil) req, _ := http.NewRequest(http.MethodGet, iri, nil)
@ -71,12 +71,12 @@ func ResolveIRI(c context.Context, iri string, callbacks ...interface{}) error {
} }
// fmt.Println(string(data)) // fmt.Println(string(data))
return Resolve(c, data, callbacks...) return apr.Resolve(c, data, callbacks...)
} }
// GetResolvedActorFromActorProperty resolve an external actor property to a // GetResolvedActorFromActorProperty resolve an external actor property to a
// fully populated internal actor representation. // fully populated internal actor representation.
func GetResolvedActorFromActorProperty(actor vocab.ActivityStreamsActorProperty) (apmodels.ActivityPubActor, error) { func (apr *APResolvers) GetResolvedActorFromActorProperty(actor vocab.ActivityStreamsActorProperty) (apmodels.ActivityPubActor, error) {
var err error var err error
var apActor apmodels.ActivityPubActor var apActor apmodels.ActivityPubActor
resolved := false resolved := false
@ -89,7 +89,7 @@ func GetResolvedActorFromActorProperty(actor vocab.ActivityStreamsActorProperty)
// If the actor is an unresolved IRI then we need to resolve it. // If the actor is an unresolved IRI then we need to resolve it.
if actorObjectOrIRI.IsIRI() { if actorObjectOrIRI.IsIRI() {
iri := actorObjectOrIRI.GetIRI().String() iri := actorObjectOrIRI.GetIRI().String()
return GetResolvedActorFromIRI(iri) return apr.GetResolvedActorFromIRI(iri)
} }
if actorObjectOrIRI.IsActivityStreamsPerson() { if actorObjectOrIRI.IsActivityStreamsPerson() {
@ -125,7 +125,7 @@ func GetResolvedActorFromActorProperty(actor vocab.ActivityStreamsActorProperty)
} }
// GetResolvedPublicKeyFromIRI will resolve a publicKey IRI string to a vocab.W3IDSecurityV1PublicKey. // GetResolvedPublicKeyFromIRI will resolve a publicKey IRI string to a vocab.W3IDSecurityV1PublicKey.
func GetResolvedPublicKeyFromIRI(publicKeyIRI string) (vocab.W3IDSecurityV1PublicKey, error) { func (apr *APResolvers) GetResolvedPublicKeyFromIRI(publicKeyIRI string) (vocab.W3IDSecurityV1PublicKey, error) {
var err error var err error
var pubkey vocab.W3IDSecurityV1PublicKey var pubkey vocab.W3IDSecurityV1PublicKey
resolved := false resolved := false
@ -175,7 +175,7 @@ func GetResolvedPublicKeyFromIRI(publicKeyIRI string) (vocab.W3IDSecurityV1Publi
return nil return nil
} }
if e := ResolveIRI(context.Background(), publicKeyIRI, personCallback, serviceCallback, applicationCallback, pubkeyCallback); e != nil { if e := apr.ResolveIRI(context.Background(), publicKeyIRI, personCallback, serviceCallback, applicationCallback, pubkeyCallback); e != nil {
err = e err = e
} }
@ -191,7 +191,7 @@ func GetResolvedPublicKeyFromIRI(publicKeyIRI string) (vocab.W3IDSecurityV1Publi
} }
// GetResolvedActorFromIRI will resolve an IRI string to a fully populated actor. // GetResolvedActorFromIRI will resolve an IRI string to a fully populated actor.
func GetResolvedActorFromIRI(personOrServiceIRI string) (apmodels.ActivityPubActor, error) { func (apr *APResolvers) GetResolvedActorFromIRI(personOrServiceIRI string) (apmodels.ActivityPubActor, error) {
var err error var err error
var apActor apmodels.ActivityPubActor var apActor apmodels.ActivityPubActor
resolved := false resolved := false
@ -222,7 +222,7 @@ func GetResolvedActorFromIRI(personOrServiceIRI string) (apmodels.ActivityPubAct
return e return e
} }
if e := ResolveIRI(context.Background(), personOrServiceIRI, personCallback, serviceCallback, applicationCallback); e != nil { if e := apr.ResolveIRI(context.Background(), personOrServiceIRI, personCallback, serviceCallback, applicationCallback); e != nil {
err = e err = e
} }

24
services/apfederation/resolvers/resolvers.go

@ -0,0 +1,24 @@
package resolvers
import (
"github.com/owncast/owncast/storage/configrepository"
)
type APResolvers struct {
configRepository configrepository.ConfigRepository
}
func New() *APResolvers {
return &APResolvers{
configRepository: configrepository.Get(),
}
}
var temporaryGlobalInstance *APResolvers
func Get() *APResolvers {
if temporaryGlobalInstance == nil {
temporaryGlobalInstance = New()
}
return temporaryGlobalInstance
}

17
activitypub/webfinger/webfinger.go → services/apfederation/webfinger/webfinger.go

@ -8,8 +8,23 @@ import (
"strings" "strings"
) )
type Webfinger struct{}
var temporaryGlobalInstance *Webfinger
func Get() *Webfinger {
if temporaryGlobalInstance == nil {
temporaryGlobalInstance = New()
}
return temporaryGlobalInstance
}
func New() *Webfinger {
return &Webfinger{}
}
// GetWebfingerLinks will return webfinger data for an account. // GetWebfingerLinks will return webfinger data for an account.
func GetWebfingerLinks(account string) ([]map[string]interface{}, error) { func (w *Webfinger) GetWebfingerLinks(account string) ([]map[string]interface{}, error) {
type webfingerResponse struct { type webfingerResponse struct {
Links []map[string]interface{} `json:"links"` Links []map[string]interface{} `json:"links"`
} }

35
activitypub/workerpool/outbound.go → services/apfederation/workerpool/outbound.go

@ -15,12 +15,29 @@ type Job struct {
request *http.Request request *http.Request
} }
var queue chan Job type WorkerPool struct {
queue chan Job
}
// InitOutboundWorkerPool starts n go routines that await ActivityPub jobs. func New() *WorkerPool {
func InitOutboundWorkerPool() { wp := &WorkerPool{
queue = make(chan Job) queue: make(chan Job),
}
wp.initOutboundWorkerPool()
return wp
}
var temporaryGlobalInstance *WorkerPool
func Get() *WorkerPool {
if temporaryGlobalInstance == nil {
temporaryGlobalInstance = New()
}
return temporaryGlobalInstance
}
// InitOutboundWorkerPool starts n go routines that await ActivityPub jobs.
func (wp *WorkerPool) initOutboundWorkerPool() {
// start workers // start workers
for i := 1; i <= workerPoolSize; i++ { for i := 1; i <= workerPoolSize; i++ {
go worker(i, queue) go worker(i, queue)
@ -28,23 +45,23 @@ func InitOutboundWorkerPool() {
} }
// AddToOutboundQueue will queue up an outbound http request. // AddToOutboundQueue will queue up an outbound http request.
func AddToOutboundQueue(req *http.Request) { func (wp *WorkerPool) AddToOutboundQueue(req *http.Request) {
log.Tracef("Queued request for ActivityPub destination %s", req.RequestURI) log.Tracef("Queued request for ActivityPub destination %s", req.RequestURI)
queue <- Job{req} wp.queue <- Job{req}
} }
func worker(workerID int, queue <-chan Job) { func (wp *WorkerPool) worker(workerID int, queue <-chan Job) {
log.Debugf("Started ActivityPub worker %d", workerID) log.Debugf("Started ActivityPub worker %d", workerID)
for job := range queue { for job := range queue {
if err := sendActivityPubMessageToInbox(job); err != nil { if err := wp.sendActivityPubMessageToInbox(job); err != nil {
log.Errorf("ActivityPub destination %s failed to send Error: %s", job.request.RequestURI, err) log.Errorf("ActivityPub destination %s failed to send Error: %s", job.request.RequestURI, err)
} }
log.Tracef("Done with ActivityPub destination %s using worker %d", job.request.RequestURI, workerID) log.Tracef("Done with ActivityPub destination %s using worker %d", job.request.RequestURI, workerID)
} }
} }
func sendActivityPubMessageToInbox(job Job) error { func (wp *WorkerPool) sendActivityPubMessageToInbox(job Job) error {
client := &http.Client{} client := &http.Client{}
resp, err := client.Do(job.request) resp, err := client.Do(job.request)

240
services/chat/chat.go

@ -0,0 +1,240 @@
package chat
import (
"errors"
"fmt"
"net/http"
"sort"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/storage/chatrepository"
"github.com/owncast/owncast/storage/configrepository"
"github.com/owncast/owncast/storage/userrepository"
log "github.com/sirupsen/logrus"
)
type Chat struct {
getStatus func() *models.Status
server *Server
configRepository *configrepository.SqlConfigRepository
}
func New() *Chat {
return &Chat{
configRepository: configrepository.Get(),
}
}
var temporaryGlobalInstance *Chat
// GetConfig returns the temporary global instance.
// Remove this after dependency injection is implemented.
func Get() *Chat {
if temporaryGlobalInstance == nil {
temporaryGlobalInstance = New()
}
return temporaryGlobalInstance
}
// Start begins the chat server.
func (c *Chat) Start(getStatusFunc func() *models.Status) error {
c.getStatus = getStatusFunc
c.server = NewChat()
go c.server.Run()
log.Traceln("Chat server started with max connection count of", c.server.maxSocketConnectionLimit)
return nil
}
// GetClientsForUser will return chat connections that are owned by a specific user.
func (c *Chat) GetClientsForUser(userID string) ([]*Client, error) {
c.server.mu.Lock()
defer c.server.mu.Unlock()
clients := map[string][]*Client{}
for _, client := range c.server.clients {
clients[client.User.ID] = append(clients[client.User.ID], client)
}
if _, exists := clients[userID]; !exists {
return nil, errors.New("no connections for user found")
}
return clients[userID], nil
}
// FindClientByID will return a single connected client by ID.
func (c *Chat) FindClientByID(clientID uint) (*Client, bool) {
client, found := c.server.clients[clientID]
return client, found
}
// GetClients will return all the current chat clients connected.
func (c *Chat) GetClients() []*Client {
clients := []*Client{}
if c.server == nil {
return clients
}
// Convert the keyed map to a slice.
for _, client := range c.server.clients {
clients = append(clients, client)
}
sort.Slice(clients, func(i, j int) bool {
return clients[i].ConnectedAt.Before(clients[j].ConnectedAt)
})
return clients
}
// SendSystemMessage will send a message string as a system message to all clients.
func (c *Chat) SendSystemMessage(text string, ephemeral bool) error {
message := models.SystemMessageEvent{
MessageEvent: models.MessageEvent{
Body: text,
},
}
message.SetDefaults()
message.RenderBody()
message.DisplayName = c.configRepository.GetServerName()
if err := c.Broadcast(&message); err != nil {
log.Errorln("error sending system message", err)
}
if !ephemeral {
cr := chatrepository.Get()
cr.SaveEvent(message.ID, nil, message.Body, message.GetMessageType(), nil, message.Timestamp, nil, nil, nil, nil)
}
return nil
}
// SendFediverseAction will send a message indicating some Fediverse engagement took place.
func (c *Chat) SendFediverseAction(eventType string, userAccountName string, image *string, body string, link string) error {
message := models.FediverseEngagementEvent{
Event: models.Event{
Type: eventType,
},
MessageEvent: models.MessageEvent{
Body: body,
},
UserAccountName: userAccountName,
Image: image,
Link: link,
}
message.SetDefaults()
message.RenderBody()
if err := c.Broadcast(&message); err != nil {
log.Errorln("error sending system message", err)
return err
}
cr := chatrepository.Get()
cr.SaveFederatedAction(message)
return nil
}
// SendSystemAction will send a system action string as an action event to all clients.
func (c *Chat) SendSystemAction(text string, ephemeral bool) error {
message := models.ActionEvent{
MessageEvent: models.MessageEvent{
Body: text,
},
}
message.SetDefaults()
message.RenderBody()
if err := c.Broadcast(&message); err != nil {
log.Errorln("error sending system chat action")
}
if !ephemeral {
cr := chatrepository.Get()
cr.SaveEvent(message.ID, nil, message.Body, message.GetMessageType(), nil, message.Timestamp, nil, nil, nil, nil)
}
return nil
}
// SendAllWelcomeMessage will send the chat message to all connected clients.
func (c *Chat) SendAllWelcomeMessage() {
c.server.sendAllWelcomeMessage()
}
// SendSystemMessageToClient will send a single message to a single connected chat client.
func (c *Chat) SendSystemMessageToClient(clientID uint, text string) {
if client, foundClient := c.FindClientByID(clientID); foundClient {
c.server.sendSystemMessageToClient(client, text)
}
}
// Broadcast will send all connected clients the outbound object provided.
func (c *Chat) Broadcast(event models.OutboundEvent) error {
return c.server.Broadcast(event.GetBroadcastPayload())
}
// HandleClientConnection handles a single inbound websocket connection.
func (c *Chat) HandleClientConnection(w http.ResponseWriter, r *http.Request) {
c.server.HandleClientConnection(w, r)
}
// DisconnectClients will forcefully disconnect all clients belonging to a user by ID.
func (c *Chat) DisconnectClients(clients []*Client) {
c.server.DisconnectClients(clients)
}
// SendConnectedClientInfoToUser will find all the connected clients assigned to a user
// and re-send each the connected client info.
func (c *Chat) SendConnectedClientInfoToUser(userID string) error {
clients, err := c.GetClientsForUser(userID)
if err != nil {
return err
}
userRepository := userrepository.Get()
// Get an updated reference to the user.
user := userRepository.GetUserByID(userID)
if user == nil {
return fmt.Errorf("user not found")
}
if err != nil {
return err
}
for _, client := range clients {
// Update the client's reference to its user.
client.User = user
// Send the update to the client.
client.sendConnectedClientInfo()
}
return nil
}
// SendActionToUser will send system action text to all connected clients
// assigned to a user ID.
func (c *Chat) SendActionToUser(userID string, text string) error {
clients, err := c.GetClientsForUser(userID)
if err != nil {
return err
}
for _, client := range clients {
c.server.sendActionToClient(client, text)
}
return nil
}

11
core/chat/chatclient.go → services/chat/chatclient.go

@ -11,7 +11,6 @@ import (
"golang.org/x/time/rate" "golang.org/x/time/rate"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/services/config" "github.com/owncast/owncast/services/config"
"github.com/owncast/owncast/services/geoip" "github.com/owncast/owncast/services/geoip"
@ -75,9 +74,9 @@ var (
) )
func (c *Client) sendConnectedClientInfo() { func (c *Client) sendConnectedClientInfo() {
payload := events.ConnectedClientInfo{ payload := models.ConnectedClientInfo{
Event: events.Event{ Event: models.Event{
Type: events.ConnectedUserInfo, Type: models.ConnectedUserInfo,
}, },
User: c.User, User: c.User,
} }
@ -237,8 +236,8 @@ func (c *Client) sendPayload(payload interface{}) {
} }
func (c *Client) sendAction(message string) { func (c *Client) sendAction(message string) {
clientMessage := events.ActionEvent{ clientMessage := models.ActionEvent{
MessageEvent: events.MessageEvent{ MessageEvent: models.MessageEvent{
Body: message, Body: message,
}, },
} }

35
core/chat/events.go → services/chat/events.go

@ -6,16 +6,18 @@ import (
"strings" "strings"
"time" "time"
"github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/services/config" "github.com/owncast/owncast/services/config"
"github.com/owncast/owncast/services/status"
"github.com/owncast/owncast/services/webhooks" "github.com/owncast/owncast/services/webhooks"
"github.com/owncast/owncast/storage" "github.com/owncast/owncast/storage/chatrepository"
"github.com/owncast/owncast/storage/userrepository"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
func (s *Server) userNameChanged(eventData chatClientEvent) { func (s *Server) userNameChanged(eventData chatClientEvent) {
var receivedEvent events.NameChangeEvent var receivedEvent models.NameChangeEvent
if err := json.Unmarshal(eventData.data, &receivedEvent); err != nil { if err := json.Unmarshal(eventData.data, &receivedEvent); err != nil {
log.Errorln("error unmarshalling to NameChangeEvent", err) log.Errorln("error unmarshalling to NameChangeEvent", err)
return return
@ -24,8 +26,8 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
proposedUsername := receivedEvent.NewName proposedUsername := receivedEvent.NewName
// Check if name is on the blocklist // Check if name is on the blocklist
blocklist := configRepository.GetForbiddenUsernameList() blocklist := s.configRepository.GetForbiddenUsernameList()
userRepository := storage.GetUserRepository() userRepository := userrepository.Get()
// Names have a max length // Names have a max length
proposedUsername = utils.MakeSafeStringOfLength(proposedUsername, config.MaxChatDisplayNameLength) proposedUsername = utils.MakeSafeStringOfLength(proposedUsername, config.MaxChatDisplayNameLength)
@ -82,7 +84,7 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
// Send chat event letting everyone about about the name change // Send chat event letting everyone about about the name change
savedUser.DisplayName = proposedUsername savedUser.DisplayName = proposedUsername
broadcastEvent := events.NameChangeBroadcast{ broadcastEvent := models.NameChangeBroadcast{
Oldname: oldName, Oldname: oldName,
} }
broadcastEvent.User = savedUser broadcastEvent.User = savedUser
@ -104,7 +106,7 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
} }
func (s *Server) userColorChanged(eventData chatClientEvent) { func (s *Server) userColorChanged(eventData chatClientEvent) {
var receivedEvent events.ColorChangeEvent var receivedEvent models.ColorChangeEvent
if err := json.Unmarshal(eventData.data, &receivedEvent); err != nil { if err := json.Unmarshal(eventData.data, &receivedEvent); err != nil {
log.Errorln("error unmarshalling to ColorChangeEvent", err) log.Errorln("error unmarshalling to ColorChangeEvent", err)
return return
@ -115,7 +117,7 @@ func (s *Server) userColorChanged(eventData chatClientEvent) {
log.Errorln("invalid color requested when changing user display color") log.Errorln("invalid color requested when changing user display color")
return return
} }
userRepository := storage.GetUserRepository() userRepository := userrepository.Get()
// Save the new color // Save the new color
if err := userRepository.ChangeUserColor(eventData.client.User.ID, receivedEvent.NewColor); err != nil { if err := userRepository.ChangeUserColor(eventData.client.User.ID, receivedEvent.NewColor); err != nil {
@ -128,7 +130,7 @@ func (s *Server) userColorChanged(eventData chatClientEvent) {
} }
func (s *Server) userMessageSent(eventData chatClientEvent) { func (s *Server) userMessageSent(eventData chatClientEvent) {
var event events.UserMessageEvent var event models.UserMessageEvent
if err := json.Unmarshal(eventData.data, &event); err != nil { if err := json.Unmarshal(eventData.data, &event); err != nil {
log.Errorln("error unmarshalling to UserMessageEvent", err) log.Errorln("error unmarshalling to UserMessageEvent", err)
return return
@ -142,15 +144,17 @@ func (s *Server) userMessageSent(eventData chatClientEvent) {
return return
} }
st := status.Get()
// Ignore if the stream has been offline // Ignore if the stream has been offline
if !getStatus().Online && getStatus().LastDisconnectTime != nil { if st.Online && st.Status.LastDisconnectTime != nil {
disconnectedTime := getStatus().LastDisconnectTime.Time disconnectedTime := st.Status.LastDisconnectTime.Time
if time.Since(disconnectedTime) > 5*time.Minute { if time.Since(disconnectedTime) > 5*time.Minute {
return return
} }
} }
userRepository := storage.GetUserRepository() userRepository := userrepository.Get()
event.User = userRepository.GetUserByToken(eventData.client.accessToken) event.User = userRepository.GetUserByToken(eventData.client.accessToken)
@ -168,11 +172,12 @@ func (s *Server) userMessageSent(eventData chatClientEvent) {
// Send chat message sent webhook // Send chat message sent webhook
webhookManager := webhooks.Get() webhookManager := webhooks.Get()
webhookManager.SendChatEvent(&event) webhookManager.SendChatEvent(&event)
chatMessagesSentCounter.Inc() s.chatMessagesSentCounter.Inc()
SaveUserMessage(event) cr := chatrepository.Get()
cr.SaveUserMessage(event)
eventData.client.MessageCount++ eventData.client.MessageCount++
_lastSeenCache[event.User.ID] = time.Now() s.lastSeenCache[event.User.ID] = time.Now()
} }
func logSanitize(userValue string) string { func logSanitize(userValue string) string {

3
core/chat/messageRendering_test.go → services/chat/messageRendering_test.go

@ -4,6 +4,7 @@ import (
"testing" "testing"
"github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/models"
) )
// Test a bunch of arbitrary markup and markdown to make sure we get sanitized // Test a bunch of arbitrary markup and markdown to make sure we get sanitized
@ -54,7 +55,7 @@ func TestAllowEmojiImages(t *testing.T) {
func TestAllowHTML(t *testing.T) { func TestAllowHTML(t *testing.T) {
messageContent := `<img src="/img/emoji/beerparrot.gif"><ul><li>**test thing**</li></ul>` messageContent := `<img src="/img/emoji/beerparrot.gif"><ul><li>**test thing**</li></ul>`
expected := "<p><img src=\"/img/emoji/beerparrot.gif\"><ul><li><strong>test thing</strong></li></ul></p>\n" expected := "<p><img src=\"/img/emoji/beerparrot.gif\"><ul><li><strong>test thing</strong></li></ul></p>\n"
result := events.RenderMarkdown(messageContent) result := models.RenderMarkdown(messageContent)
if result != expected { if result != expected {
t.Errorf("message rendering does not match expected. Got\n%s, \n\n want:\n%s", result, expected) t.Errorf("message rendering does not match expected. Got\n%s, \n\n want:\n%s", result, expected)

13
core/chat/messages.go → services/chat/messages.go

@ -3,29 +3,32 @@ package chat
import ( import (
"errors" "errors"
"github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/services/webhooks" "github.com/owncast/owncast/services/webhooks"
"github.com/owncast/owncast/storage/chatrepository"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// SetMessagesVisibility will set the visibility of multiple messages by ID. // SetMessagesVisibility will set the visibility of multiple messages by ID.
func SetMessagesVisibility(messageIDs []string, visibility bool) error { func (c *Chat) SetMessagesVisibility(messageIDs []string, visibility bool) error {
cr := chatrepository.Get()
// Save new message visibility // Save new message visibility
if err := saveMessageVisibility(messageIDs, visibility); err != nil { if err := cr.SaveMessageVisibility(messageIDs, visibility); err != nil {
log.Errorln(err) log.Errorln(err)
return err return err
} }
// Send an event letting the chat clients know to hide or show // Send an event letting the chat clients know to hide or show
// the messages. // the messages.
event := events.SetMessageVisibilityEvent{ event := models.SetMessageVisibilityEvent{
MessageIDs: messageIDs, MessageIDs: messageIDs,
Visible: visibility, Visible: visibility,
} }
event.Event.SetDefaults() event.Event.SetDefaults()
payload := event.GetBroadcastPayload() payload := event.GetBroadcastPayload()
if err := _server.Broadcast(payload); err != nil { if err := c.server.Broadcast(payload); err != nil {
return errors.New("error broadcasting message visibility payload " + err.Error()) return errors.New("error broadcasting message visibility payload " + err.Error())
} }

162
core/chat/server.go → services/chat/server.go

@ -7,24 +7,23 @@ import (
"sync" "sync"
"time" "time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/services/config" "github.com/owncast/owncast/services/config"
"github.com/owncast/owncast/services/geoip" "github.com/owncast/owncast/services/geoip"
"github.com/owncast/owncast/services/status"
"github.com/owncast/owncast/services/webhooks" "github.com/owncast/owncast/services/webhooks"
"github.com/owncast/owncast/storage" "github.com/owncast/owncast/storage/chatrepository"
"github.com/owncast/owncast/storage/configrepository"
"github.com/owncast/owncast/storage/userrepository"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
) )
var _server *Server
// a map of user IDs and when they last were active.
var _lastSeenCache = map[string]time.Time{}
// Server represents an instance of the chat server. // Server represents an instance of the chat server.
type Server struct { type Server struct {
clients map[uint]*Client clients map[uint]*Client
@ -41,24 +40,41 @@ type Server struct {
geoipClient *geoip.Client geoipClient *geoip.Client
seq uint seq uint
maxSocketConnectionLimit int64 maxSocketConnectionLimit int64
chatMessagesSentCounter prometheus.Gauge
// a map of user IDs and when they last were active.
lastSeenCache map[string]time.Time
mu sync.RWMutex mu sync.RWMutex
config *config.Config
configRepository *configrepository.SqlConfigRepository
chatRepository *chatrepository.ChatRepository
} }
// NewChat will return a new instance of the chat server. // NewChat will return a new instance of the chat server.
func NewChat() *Server { func NewChat() *Server {
maximumConcurrentConnectionLimit := getMaximumConcurrentConnectionLimit()
setSystemConcurrentConnectionLimit(maximumConcurrentConnectionLimit)
server := &Server{ server := &Server{
clients: map[uint]*Client{}, clients: map[uint]*Client{},
outbound: make(chan []byte), outbound: make(chan []byte),
inbound: make(chan chatClientEvent), inbound: make(chan chatClientEvent),
unregister: make(chan uint), unregister: make(chan uint),
maxSocketConnectionLimit: maximumConcurrentConnectionLimit, maxSocketConnectionLimit: 100, // TODO: Set this properly!
lastSeenCache: map[string]time.Time{},
geoipClient: geoip.NewClient(), geoipClient: geoip.NewClient(),
config: config.Get(),
configRepository: configrepository.Get(),
chatRepository: chatrepository.Get(),
} }
server.chatMessagesSentCounter = promauto.NewGauge(prometheus.GaugeOpts{
Name: "total_chat_message_count",
Help: "The number of chat messages incremented over time.",
ConstLabels: map[string]string{
"version": server.config.VersionNumber,
"host": server.configRepository.GetServerURL(),
},
})
return server return server
} }
@ -93,8 +109,8 @@ func (s *Server) Addclient(conn *websocket.Conn, user *models.User, accessToken
} }
// Do not send user re-joined broadcast message if they've been active within 10 minutes. // Do not send user re-joined broadcast message if they've been active within 10 minutes.
shouldSendJoinedMessages := configRepository.GetChatJoinMessagesEnabled() shouldSendJoinedMessages := s.configRepository.GetChatJoinMessagesEnabled()
if previouslyLastSeen, ok := _lastSeenCache[user.ID]; ok && time.Since(previouslyLastSeen) < time.Minute*10 { if previouslyLastSeen, ok := s.lastSeenCache[user.ID]; ok && time.Since(previouslyLastSeen) < time.Minute*10 {
shouldSendJoinedMessages = false shouldSendJoinedMessages = false
} }
@ -103,7 +119,7 @@ func (s *Server) Addclient(conn *websocket.Conn, user *models.User, accessToken
client.Id = s.seq client.Id = s.seq
s.clients[client.Id] = client s.clients[client.Id] = client
s.seq++ s.seq++
_lastSeenCache[user.ID] = time.Now() s.lastSeenCache[user.ID] = time.Now()
} }
s.mu.Unlock() s.mu.Unlock()
@ -114,7 +130,9 @@ func (s *Server) Addclient(conn *websocket.Conn, user *models.User, accessToken
client.sendConnectedClientInfo() client.sendConnectedClientInfo()
if getStatus().Online { st := status.Get()
if st.Online {
if shouldSendJoinedMessages { if shouldSendJoinedMessages {
s.sendUserJoinedMessage(client) s.sendUserJoinedMessage(client)
} }
@ -130,7 +148,7 @@ func (s *Server) Addclient(conn *websocket.Conn, user *models.User, accessToken
} }
func (s *Server) sendUserJoinedMessage(c *Client) { func (s *Server) sendUserJoinedMessage(c *Client) {
userJoinedEvent := events.UserJoinedEvent{} userJoinedEvent := models.UserJoinedEvent{}
userJoinedEvent.SetDefaults() userJoinedEvent.SetDefaults()
userJoinedEvent.User = c.User userJoinedEvent.User = c.User
userJoinedEvent.ClientID = c.Id userJoinedEvent.ClientID = c.Id
@ -158,14 +176,17 @@ func (s *Server) ClientClosed(c *Client) {
// HandleClientConnection is fired when a single client connects to the websocket. // HandleClientConnection is fired when a single client connects to the websocket.
func (s *Server) HandleClientConnection(w http.ResponseWriter, r *http.Request) { func (s *Server) HandleClientConnection(w http.ResponseWriter, r *http.Request) {
if configRepository.GetChatDisabled() { cr := configrepository.Get()
_, _ = w.Write([]byte(events.ChatDisabled)) chatRepository := chatrepository.Get()
if cr.GetChatDisabled() {
_, _ = w.Write([]byte(models.ChatDisabled))
return return
} }
ipAddress := utils.GetIPAddressFromRequest(r) ipAddress := utils.GetIPAddressFromRequest(r)
// Check if this client's IP address is banned. If so send a rejection. // Check if this client's IP address is banned. If so send a rejection.
if blocked, err := configRepository.IsIPAddressBanned(ipAddress); blocked { if blocked, err := chatRepository.IsIPAddressBanned(ipAddress); blocked {
log.Debugln("Client ip address has been blocked. Rejecting.") log.Debugln("Client ip address has been blocked. Rejecting.")
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
@ -177,7 +198,7 @@ func (s *Server) HandleClientConnection(w http.ResponseWriter, r *http.Request)
// Limit concurrent chat connections // Limit concurrent chat connections
if int64(len(s.clients)) >= s.maxSocketConnectionLimit { if int64(len(s.clients)) >= s.maxSocketConnectionLimit {
log.Warnln("rejecting incoming client connection as it exceeds the max client count of", s.maxSocketConnectionLimit) log.Warnln("rejecting incoming client connection as it exceeds the max client count of", s.maxSocketConnectionLimit)
_, _ = w.Write([]byte(events.ErrorMaxConnectionsExceeded)) _, _ = w.Write([]byte(models.ErrorMaxConnectionsExceeded))
return return
} }
@ -200,14 +221,14 @@ func (s *Server) HandleClientConnection(w http.ResponseWriter, r *http.Request)
return return
} }
userRepository := storage.GetUserRepository() userRepository := userrepository.Get()
// A user is required to use the websocket // A user is required to use the websocket
user := userRepository.GetUserByToken(accessToken) user := userRepository.GetUserByToken(accessToken)
if user == nil { if user == nil {
// Send error that registration is required // Send error that registration is required
_ = conn.WriteJSON(events.EventPayload{ _ = conn.WriteJSON(models.EventPayload{
"type": events.ErrorNeedsRegistration, "type": models.ErrorNeedsRegistration,
}) })
_ = conn.Close() _ = conn.Close()
return return
@ -216,8 +237,8 @@ func (s *Server) HandleClientConnection(w http.ResponseWriter, r *http.Request)
// User is disabled therefore we should disconnect. // User is disabled therefore we should disconnect.
if user.DisabledAt != nil { if user.DisabledAt != nil {
log.Traceln("Disabled user", user.ID, user.DisplayName, "rejected") log.Traceln("Disabled user", user.ID, user.DisplayName, "rejected")
_ = conn.WriteJSON(events.EventPayload{ _ = conn.WriteJSON(models.EventPayload{
"type": events.ErrorUserDisabled, "type": models.ErrorUserDisabled,
}) })
_ = conn.Close() _ = conn.Close()
return return
@ -229,7 +250,7 @@ func (s *Server) HandleClientConnection(w http.ResponseWriter, r *http.Request)
} }
// Broadcast sends message to all connected clients. // Broadcast sends message to all connected clients.
func (s *Server) Broadcast(payload events.EventPayload) error { func (s *Server) Broadcast(payload models.EventPayload) error {
data, err := json.Marshal(payload) data, err := json.Marshal(payload)
if err != nil { if err != nil {
return err return err
@ -254,7 +275,7 @@ func (s *Server) Broadcast(payload events.EventPayload) error {
} }
// Send will send a single payload to a single connected client. // Send will send a single payload to a single connected client.
func (s *Server) Send(payload events.EventPayload, client *Client) { func (s *Server) Send(payload models.EventPayload, client *Client) {
data, err := json.Marshal(payload) data, err := json.Marshal(payload)
if err != nil { if err != nil {
log.Errorln(err) log.Errorln(err)
@ -270,12 +291,12 @@ func (s *Server) DisconnectClients(clients []*Client) {
log.Traceln("Disconnecting client", client.User.ID, "owned by", client.User.DisplayName) log.Traceln("Disconnecting client", client.User.ID, "owned by", client.User.DisplayName)
go func(client *Client) { go func(client *Client) {
event := events.UserDisabledEvent{} event := models.UserDisabledEvent{}
event.SetDefaults() event.SetDefaults()
// Send this disabled event specifically to this single connected client // Send this disabled event specifically to this single connected client
// to let them know they've been banned. // to let them know they've been banned.
_server.Send(event.GetBroadcastPayload(), client) s.Send(event.GetBroadcastPayload(), client)
// Give the socket time to send out the above message. // Give the socket time to send out the above message.
// Unfortunately I don't know of any way to get a real callback to know when // Unfortunately I don't know of any way to get a real callback to know when
@ -290,58 +311,15 @@ func (s *Server) DisconnectClients(clients []*Client) {
} }
} }
// SendConnectedClientInfoToUser will find all the connected clients assigned to a user
// and re-send each the connected client info.
func SendConnectedClientInfoToUser(userID string) error {
clients, err := GetClientsForUser(userID)
if err != nil {
return err
}
userRepository := storage.GetUserRepository()
// Get an updated reference to the user.
user := userRepository.GetUserByID(userID)
if user == nil {
return fmt.Errorf("user not found")
}
if err != nil {
return err
}
for _, client := range clients {
// Update the client's reference to its user.
client.User = user
// Send the update to the client.
client.sendConnectedClientInfo()
}
return nil
}
// SendActionToUser will send system action text to all connected clients
// assigned to a user ID.
func SendActionToUser(userID string, text string) error {
clients, err := GetClientsForUser(userID)
if err != nil {
return err
}
for _, client := range clients {
_server.sendActionToClient(client, text)
}
return nil
}
func (s *Server) eventReceived(event chatClientEvent) { func (s *Server) eventReceived(event chatClientEvent) {
c := event.client c := event.client
u := c.User u := c.User
cr := configrepository.Get()
// If established chat user only mode is enabled and the user is not old // If established chat user only mode is enabled and the user is not old
// enough then reject this event and send them an informative message. // enough then reject this event and send them an informative message.
if u != nil && configRepository.GetChatEstbalishedUsersOnlyMode() && time.Since(event.client.User.CreatedAt) < config.GetDefaults().ChatEstablishedUserModeTimeDuration && !u.IsModerator() { if u != nil && cr.GetChatEstbalishedUsersOnlyMode() && time.Since(event.client.User.CreatedAt) < config.GetDefaults().ChatEstablishedUserModeTimeDuration && !u.IsModerator() {
s.sendActionToClient(c, "You have not been an established chat participant long enough to take part in chat. Please enjoy the stream and try again later.") s.sendActionToClient(c, "You have not been an established chat participant long enough to take part in chat. Please enjoy the stream and try again later.")
return return
} }
@ -354,13 +332,13 @@ func (s *Server) eventReceived(event chatClientEvent) {
eventType := typecheck["type"] eventType := typecheck["type"]
switch eventType { switch eventType {
case events.MessageSent: case models.MessageSent:
s.userMessageSent(event) s.userMessageSent(event)
case events.UserNameChanged: case models.UserNameChanged:
s.userNameChanged(event) s.userNameChanged(event)
case events.UserColorChanged: case models.UserColorChanged:
s.userColorChanged(event) s.userColorChanged(event)
default: default:
log.Debugln(logSanitize(fmt.Sprint(eventType)), "event not found:", logSanitize(fmt.Sprint(typecheck))) log.Debugln(logSanitize(fmt.Sprint(eventType)), "event not found:", logSanitize(fmt.Sprint(typecheck)))
@ -370,8 +348,9 @@ func (s *Server) eventReceived(event chatClientEvent) {
func (s *Server) sendWelcomeMessageToClient(c *Client) { func (s *Server) sendWelcomeMessageToClient(c *Client) {
// Add an artificial delay so people notice this message come in. // Add an artificial delay so people notice this message come in.
time.Sleep(7 * time.Second) time.Sleep(7 * time.Second)
cr := configrepository.Get()
welcomeMessage := utils.RenderSimpleMarkdown(configRepository.GetServerWelcomeMessage()) welcomeMessage := utils.RenderSimpleMarkdown(cr.GetServerWelcomeMessage())
if welcomeMessage != "" { if welcomeMessage != "" {
s.sendSystemMessageToClient(c, welcomeMessage) s.sendSystemMessageToClient(c, welcomeMessage)
@ -379,39 +358,42 @@ func (s *Server) sendWelcomeMessageToClient(c *Client) {
} }
func (s *Server) sendAllWelcomeMessage() { func (s *Server) sendAllWelcomeMessage() {
welcomeMessage := utils.RenderSimpleMarkdown(configRepository.GetServerWelcomeMessage()) cr := configrepository.Get()
welcomeMessage := utils.RenderSimpleMarkdown(cr.GetServerWelcomeMessage())
if welcomeMessage != "" { if welcomeMessage != "" {
clientMessage := events.SystemMessageEvent{ clientMessage := models.SystemMessageEvent{
Event: events.Event{}, Event: models.Event{},
MessageEvent: events.MessageEvent{ MessageEvent: models.MessageEvent{
Body: welcomeMessage, Body: welcomeMessage,
}, },
} }
clientMessage.SetDefaults() clientMessage.SetDefaults()
clientMessage.DisplayName = s.configRepository.GetServerName()
_ = s.Broadcast(clientMessage.GetBroadcastPayload()) _ = s.Broadcast(clientMessage.GetBroadcastPayload())
} }
} }
func (s *Server) sendSystemMessageToClient(c *Client, message string) { func (s *Server) sendSystemMessageToClient(c *Client, message string) {
clientMessage := events.SystemMessageEvent{ clientMessage := models.SystemMessageEvent{
Event: events.Event{}, Event: models.Event{},
MessageEvent: events.MessageEvent{ MessageEvent: models.MessageEvent{
Body: message, Body: message,
}, },
} }
clientMessage.SetDefaults() clientMessage.SetDefaults()
clientMessage.RenderBody() clientMessage.RenderBody()
clientMessage.DisplayName = s.configRepository.GetServerName()
s.Send(clientMessage.GetBroadcastPayload(), c) s.Send(clientMessage.GetBroadcastPayload(), c)
} }
func (s *Server) sendActionToClient(c *Client, message string) { func (s *Server) sendActionToClient(c *Client, message string) {
clientMessage := events.ActionEvent{ clientMessage := models.ActionEvent{
MessageEvent: events.MessageEvent{ MessageEvent: models.MessageEvent{
Body: message, Body: message,
}, },
Event: events.Event{ Event: models.Event{
Type: events.ChatActionSent, Type: models.ChatActionSent,
}, },
} }
clientMessage.SetDefaults() clientMessage.SetDefaults()

11
services/config/config.go

@ -44,7 +44,7 @@ type Config struct {
} }
// NewFediAuth creates a new FediAuth instance. // NewFediAuth creates a new FediAuth instance.
func NewConfig() *Config { func New() *Config {
// Default config values. // Default config values.
c := &Config{ c := &Config{
DatabaseFilePath: "data/owncast.db", DatabaseFilePath: "data/owncast.db",
@ -71,9 +71,9 @@ var temporaryGlobalInstance *Config
// GetConfig returns the temporary global instance. // GetConfig returns the temporary global instance.
// Remove this after dependency injection is implemented. // Remove this after dependency injection is implemented.
func GetConfig() *Config { func Get() *Config {
if temporaryGlobalInstance == nil { if temporaryGlobalInstance == nil {
temporaryGlobalInstance = NewConfig() temporaryGlobalInstance = New()
} }
return temporaryGlobalInstance return temporaryGlobalInstance
@ -104,3 +104,8 @@ func (c *Config) GetReleaseString() string {
return fmt.Sprintf("Owncast v%s-%s (%s)", versionNumber, buildPlatform, gitCommit) return fmt.Sprintf("Owncast v%s-%s (%s)", versionNumber, buildPlatform, gitCommit)
} }
// GetTranscoderLogFilePath returns the logging path for the transcoder log output.
func (c *Config) GetTranscoderLogFilePath() string {
return filepath.Join(c.LogDirectory, "transcoder.log")
}

17
services/metrics/healthOverview.go

@ -6,6 +6,7 @@ import (
"github.com/owncast/owncast/core" "github.com/owncast/owncast/core"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/services/status"
"github.com/owncast/owncast/storage/configrepository" "github.com/owncast/owncast/storage/configrepository"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
) )
@ -16,8 +17,6 @@ const (
minClientCountForDetails = 3 minClientCountForDetails = 3
) )
var configRepository = configrepository.Get()
// GetStreamHealthOverview will return the stream health overview. // GetStreamHealthOverview will return the stream health overview.
func (m *Metrics) GetStreamHealthOverview() *models.StreamHealthOverview { func (m *Metrics) GetStreamHealthOverview() *models.StreamHealthOverview {
return m.metrics.streamHealthOverview return m.metrics.streamHealthOverview
@ -71,6 +70,8 @@ func (m *Metrics) networkSpeedHealthOverviewMessage() string {
bitrate int bitrate int
} }
configRepository := configrepository.Get()
outputVariants := configRepository.GetStreamOutputVariants() outputVariants := configRepository.GetStreamOutputVariants()
streamSortVariants := make([]singleVariant, len(outputVariants)) streamSortVariants := make([]singleVariant, len(outputVariants))
@ -137,12 +138,14 @@ func (m *Metrics) wastefulBitrateOverviewMessage() string {
return "" return ""
} }
currentBroadcast := core.GetCurrentBroadcast() stat := status.Get()
currentBroadcast := stat.GetCurrentBroadcast()
if currentBroadcast == nil { if currentBroadcast == nil {
return "" return ""
} }
currentBroadcaster := core.GetBroadcaster() currentBroadcaster := stat.GetBroadcaster()
if currentBroadcast == nil { if currentBroadcast == nil {
return "" return ""
} }
@ -156,6 +159,7 @@ func (m *Metrics) wastefulBitrateOverviewMessage() string {
if inboundBitrate == 0 { if inboundBitrate == 0 {
return "" return ""
} }
configRepository := configrepository.Get()
outputVariants := configRepository.GetStreamOutputVariants() outputVariants := configRepository.GetStreamOutputVariants()
@ -230,6 +234,8 @@ func (m *Metrics) errorCountHealthOverviewMessage() string {
if totalNumberOfClients >= minClientCountForDetails { if totalNumberOfClients >= minClientCountForDetails {
healthyPercentage := utils.IntPercentage(clientsWithErrors, totalNumberOfClients) healthyPercentage := utils.IntPercentage(clientsWithErrors, totalNumberOfClients)
configRepository := configrepository.Get()
isUsingPassthrough := false isUsingPassthrough := false
outputVariants := configRepository.GetStreamOutputVariants() outputVariants := configRepository.GetStreamOutputVariants()
for _, variant := range outputVariants { for _, variant := range outputVariants {
@ -242,7 +248,8 @@ func (m *Metrics) errorCountHealthOverviewMessage() string {
return fmt.Sprintf("%d of %d viewers (%d%%) are experiencing errors. You're currently using a video passthrough output, often known for causing playback issues for people. It is suggested you turn it off.", clientsWithErrors, totalNumberOfClients, healthyPercentage) return fmt.Sprintf("%d of %d viewers (%d%%) are experiencing errors. You're currently using a video passthrough output, often known for causing playback issues for people. It is suggested you turn it off.", clientsWithErrors, totalNumberOfClients, healthyPercentage)
} }
currentBroadcast := core.GetCurrentBroadcast() stat := status.Get()
currentBroadcast := stat.GetCurrentBroadcast()
if currentBroadcast != nil && currentBroadcast.LatencyLevel.SecondsPerSegment < 3 { if currentBroadcast != nil && currentBroadcast.LatencyLevel.SecondsPerSegment < 3 {
return fmt.Sprintf("%d of %d viewers (%d%%) may be experiencing some issues. You may want to increase your latency buffer level in your video configuration to see if it helps.", clientsWithErrors, totalNumberOfClients, healthyPercentage) return fmt.Sprintf("%d of %d viewers (%d%%) may be experiencing some issues. You may want to increase your latency buffer level in your video configuration to see if it helps.", clientsWithErrors, totalNumberOfClients, healthyPercentage)
} }

9
services/metrics/metrics.go

@ -5,7 +5,9 @@ import (
"time" "time"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/services/chat"
"github.com/owncast/owncast/services/config" "github.com/owncast/owncast/services/config"
"github.com/owncast/owncast/storage/configrepository"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
) )
@ -26,6 +28,8 @@ type Metrics struct {
chatUserCount prometheus.Gauge chatUserCount prometheus.Gauge
currentChatMessageCount prometheus.Gauge currentChatMessageCount prometheus.Gauge
playbackErrorCount prometheus.Gauge playbackErrorCount prometheus.Gauge
chatService *chat.Chat
} }
// How often we poll for updates. // How often we poll for updates.
@ -76,6 +80,7 @@ func New() *Metrics {
windowedBandwidths: map[string]float64{}, windowedBandwidths: map[string]float64{},
windowedLatencies: map[string]float64{}, windowedLatencies: map[string]float64{},
windowedDownloadDurations: map[string]float64{}, windowedDownloadDurations: map[string]float64{},
chatService: chat.Get(),
} }
} }
@ -84,12 +89,14 @@ func New() *Metrics {
// Start will begin the metrics collection and alerting. // Start will begin the metrics collection and alerting.
func (m *Metrics) Start(getStatus func() models.Status) { func (m *Metrics) Start(getStatus func() models.Status) {
m.getStatus = getStatus m.getStatus = getStatus
configRepository := configrepository.Get()
host := configRepository.GetServerURL() host := configRepository.GetServerURL()
if host == "" { if host == "" {
host = "unknown" host = "unknown"
} }
c := config.GetConfig() c := config.Get()
m.labels = map[string]string{ m.labels = map[string]string{
"version": c.VersionNumber, "version": c.VersionNumber,

5
services/metrics/playback.go

@ -6,6 +6,7 @@ import (
"github.com/owncast/owncast/core" "github.com/owncast/owncast/core"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/services/status"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
) )
@ -13,8 +14,10 @@ func (m *Metrics) handlePlaybackPolling() {
m.metrics.m.Lock() m.metrics.m.Lock()
defer m.metrics.m.Unlock() defer m.metrics.m.Unlock()
s := status.Get()
// Make sure this is fired first before all the values get cleared below. // Make sure this is fired first before all the values get cleared below.
if m.getStatus().Online { if s.Online {
m.generateStreamHealthOverview() m.generateStreamHealthOverview()
} }

13
services/metrics/viewers.go

@ -4,9 +4,8 @@ import (
"time" "time"
"github.com/nakabonne/tstorage" "github.com/nakabonne/tstorage"
"github.com/owncast/owncast/core"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/services/status"
"github.com/owncast/owncast/storage/chatrepository" "github.com/owncast/owncast/storage/chatrepository"
"github.com/owncast/owncast/storage/userrepository" "github.com/owncast/owncast/storage/userrepository"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -30,12 +29,14 @@ func (m *Metrics) startViewerCollectionMetrics() {
} }
func (m *Metrics) collectViewerCount() { func (m *Metrics) collectViewerCount() {
s := status.Get()
// Don't collect metrics for viewers if there's no stream active. // Don't collect metrics for viewers if there's no stream active.
if !core.GetStatus().Online { if !s.Online {
return return
} }
count := core.GetStatus().ViewerCount count := s.ViewerCount
// Save active viewer count to our Prometheus collector. // Save active viewer count to our Prometheus collector.
m.activeViewerCount.Set(float64(count)) m.activeViewerCount.Set(float64(count))
@ -52,9 +53,9 @@ func (m *Metrics) collectViewerCount() {
} }
func (m *Metrics) collectChatClientCount() { func (m *Metrics) collectChatClientCount() {
count := len(chat.GetClients()) count := len(m.chatService.GetClients())
m.activeChatClientCount.Set(float64(count)) m.activeChatClientCount.Set(float64(count))
chatRepository := chatrepository.GetChatRepository() chatRepository := chatrepository.Get()
usersRepository := userrepository.Get() usersRepository := userrepository.Get()
// Total message count // Total message count

7
services/notifications/notifications.go

@ -22,12 +22,15 @@ type Notifier struct {
} }
var ( var (
configRepository = configrepository.Get() configRepository *configrepository.SqlConfigRepository
notificationsRepository = notificationsrepository.Get() notificationsRepository *notificationsrepository.SqlNotificationsRepository
) )
// Setup will perform any pre-use setup for the notifier. // Setup will perform any pre-use setup for the notifier.
func Setup(datastore *data.Store) { func Setup(datastore *data.Store) {
configRepository = configrepository.Get()
notificationsRepository = notificationsrepository.Get()
initializeBrowserPushIfNeeded() initializeBrowserPushIfNeeded()
} }

61
services/status/status.go

@ -0,0 +1,61 @@
package status
import (
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/storage/configrepository"
)
type Status struct {
models.Stats
models.Status
broadcast *models.CurrentBroadcast
broadcaster *models.Broadcaster
StreamConnected bool
VersionNumber string `json:"versionNumber"`
StreamTitle string `json:"streamTitle"`
ViewerCount int `json:"viewerCount"`
OverallMaxViewerCount int `json:"overallMaxViewerCount"`
SessionMaxViewerCount int `json:"sessionMaxViewerCount"`
Online bool `json:"online"`
}
var temporaryGlobalInstance *Status
func New() *Status {
configRepository := configrepository.Get()
return &Status{
StreamTitle: configRepository.GetStreamTitle(),
}
}
// Get will return the global instance of the status service.
func Get() *Status {
if temporaryGlobalInstance == nil {
temporaryGlobalInstance = &Status{}
}
return temporaryGlobalInstance
}
// GetCurrentBroadcast will return the currently active broadcast.
func (s *Status) GetCurrentBroadcast() *models.CurrentBroadcast {
return s.broadcast
}
func (s *Status) SetCurrentBroadcast(broadcast *models.CurrentBroadcast) {
s.broadcast = broadcast
}
// SetBroadcaster will store the current inbound broadcasting details.
func (s *Status) SetBroadcaster(broadcaster *models.Broadcaster) {
s.broadcaster = broadcaster
}
// GetBroadcaster will return the details of the currently active broadcaster.
func (s *Status) GetBroadcaster() *models.Broadcaster {
return s.broadcaster
}

9
services/webhooks/chat.go

@ -1,12 +1,11 @@
package webhooks package webhooks
import ( import (
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
) )
// SendChatEvent will send a chat event to webhook destinations. // SendChatEvent will send a chat event to webhook destinations.
func (w *LiveWebhookManager) SendChatEvent(chatEvent *events.UserMessageEvent) { func (w *LiveWebhookManager) SendChatEvent(chatEvent *models.UserMessageEvent) {
webhookEvent := WebhookEvent{ webhookEvent := WebhookEvent{
Type: chatEvent.GetMessageType(), Type: chatEvent.GetMessageType(),
EventData: &WebhookChatMessage{ EventData: &WebhookChatMessage{
@ -24,7 +23,7 @@ func (w *LiveWebhookManager) SendChatEvent(chatEvent *events.UserMessageEvent) {
} }
// SendChatEventUsernameChanged will send a username changed event to webhook destinations. // SendChatEventUsernameChanged will send a username changed event to webhook destinations.
func (w *LiveWebhookManager) SendChatEventUsernameChanged(event events.NameChangeEvent) { func (w *LiveWebhookManager) SendChatEventUsernameChanged(event models.NameChangeEvent) {
webhookEvent := WebhookEvent{ webhookEvent := WebhookEvent{
Type: models.UserNameChanged, Type: models.UserNameChanged,
EventData: event, EventData: event,
@ -34,7 +33,7 @@ func (w *LiveWebhookManager) SendChatEventUsernameChanged(event events.NameChang
} }
// SendChatEventUserJoined sends a webhook notifying that a user has joined. // SendChatEventUserJoined sends a webhook notifying that a user has joined.
func (w *LiveWebhookManager) SendChatEventUserJoined(event events.UserJoinedEvent) { func (w *LiveWebhookManager) SendChatEventUserJoined(event models.UserJoinedEvent) {
webhookEvent := WebhookEvent{ webhookEvent := WebhookEvent{
Type: models.UserJoined, Type: models.UserJoined,
EventData: event, EventData: event,
@ -45,7 +44,7 @@ func (w *LiveWebhookManager) SendChatEventUserJoined(event events.UserJoinedEven
// SendChatEventSetMessageVisibility sends a webhook notifying that the visibility of one or more // SendChatEventSetMessageVisibility sends a webhook notifying that the visibility of one or more
// messages has changed. // messages has changed.
func (w *LiveWebhookManager) SendChatEventSetMessageVisibility(event events.SetMessageVisibilityEvent) { func (w *LiveWebhookManager) SendChatEventSetMessageVisibility(event models.SetMessageVisibilityEvent) {
webhookEvent := WebhookEvent{ webhookEvent := WebhookEvent{
Type: models.VisibiltyToggled, Type: models.VisibiltyToggled,
EventData: event, EventData: event,

42
services/webhooks/chat_test.go

@ -4,14 +4,12 @@ import (
"testing" "testing"
"time" "time"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
) )
func TestSendChatEvent(t *testing.T) { func TestSendChatEvent(t *testing.T) {
timestamp := time.Unix(72, 6).UTC() timestamp := time.Unix(72, 6).UTC()
user := user.User{ user := models.User{
ID: "user id", ID: "user id",
DisplayName: "display name", DisplayName: "display name",
DisplayColor: 4, DisplayColor: 4,
@ -26,18 +24,18 @@ func TestSendChatEvent(t *testing.T) {
} }
checkPayload(t, models.MessageSent, func() { checkPayload(t, models.MessageSent, func() {
manager.SendChatEvent(&events.UserMessageEvent{ manager.SendChatEvent(&models.UserMessageEvent{
Event: events.Event{ Event: models.Event{
Type: events.MessageSent, Type: models.MessageSent,
ID: "id", ID: "id",
Timestamp: timestamp, Timestamp: timestamp,
}, },
UserEvent: events.UserEvent{ UserEvent: models.UserEvent{
User: &user, User: &user,
ClientID: 51, ClientID: 51,
HiddenAt: nil, HiddenAt: nil,
}, },
MessageEvent: events.MessageEvent{ MessageEvent: models.MessageEvent{
OutboundEvent: nil, OutboundEvent: nil,
Body: "body", Body: "body",
RawBody: "raw body", RawBody: "raw body",
@ -64,7 +62,7 @@ func TestSendChatEvent(t *testing.T) {
func TestSendChatEventUsernameChanged(t *testing.T) { func TestSendChatEventUsernameChanged(t *testing.T) {
timestamp := time.Unix(72, 6).UTC() timestamp := time.Unix(72, 6).UTC()
user := user.User{ user := models.User{
ID: "user id", ID: "user id",
DisplayName: "display name", DisplayName: "display name",
DisplayColor: 4, DisplayColor: 4,
@ -79,13 +77,13 @@ func TestSendChatEventUsernameChanged(t *testing.T) {
} }
checkPayload(t, models.UserNameChanged, func() { checkPayload(t, models.UserNameChanged, func() {
manager.SendChatEventUsernameChanged(events.NameChangeEvent{ manager.SendChatEventUsernameChanged(models.NameChangeEvent{
Event: events.Event{ Event: models.Event{
Type: events.UserNameChanged, Type: models.UserNameChanged,
ID: "id", ID: "id",
Timestamp: timestamp, Timestamp: timestamp,
}, },
UserEvent: events.UserEvent{ UserEvent: models.UserEvent{
User: &user, User: &user,
ClientID: 51, ClientID: 51,
HiddenAt: nil, HiddenAt: nil,
@ -112,7 +110,7 @@ func TestSendChatEventUsernameChanged(t *testing.T) {
func TestSendChatEventUserJoined(t *testing.T) { func TestSendChatEventUserJoined(t *testing.T) {
timestamp := time.Unix(72, 6).UTC() timestamp := time.Unix(72, 6).UTC()
user := user.User{ user := models.User{
ID: "user id", ID: "user id",
DisplayName: "display name", DisplayName: "display name",
DisplayColor: 4, DisplayColor: 4,
@ -127,13 +125,13 @@ func TestSendChatEventUserJoined(t *testing.T) {
} }
checkPayload(t, models.UserJoined, func() { checkPayload(t, models.UserJoined, func() {
manager.SendChatEventUserJoined(events.UserJoinedEvent{ manager.SendChatEventUserJoined(models.UserJoinedEvent{
Event: events.Event{ Event: models.Event{
Type: events.UserJoined, Type: models.UserJoined,
ID: "id", ID: "id",
Timestamp: timestamp, Timestamp: timestamp,
}, },
UserEvent: events.UserEvent{ UserEvent: models.UserEvent{
User: &user, User: &user,
ClientID: 51, ClientID: 51,
HiddenAt: nil, HiddenAt: nil,
@ -160,13 +158,13 @@ func TestSendChatEventSetMessageVisibility(t *testing.T) {
timestamp := time.Unix(72, 6).UTC() timestamp := time.Unix(72, 6).UTC()
checkPayload(t, models.VisibiltyToggled, func() { checkPayload(t, models.VisibiltyToggled, func() {
manager.SendChatEventSetMessageVisibility(events.SetMessageVisibilityEvent{ manager.SendChatEventSetMessageVisibility(models.SetMessageVisibilityEvent{
Event: events.Event{ Event: models.Event{
Type: events.VisibiltyUpdate, Type: models.VisibiltyUpdate,
ID: "id", ID: "id",
Timestamp: timestamp, Timestamp: timestamp,
}, },
UserMessageEvent: events.UserMessageEvent{}, UserMessageEvent: models.UserMessageEvent{},
MessageIDs: []string{"message1", "message2"}, MessageIDs: []string{"message1", "message2"},
Visible: false, Visible: false,
}) })

6
services/webhooks/manager.go

@ -10,11 +10,11 @@ type Manager interface {
// to be sent out to all registered webhook destinations. // to be sent out to all registered webhook destinations.
type LiveWebhookManager struct { type LiveWebhookManager struct {
queue chan Job queue chan Job
getStatus func() models.Status getStatus func() *models.Status
} }
// New creates a new webhook manager. // New creates a new webhook manager.
func New(getStatusFunc func() models.Status) *LiveWebhookManager { func New(getStatusFunc func() *models.Status) *LiveWebhookManager {
m := &LiveWebhookManager{ m := &LiveWebhookManager{
getStatus: getStatusFunc, getStatus: getStatusFunc,
} }
@ -24,7 +24,7 @@ func New(getStatusFunc func() models.Status) *LiveWebhookManager {
// InitTemporarySingleton initializes the the temporary global instance of the webhook manager // InitTemporarySingleton initializes the the temporary global instance of the webhook manager
// to be deleted once dependency injection is implemented. // to be deleted once dependency injection is implemented.
func InitTemporarySingleton(getStatusFunc func() models.Status) { func InitTemporarySingleton(getStatusFunc func() *models.Status) {
temporaryGlobalInstance = New(getStatusFunc) temporaryGlobalInstance = New(getStatusFunc)
} }

4
services/webhooks/stream.go

@ -8,14 +8,14 @@ import (
"github.com/teris-io/shortid" "github.com/teris-io/shortid"
) )
var configRepository = configrepository.Get()
// SendStreamStatusEvent will send all webhook destinations the current stream status. // SendStreamStatusEvent will send all webhook destinations the current stream status.
func (w *LiveWebhookManager) SendStreamStatusEvent(eventType models.EventType) { func (w *LiveWebhookManager) SendStreamStatusEvent(eventType models.EventType) {
w.sendStreamStatusEvent(eventType, shortid.MustGenerate(), time.Now()) w.sendStreamStatusEvent(eventType, shortid.MustGenerate(), time.Now())
} }
func (w *LiveWebhookManager) sendStreamStatusEvent(eventType models.EventType, id string, timestamp time.Time) { func (w *LiveWebhookManager) sendStreamStatusEvent(eventType models.EventType, id string, timestamp time.Time) {
configRepository := configrepository.Get()
w.SendEventToWebhooks(WebhookEvent{ w.SendEventToWebhooks(WebhookEvent{
Type: eventType, Type: eventType,
EventData: map[string]interface{}{ EventData: map[string]interface{}{

6
services/webhooks/stream_test.go

@ -4,17 +4,19 @@ import (
"testing" "testing"
"time" "time"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/storage/configrepository"
) )
func TestSendStreamStatusEvent(t *testing.T) { func TestSendStreamStatusEvent(t *testing.T) {
configRepository := configrepository.Get()
configRepository.SetServerName("my server") configRepository.SetServerName("my server")
configRepository.SetServerSummary("my server where I stream") configRepository.SetServerSummary("my server where I stream")
configRepository.SetStreamTitle("my stream") configRepository.SetStreamTitle("my stream")
checkPayload(t, models.StreamStarted, func() { checkPayload(t, models.StreamStarted, func() {
manager.sendStreamStatusEvent(events.StreamStarted, "id", time.Unix(72, 6).UTC()) manager.sendStreamStatusEvent(models.StreamStarted, "id", time.Unix(72, 6).UTC())
}, `{ }, `{
"id": "id", "id": "id",
"name": "my server", "name": "my server",

3
services/webhooks/webhooks.go

@ -25,14 +25,13 @@ type WebhookChatMessage struct {
Visible bool `json:"visible"` Visible bool `json:"visible"`
} }
var webhookRepository = storage.GetWebhookRepository()
// SendEventToWebhooks will send a single webhook event to all webhook destinations. // SendEventToWebhooks will send a single webhook event to all webhook destinations.
func (w *LiveWebhookManager) SendEventToWebhooks(payload WebhookEvent) { func (w *LiveWebhookManager) SendEventToWebhooks(payload WebhookEvent) {
w.sendEventToWebhooks(payload, nil) w.sendEventToWebhooks(payload, nil)
} }
func (w *LiveWebhookManager) sendEventToWebhooks(payload WebhookEvent, wg *sync.WaitGroup) { func (w *LiveWebhookManager) sendEventToWebhooks(payload WebhookEvent, wg *sync.WaitGroup) {
webhookRepository := storage.GetWebhookRepository()
webhooks := webhookRepository.GetWebhooksForEvent(payload.Type) webhooks := webhookRepository.GetWebhooksForEvent(payload.Type)
for _, webhook := range webhooks { for _, webhook := range webhooks {

27
services/webhooks/webhooks_test.go

@ -6,20 +6,21 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/storage"
"github.com/owncast/owncast/storage/data"
jsonpatch "gopkg.in/evanphx/json-patch.v5" jsonpatch "gopkg.in/evanphx/json-patch.v5"
) )
var manager *LiveWebhookManager var manager *LiveWebhookManager
func fakeGetStatus() models.Status { func fakeGetStatus() *models.Status {
return models.Status{ return &models.Status{
Online: true, Online: true,
ViewerCount: 5, ViewerCount: 5,
OverallMaxViewerCount: 420, OverallMaxViewerCount: 420,
@ -30,16 +31,10 @@ func fakeGetStatus() models.Status {
} }
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
dbFile, err := os.CreateTemp(os.TempDir(), "owncast-test-db.db") _, err := data.NewStore(":memory:")
if err != nil { if err != nil {
panic(err) panic(err)
} }
dbFile.Close()
defer os.Remove(dbFile.Name())
if err := data.SetupPersistence(dbFile.Name()); err != nil {
panic(err)
}
InitTemporarySingleton(fakeGetStatus) InitTemporarySingleton(fakeGetStatus)
manager = Get() manager = Get()
@ -51,6 +46,8 @@ func TestMain(m *testing.M) {
// Because the other tests use `sendEventToWebhooks` with a `WaitGroup` to know when the test completes, // Because the other tests use `sendEventToWebhooks` with a `WaitGroup` to know when the test completes,
// this test ensures that `SendToWebhooks` without a `WaitGroup` doesn't panic. // this test ensures that `SendToWebhooks` without a `WaitGroup` doesn't panic.
func TestPublicSend(t *testing.T) { func TestPublicSend(t *testing.T) {
webhookRepository := storage.GetWebhookRepository()
// Send enough events to be sure at least one worker delivers a second event. // Send enough events to be sure at least one worker delivers a second event.
eventsCount := webhookWorkerPoolSize + 1 eventsCount := webhookWorkerPoolSize + 1
@ -85,6 +82,8 @@ func TestPublicSend(t *testing.T) {
// Make sure that events are only sent to interested endpoints. // Make sure that events are only sent to interested endpoints.
func TestRouting(t *testing.T) { func TestRouting(t *testing.T) {
webhookRepository := storage.GetWebhookRepository()
eventTypes := []models.EventType{models.ChatActionSent, models.UserJoined} eventTypes := []models.EventType{models.ChatActionSent, models.UserJoined}
calls := map[models.EventType]int{} calls := map[models.EventType]int{}
@ -140,6 +139,8 @@ func TestRouting(t *testing.T) {
// Make sure that events are sent to all interested endpoints. // Make sure that events are sent to all interested endpoints.
func TestMultiple(t *testing.T) { func TestMultiple(t *testing.T) {
webhookRepository := storage.GetWebhookRepository()
const times = 2 const times = 2
var calls uint32 var calls uint32
@ -177,6 +178,8 @@ func TestMultiple(t *testing.T) {
// Make sure when a webhook is used its last used timestamp is updated. // Make sure when a webhook is used its last used timestamp is updated.
func TestTimestamps(t *testing.T) { func TestTimestamps(t *testing.T) {
webhookRepository := storage.GetWebhookRepository()
const tolerance = time.Second const tolerance = time.Second
start := time.Now() start := time.Now()
eventTypes := []models.EventType{models.StreamStarted, models.StreamStopped} eventTypes := []models.EventType{models.StreamStarted, models.StreamStopped}
@ -258,6 +261,8 @@ func TestTimestamps(t *testing.T) {
// Make sure up to the expected number of events can be fired in parallel. // Make sure up to the expected number of events can be fired in parallel.
func TestParallel(t *testing.T) { func TestParallel(t *testing.T) {
webhookRepository := storage.GetWebhookRepository()
var calls uint32 var calls uint32
var wgStart sync.WaitGroup var wgStart sync.WaitGroup
@ -310,6 +315,8 @@ func TestParallel(t *testing.T) {
// Send an event, capture it, and verify that it has the expected payload. // Send an event, capture it, and verify that it has the expected payload.
func checkPayload(t *testing.T, eventType models.EventType, send func(), expectedJson string) { func checkPayload(t *testing.T, eventType models.EventType, send func(), expectedJson string) {
webhookRepository := storage.GetWebhookRepository()
eventChannel := make(chan WebhookEvent) eventChannel := make(chan WebhookEvent)
// Set up a server. // Set up a server.

3
services/webhooks/workerpool.go

@ -10,6 +10,7 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/storage"
) )
// webhookWorkerPoolSize defines the number of concurrent HTTP webhook requests. // webhookWorkerPoolSize defines the number of concurrent HTTP webhook requests.
@ -75,6 +76,8 @@ func (w *LiveWebhookManager) sendWebhook(job Job) error {
defer resp.Body.Close() defer resp.Body.Close()
webhookRepository := storage.GetWebhookRepository()
if err := webhookRepository.SetWebhookAsUsed(job.webhook); err != nil { if err := webhookRepository.SetWebhookAsUsed(job.webhook); err != nil {
log.Warnln(err) log.Warnln(err)
} }

8
services/yp/yp.go

@ -18,12 +18,10 @@ import (
const pingInterval = 4 * time.Minute const pingInterval = 4 * time.Minute
var ( var (
getStatus func() models.Status getStatus func() *models.Status
_inErrorState = false _inErrorState = false
) )
var configRepository = configrepository.Get()
// YP is a service for handling listing in the Owncast directory. // YP is a service for handling listing in the Owncast directory.
type YP struct { type YP struct {
timer *time.Ticker timer *time.Ticker
@ -42,7 +40,7 @@ type ypPingRequest struct {
} }
// NewYP creates a new instance of the YP service handler. // NewYP creates a new instance of the YP service handler.
func NewYP(getStatusFunc func() models.Status) *YP { func NewYP(getStatusFunc func() *models.Status) *YP {
getStatus = getStatusFunc getStatus = getStatusFunc
return &YP{} return &YP{}
} }
@ -63,6 +61,8 @@ func (yp *YP) Stop() {
} }
func (yp *YP) ping() { func (yp *YP) ping() {
configRepository := configrepository.Get()
if !configRepository.GetDirectoryEnabled() { if !configRepository.GetDirectoryEnabled() {
return return
} }

89
static/emoji.go vendored

@ -1,8 +1,6 @@
package static package static
import ( import (
"fmt"
"io"
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
@ -11,8 +9,6 @@ import (
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/services/config" "github.com/owncast/owncast/services/config"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@ -88,88 +84,3 @@ func GetEmojiList() []models.CustomEmoji {
return emojiData return emojiData
} }
// SetupEmojiDirectory sets up the custom emoji directory by copying all built-in
// emojis if the directory does not yet exist.
func SetupEmojiDirectory() (err error) {
type emojiDirectory struct {
path string
isDir bool
}
c := config.GetConfig()
if utils.DoesFileExists(c.CustomEmojiPath) {
return nil
}
if err = os.MkdirAll(c.CustomEmojiPath, 0o750); err != nil {
return fmt.Errorf("unable to create custom emoji directory: %w", err)
}
staticFS := GetEmoji()
files := []emojiDirectory{}
walkFunction := func(path string, d os.DirEntry, err error) error {
if path == "." {
return nil
}
if d.Name() == "LICENSE.md" {
return nil
}
files = append(files, emojiDirectory{path: path, isDir: d.IsDir()})
return nil
}
if err := fs.WalkDir(staticFS, ".", walkFunction); err != nil {
log.Errorln("unable to fetch emojis: " + err.Error())
return errors.Wrap(err, "unable to fetch embedded emoji files")
}
if err != nil {
return fmt.Errorf("unable to read built-in emoji files: %w", err)
}
// Now copy all built-in emojis to the custom emoji directory
for _, path := range files {
emojiPath := filepath.Join(c.CustomEmojiPath, path.path)
if path.isDir {
if err := os.Mkdir(emojiPath, 0o700); err != nil {
return errors.Wrap(err, "unable to create emoji directory, check permissions?: "+path.path)
}
continue
}
memFile, staticOpenErr := staticFS.Open(path.path)
if staticOpenErr != nil {
return errors.Wrap(staticOpenErr, "unable to open emoji file from embedded filesystem")
}
// nolint:gosec
diskFile, err := os.Create(emojiPath)
if err != nil {
return fmt.Errorf("unable to create custom emoji file on disk: %w", err)
}
if err != nil {
_ = diskFile.Close()
return fmt.Errorf("unable to open built-in emoji file: %w", err)
}
if _, err = io.Copy(diskFile, memFile); err != nil {
_ = diskFile.Close()
_ = os.Remove(emojiPath)
return fmt.Errorf("unable to copy built-in emoji file to disk: %w", err)
}
if err = diskFile.Close(); err != nil {
_ = os.Remove(emojiPath)
return fmt.Errorf("unable to close custom emoji file on disk: %w", err)
}
}
return nil
}

4
storage/chatrepository/chatrepository.go

@ -11,6 +11,8 @@ func New(datastore *data.Store) *ChatRepository {
datastore: datastore, datastore: datastore,
} }
r.startPruner()
return r return r
} }
@ -18,7 +20,7 @@ func New(datastore *data.Store) *ChatRepository {
var temporaryGlobalInstance *ChatRepository var temporaryGlobalInstance *ChatRepository
// GetUserRepository will return the user repository. // GetUserRepository will return the user repository.
func GetChatRepository() *ChatRepository { func Get() *ChatRepository {
if temporaryGlobalInstance == nil { if temporaryGlobalInstance == nil {
i := New(data.GetDatastore()) i := New(data.GetDatastore())
temporaryGlobalInstance = i temporaryGlobalInstance = i

133
core/chat/persistence.go → storage/chatrepository/persistence.go

@ -1,4 +1,4 @@
package chat package chatrepository
import ( import (
"context" "context"
@ -6,7 +6,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/storage/data" "github.com/owncast/owncast/storage/data"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -14,36 +13,17 @@ import (
var _datastore *data.Store var _datastore *data.Store
const (
maxBacklogHours = 2 // Keep backlog max hours worth of messages
maxBacklogNumber = 50 // Return max number of messages in history request
)
func setupPersistence() {
_datastore = data.GetDatastore()
data.CreateMessagesTable(_datastore.DB)
data.CreateBanIPTable(_datastore.DB)
chatDataPruner := time.NewTicker(5 * time.Minute)
go func() {
runPruner()
for range chatDataPruner.C {
runPruner()
}
}()
}
// SaveUserMessage will save a single chat event to the messages database. // SaveUserMessage will save a single chat event to the messages database.
func SaveUserMessage(event events.UserMessageEvent) { func (cr *ChatRepository) SaveUserMessage(event models.UserMessageEvent) {
saveEvent(event.ID, &event.User.ID, event.Body, event.Type, event.HiddenAt, event.Timestamp, nil, nil, nil, nil) cr.SaveEvent(event.ID, &event.User.ID, event.Body, event.Type, event.HiddenAt, event.Timestamp, nil, nil, nil, nil)
} }
func saveFederatedAction(event events.FediverseEngagementEvent) { func (cr *ChatRepository) SaveFederatedAction(event models.FediverseEngagementEvent) {
saveEvent(event.ID, nil, event.Body, event.Type, nil, event.Timestamp, event.Image, &event.Link, &event.UserAccountName, nil) cr.SaveEvent(event.ID, nil, event.Body, event.Type, nil, event.Timestamp, event.Image, &event.Link, &event.UserAccountName, nil)
} }
// nolint: unparam // nolint: unparam
func saveEvent(id string, userID *string, body string, eventType string, hidden *time.Time, timestamp time.Time, image *string, link *string, title *string, subtitle *string) { func (cr *ChatRepository) SaveEvent(id string, userID *string, body string, eventType string, hidden *time.Time, timestamp time.Time, image *string, link *string, title *string, subtitle *string) {
defer func() { defer func() {
_historyCache = nil _historyCache = nil
}() }()
@ -74,7 +54,7 @@ func saveEvent(id string, userID *string, body string, eventType string, hidden
} }
} }
func makeUserMessageEventFromRowData(row rowData) events.UserMessageEvent { func (cr *ChatRepository) makeUserMessageEventFromRowData(row rowData) models.UserMessageEvent {
scopes := "" scopes := ""
if row.userScopes != nil { if row.userScopes != nil {
scopes = *row.userScopes scopes = *row.userScopes
@ -117,17 +97,17 @@ func makeUserMessageEventFromRowData(row rowData) events.UserMessageEvent {
IsBot: isBot, IsBot: isBot,
} }
message := events.UserMessageEvent{ message := models.UserMessageEvent{
Event: events.Event{ Event: models.Event{
Type: row.eventType, Type: row.eventType,
ID: row.id, ID: row.id,
Timestamp: row.timestamp, Timestamp: row.timestamp,
}, },
UserEvent: events.UserEvent{ UserEvent: models.UserEvent{
User: &u, User: &u,
HiddenAt: row.hiddenAt, HiddenAt: row.hiddenAt,
}, },
MessageEvent: events.MessageEvent{ MessageEvent: models.MessageEvent{
Body: row.body, Body: row.body,
RawBody: row.body, RawBody: row.body,
}, },
@ -136,14 +116,14 @@ func makeUserMessageEventFromRowData(row rowData) events.UserMessageEvent {
return message return message
} }
func makeSystemMessageChatEventFromRowData(row rowData) events.SystemMessageEvent { func (cr *ChatRepository) makeSystemMessageChatEventFromRowData(row rowData) models.SystemMessageEvent {
message := events.SystemMessageEvent{ message := models.SystemMessageEvent{
Event: events.Event{ Event: models.Event{
Type: row.eventType, Type: row.eventType,
ID: row.id, ID: row.id,
Timestamp: row.timestamp, Timestamp: row.timestamp,
}, },
MessageEvent: events.MessageEvent{ MessageEvent: models.MessageEvent{
Body: row.body, Body: row.body,
RawBody: row.body, RawBody: row.body,
}, },
@ -151,14 +131,14 @@ func makeSystemMessageChatEventFromRowData(row rowData) events.SystemMessageEven
return message return message
} }
func makeActionMessageChatEventFromRowData(row rowData) events.ActionEvent { func (cr *ChatRepository) makeActionMessageChatEventFromRowData(row rowData) models.ActionEvent {
message := events.ActionEvent{ message := models.ActionEvent{
Event: events.Event{ Event: models.Event{
Type: row.eventType, Type: row.eventType,
ID: row.id, ID: row.id,
Timestamp: row.timestamp, Timestamp: row.timestamp,
}, },
MessageEvent: events.MessageEvent{ MessageEvent: models.MessageEvent{
Body: row.body, Body: row.body,
RawBody: row.body, RawBody: row.body,
}, },
@ -166,14 +146,14 @@ func makeActionMessageChatEventFromRowData(row rowData) events.ActionEvent {
return message return message
} }
func makeFederatedActionChatEventFromRowData(row rowData) events.FediverseEngagementEvent { func (cr *ChatRepository) makeFederatedActionChatEventFromRowData(row rowData) models.FediverseEngagementEvent {
message := events.FediverseEngagementEvent{ message := models.FediverseEngagementEvent{
Event: events.Event{ Event: models.Event{
Type: row.eventType, Type: row.eventType,
ID: row.id, ID: row.id,
Timestamp: row.timestamp, Timestamp: row.timestamp,
}, },
MessageEvent: events.MessageEvent{ MessageEvent: models.MessageEvent{
Body: row.body, Body: row.body,
RawBody: row.body, RawBody: row.body,
}, },
@ -208,7 +188,7 @@ type rowData struct {
id string id string
} }
func getChat(rows *sql.Rows) ([]interface{}, error) { func (cr *ChatRepository) getChat(rows *sql.Rows) ([]interface{}, error) {
history := make([]interface{}, 0) history := make([]interface{}, 0)
for rows.Next() { for rows.Next() {
@ -242,18 +222,18 @@ func getChat(rows *sql.Rows) ([]interface{}, error) {
var message interface{} var message interface{}
switch row.eventType { switch row.eventType {
case events.MessageSent: case models.MessageSent:
message = makeUserMessageEventFromRowData(row) message = cr.makeUserMessageEventFromRowData(row)
case events.SystemMessageSent: case models.SystemMessageSent:
message = makeSystemMessageChatEventFromRowData(row) message = cr.makeSystemMessageChatEventFromRowData(row)
case events.ChatActionSent: case models.ChatActionSent:
message = makeActionMessageChatEventFromRowData(row) message = cr.makeActionMessageChatEventFromRowData(row)
case events.FediverseEngagementFollow: case models.FediverseEngagementFollow:
message = makeFederatedActionChatEventFromRowData(row) message = cr.makeFederatedActionChatEventFromRowData(row)
case events.FediverseEngagementLike: case models.FediverseEngagementLike:
message = makeFederatedActionChatEventFromRowData(row) message = cr.makeFederatedActionChatEventFromRowData(row)
case events.FediverseEngagementRepost: case models.FediverseEngagementRepost:
message = makeFederatedActionChatEventFromRowData(row) message = cr.makeFederatedActionChatEventFromRowData(row)
} }
history = append(history, message) history = append(history, message)
@ -265,7 +245,7 @@ func getChat(rows *sql.Rows) ([]interface{}, error) {
var _historyCache *[]interface{} var _historyCache *[]interface{}
// GetChatModerationHistory will return all the chat messages suitable for moderation purposes. // GetChatModerationHistory will return all the chat messages suitable for moderation purposes.
func GetChatModerationHistory() []interface{} { func (cr *ChatRepository) GetChatModerationHistory() []interface{} {
if _historyCache != nil { if _historyCache != nil {
return *_historyCache return *_historyCache
} }
@ -295,7 +275,7 @@ func GetChatModerationHistory() []interface{} {
defer stmt.Close() defer stmt.Close()
defer rows.Close() defer rows.Close()
result, err := getChat(rows) result, err := cr.getChat(rows)
if err != nil { if err != nil {
log.Errorln(err) log.Errorln(err)
log.Errorln("There is a problem enumerating chat message rows. Please report this:", query) log.Errorln("There is a problem enumerating chat message rows. Please report this:", query)
@ -313,7 +293,7 @@ func GetChatModerationHistory() []interface{} {
} }
// GetChatHistory will return all the chat messages suitable for returning as user-facing chat history. // GetChatHistory will return all the chat messages suitable for returning as user-facing chat history.
func GetChatHistory() []interface{} { func (cr *ChatRepository) GetChatHistory() []interface{} {
tx, err := _datastore.DB.Begin() tx, err := _datastore.DB.Begin()
if err != nil { if err != nil {
log.Errorln("error fetching chat history", err) log.Errorln("error fetching chat history", err)
@ -340,7 +320,7 @@ func GetChatHistory() []interface{} {
defer stmt.Close() defer stmt.Close()
defer rows.Close() defer rows.Close()
m, err := getChat(rows) m, err := cr.getChat(rows)
if err != nil { if err != nil {
log.Errorln(err) log.Errorln(err)
log.Errorln("There is a problem enumerating chat message rows. Please report this:", query) log.Errorln("There is a problem enumerating chat message rows. Please report this:", query)
@ -361,20 +341,20 @@ func GetChatHistory() []interface{} {
} }
// GetMessagesFromUser returns chat messages that were sent by a specific user. // GetMessagesFromUser returns chat messages that were sent by a specific user.
func GetMessagesFromUser(userID string) ([]events.UserMessageEvent, error) { func (cr *ChatRepository) GetMessagesFromUser(userID string) ([]models.UserMessageEvent, error) {
query, err := _datastore.GetQueries().GetMessagesFromUser(context.Background(), sql.NullString{String: userID, Valid: true}) query, err := _datastore.GetQueries().GetMessagesFromUser(context.Background(), sql.NullString{String: userID, Valid: true})
if err != nil { if err != nil {
return nil, err return nil, err
} }
results := make([]events.UserMessageEvent, len(query)) results := make([]models.UserMessageEvent, len(query))
for i, row := range query { for i, row := range query {
results[i] = events.UserMessageEvent{ results[i] = models.UserMessageEvent{
Event: events.Event{ Event: models.Event{
Timestamp: row.Timestamp.Time, Timestamp: row.Timestamp.Time,
ID: row.ID, ID: row.ID,
}, },
MessageEvent: events.MessageEvent{ MessageEvent: models.MessageEvent{
Body: row.Body.String, Body: row.Body.String,
}, },
} }
@ -385,7 +365,7 @@ func GetMessagesFromUser(userID string) ([]events.UserMessageEvent, error) {
// SetMessageVisibilityForUserID will bulk change the visibility of messages for a user // SetMessageVisibilityForUserID will bulk change the visibility of messages for a user
// and then send out visibility changed events to chat clients. // and then send out visibility changed events to chat clients.
func SetMessageVisibilityForUserID(userID string, visible bool) error { func (cr *ChatRepository) SetMessageVisibilityForUserID(userID string, visible bool) ([]string, error) {
defer func() { defer func() {
_historyCache = nil _historyCache = nil
}() }()
@ -393,7 +373,7 @@ func SetMessageVisibilityForUserID(userID string, visible bool) error {
tx, err := _datastore.DB.Begin() tx, err := _datastore.DB.Begin()
if err != nil { if err != nil {
log.Errorln("error while setting message visibility", err) log.Errorln("error while setting message visibility", err)
return nil return nil, err
} }
defer tx.Rollback() // nolint defer tx.Rollback() // nolint
@ -402,13 +382,13 @@ func SetMessageVisibilityForUserID(userID string, visible bool) error {
stmt, err := tx.Prepare(query) stmt, err := tx.Prepare(query)
if err != nil { if err != nil {
log.Errorln("error while setting message visibility", err) log.Errorln("error while setting message visibility", err)
return nil return nil, err
} }
rows, err := stmt.Query(userID) rows, err := stmt.Query(userID)
if err != nil { if err != nil {
log.Errorln("error while setting message visibility", err) log.Errorln("error while setting message visibility", err)
return nil return nil, err
} }
defer stmt.Close() defer stmt.Close()
@ -417,31 +397,30 @@ func SetMessageVisibilityForUserID(userID string, visible bool) error {
// Get a list of IDs to send to the connected clients to hide // Get a list of IDs to send to the connected clients to hide
ids := make([]string, 0) ids := make([]string, 0)
messages, err := getChat(rows) messages, err := cr.getChat(rows)
if err != nil { if err != nil {
log.Errorln(err) log.Errorln(err)
log.Errorln("There is a problem enumerating chat message rows. Please report this:", query) log.Errorln("There is a problem enumerating chat message rows. Please report this:", query)
return nil return nil, err
} }
if len(messages) == 0 { if len(messages) == 0 {
return nil return nil, nil
} }
for _, message := range messages { for _, message := range messages {
ids = append(ids, message.(events.UserMessageEvent).ID) ids = append(ids, message.(models.UserMessageEvent).ID)
} }
if err = tx.Commit(); err != nil { if err = tx.Commit(); err != nil {
log.Errorln("error while setting message visibility ", err) log.Errorln("error while setting message visibility ", err)
return nil return nil, nil
} }
// Tell the clients to hide/show these messages. return nil, nil
return SetMessagesVisibility(ids, visible)
} }
func saveMessageVisibility(messageIDs []string, visible bool) error { func (cr *ChatRepository) SaveMessageVisibility(messageIDs []string, visible bool) error {
defer func() { defer func() {
_historyCache = nil _historyCache = nil
}() }()

23
core/chat/pruner.go → storage/chatrepository/pruner.go

@ -1,14 +1,33 @@
package chat package chatrepository
import ( import (
"fmt" "fmt"
"time"
"github.com/owncast/owncast/storage/data"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
const (
maxBacklogHours = 2 // Keep backlog max hours worth of messages
maxBacklogNumber = 50 // Return max number of messages in history request
)
func (cr *ChatRepository) startPruner() {
chatDataPruner := time.NewTicker(5 * time.Minute)
go func() {
cr.runPruner()
for range chatDataPruner.C {
cr.runPruner()
}
}()
}
// Only keep recent messages so we don't keep more chat data than needed // Only keep recent messages so we don't keep more chat data than needed
// for privacy and efficiency reasons. // for privacy and efficiency reasons.
func runPruner() { func (cr *ChatRepository) runPruner() {
_datastore := data.GetDatastore()
_datastore.DbLock.Lock() _datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock() defer _datastore.DbLock.Unlock()

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save