summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabe Kangas <gabek@real-ity.com>2022-01-12 10:31:57 -0800
committerGabe Kangas <gabek@real-ity.com>2022-01-12 11:04:38 -0800
commita585a9785d7eb4d8664024587302f77226f62a69 (patch)
tree0c627d21632112e23928cb2919d440d37280781c
parent20409eb14db8fb14e4746a14c5d5b97b8784a9f4 (diff)
First pass at browser, discord, twilio notifications
-rw-r--r--build/javascript/package.json1
-rw-r--r--controllers/admin/notifications.go76
-rw-r--r--controllers/admin/serverConfig.go65
-rw-r--r--controllers/config.go62
-rw-r--r--controllers/notifications.go48
-rw-r--r--core/core.go10
-rw-r--r--core/data/config.go203
-rw-r--r--core/streamState.go12
-rw-r--r--db/models.go7
-rw-r--r--db/query.sql6
-rw-r--r--db/query.sql.go41
-rw-r--r--db/schema.sql7
-rw-r--r--go.mod29
-rw-r--r--go.sum50
-rw-r--r--main.go22
-rw-r--r--models/twilio.go20
-rw-r--r--notifications/browser/browser.go79
-rw-r--r--notifications/channels.go6
-rw-r--r--notifications/discord/discord.go61
-rw-r--r--notifications/notifications.go150
-rw-r--r--notifications/persistence.go51
-rw-r--r--notifications/twilio/twilio.go37
-rw-r--r--router/router.go8
-rw-r--r--webroot/img/browser-push-notifications-settings.pngbin0 -> 24216 bytes
-rw-r--r--webroot/img/notification-bell.svg2
-rw-r--r--webroot/js/app.js52
-rw-r--r--webroot/js/components/notification.js211
-rw-r--r--webroot/js/notification/registerWeb.js26
-rw-r--r--webroot/js/utils/constants.js2
-rw-r--r--webroot/js/web_modules/htm.js2
-rw-r--r--webroot/js/web_modules/import-map.json1
-rw-r--r--webroot/js/web_modules/markjs/dist/mark.es6.min.js4
-rw-r--r--webroot/js/web_modules/micromodal/dist/micromodal.min.js3
-rw-r--r--webroot/js/web_modules/preact/hooks.js5
-rw-r--r--webroot/js/web_modules/videojs/dist/video.min.js2
-rw-r--r--webroot/serviceWorker.js26
36 files changed, 1253 insertions, 134 deletions
diff --git a/build/javascript/package.json b/build/javascript/package.json
index 1c9bcbb57..d5d7e55cf 100644
--- a/build/javascript/package.json
+++ b/build/javascript/package.json
@@ -26,6 +26,7 @@
"@joeattardi/emoji-button",
"htm",
"preact",
+ "preact/hooks",
"mark.js/dist/mark.es6.min.js",
"tailwindcss/dist/tailwind.min.css",
"micromodal/dist/micromodal.min.js"
diff --git a/controllers/admin/notifications.go b/controllers/admin/notifications.go
new file mode 100644
index 000000000..d8ead438f
--- /dev/null
+++ b/controllers/admin/notifications.go
@@ -0,0 +1,76 @@
+package admin
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/owncast/owncast/controllers"
+ "github.com/owncast/owncast/core/data"
+ "github.com/owncast/owncast/models"
+)
+
+// SetDiscordNotificationConfiguration will set the discord notification configuration.
+func SetDiscordNotificationConfiguration(w http.ResponseWriter, r *http.Request) {
+ if !requirePOST(w, r) {
+ return
+ }
+
+ type request struct {
+ Value models.DiscordConfiguration `json:"value"`
+ }
+
+ decoder := json.NewDecoder(r.Body)
+ var config request
+ if err := decoder.Decode(&config); err != nil {
+ controllers.WriteSimpleResponse(w, false, "unable to update discord config with provided values")
+ return
+ }
+
+ if err := data.SetDiscordConfig(config.Value); err != nil {
+ controllers.WriteSimpleResponse(w, false, "unable to update discord config with provided values")
+ }
+}
+
+// SetBrowserNotificationConfiguration will set the browser notification configuration.
+func SetBrowserNotificationConfiguration(w http.ResponseWriter, r *http.Request) {
+ if !requirePOST(w, r) {
+ return
+ }
+
+ type request struct {
+ Value models.BrowserNotificationConfiguration `json:"value"`
+ }
+
+ decoder := json.NewDecoder(r.Body)
+ var config request
+ if err := decoder.Decode(&config); err != nil {
+ controllers.WriteSimpleResponse(w, false, "unable to update browser push config with provided values")
+ return
+ }
+
+ if err := data.SetBrowserPushConfig(config.Value); err != nil {
+ controllers.WriteSimpleResponse(w, false, "unable to update browser push config with provided values")
+ }
+}
+
+// SetTwilioNotificationConfiguration will set the twilio notification configuration.
+func SetTwilioNotificationConfiguration(w http.ResponseWriter, r *http.Request) {
+ if !requirePOST(w, r) {
+ return
+ }
+
+ type request struct {
+ Value models.TwilioConfiguration `json:"value"`
+ }
+
+ decoder := json.NewDecoder(r.Body)
+ var config request
+ if err := decoder.Decode(&config); err != nil {
+ controllers.WriteSimpleResponse(w, false, "unable to update s3 config with provided values")
+ return
+ }
+
+ if err := data.SetTwilioConfig(config.Value); err != nil {
+ controllers.WriteSimpleResponse(w, false, "unable to update twilio config with provided values")
+ }
+}
diff --git a/controllers/admin/serverConfig.go b/controllers/admin/serverConfig.go
index a408f5e63..d7f093aed 100644
--- a/controllers/admin/serverConfig.go
+++ b/controllers/admin/serverConfig.go
@@ -72,6 +72,11 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
ShowEngagement: data.GetFederationShowEngagement(),
BlockedDomains: data.GetBlockedFederatedDomains(),
},
+ Notifications: notificationsConfigResponse{
+ Discord: data.GetDiscordConfig(),
+ Twilio: data.GetTwilioConfig(),
+ Browser: data.GetBrowserPushConfig(),
+ },
}
w.Header().Set("Content-Type", "application/json")
@@ -83,21 +88,22 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
}
type serverConfigAdminResponse struct {
- InstanceDetails webConfigResponse `json:"instanceDetails"`
- FFmpegPath string `json:"ffmpegPath"`
- StreamKey string `json:"streamKey"`
- WebServerPort int `json:"webServerPort"`
- WebServerIP string `json:"webServerIP"`
- RTMPServerPort int `json:"rtmpServerPort"`
- S3 models.S3 `json:"s3"`
- VideoSettings videoSettings `json:"videoSettings"`
- YP yp `json:"yp"`
- ChatDisabled bool `json:"chatDisabled"`
- ExternalActions []models.ExternalAction `json:"externalActions"`
- SupportedCodecs []string `json:"supportedCodecs"`
- VideoCodec string `json:"videoCodec"`
- ForbiddenUsernames []string `json:"forbiddenUsernames"`
- Federation federationConfigResponse `json:"federation"`
+ InstanceDetails webConfigResponse `json:"instanceDetails"`
+ FFmpegPath string `json:"ffmpegPath"`
+ StreamKey string `json:"streamKey"`
+ WebServerPort int `json:"webServerPort"`
+ WebServerIP string `json:"webServerIP"`
+ RTMPServerPort int `json:"rtmpServerPort"`
+ S3 models.S3 `json:"s3"`
+ VideoSettings videoSettings `json:"videoSettings"`
+ YP yp `json:"yp"`
+ ChatDisabled bool `json:"chatDisabled"`
+ ExternalActions []models.ExternalAction `json:"externalActions"`
+ SupportedCodecs []string `json:"supportedCodecs"`
+ VideoCodec string `json:"videoCodec"`
+ ForbiddenUsernames []string `json:"forbiddenUsernames"`
+ Federation federationConfigResponse `json:"federation"`
+ Notifications notificationsConfigResponse `json:"notifications"`
}
type videoSettings struct {
@@ -106,17 +112,18 @@ type videoSettings struct {
}
type webConfigResponse struct {
- Name string `json:"name"`
- Summary string `json:"summary"`
- WelcomeMessage string `json:"welcomeMessage"`
- Logo string `json:"logo"`
- Tags []string `json:"tags"`
- Version string `json:"version"`
- NSFW bool `json:"nsfw"`
- ExtraPageContent string `json:"extraPageContent"`
- StreamTitle string `json:"streamTitle"` // What's going on with the current stream
- SocialHandles []models.SocialHandle `json:"socialHandles"`
- CustomStyles string `json:"customStyles"`
+ Name string `json:"name"`
+ Summary string `json:"summary"`
+ WelcomeMessage string `json:"welcomeMessage"`
+ Logo string `json:"logo"`
+ Tags []string `json:"tags"`
+ Version string `json:"version"`
+ NSFW bool `json:"nsfw"`
+ ExtraPageContent string `json:"extraPageContent"`
+ StreamTitle string `json:"streamTitle"` // What's going on with the current stream
+ SocialHandles []models.SocialHandle `json:"socialHandles"`
+ CustomStyles string `json:"customStyles"`
+ Notifications notificationsConfigResponse `json:"notifications"`
}
type yp struct {
@@ -133,3 +140,9 @@ type federationConfigResponse struct {
ShowEngagement bool `json:"showEngagement"`
BlockedDomains []string `json:"blockedDomains"`
}
+
+type notificationsConfigResponse struct {
+ Browser models.BrowserNotificationConfiguration `json:"browser"`
+ Twilio models.TwilioConfiguration `json:"twilio"`
+ Discord models.DiscordConfiguration `json:"discord"`
+}
diff --git a/controllers/config.go b/controllers/config.go
index b91152bdc..5203a1bca 100644
--- a/controllers/config.go
+++ b/controllers/config.go
@@ -12,23 +12,25 @@ import (
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/router/middleware"
"github.com/owncast/owncast/utils"
+ log "github.com/sirupsen/logrus"
)
type webConfigResponse struct {
- Name string `json:"name"`
- Summary string `json:"summary"`
- Logo string `json:"logo"`
- Tags []string `json:"tags"`
- Version string `json:"version"`
- NSFW bool `json:"nsfw"`
- ExtraPageContent string `json:"extraPageContent"`
- StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream
- SocialHandles []models.SocialHandle `json:"socialHandles"`
- ChatDisabled bool `json:"chatDisabled"`
- ExternalActions []models.ExternalAction `json:"externalActions"`
- CustomStyles string `json:"customStyles"`
- MaxSocketPayloadSize int `json:"maxSocketPayloadSize"`
- Federation federationConfigResponse `json:"federation"`
+ Name string `json:"name"`
+ Summary string `json:"summary"`
+ Logo string `json:"logo"`
+ Tags []string `json:"tags"`
+ Version string `json:"version"`
+ NSFW bool `json:"nsfw"`
+ ExtraPageContent string `json:"extraPageContent"`
+ StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream
+ SocialHandles []models.SocialHandle `json:"socialHandles"`
+ ChatDisabled bool `json:"chatDisabled"`
+ ExternalActions []models.ExternalAction `json:"externalActions"`
+ CustomStyles string `json:"customStyles"`
+ MaxSocketPayloadSize int `json:"maxSocketPayloadSize"`
+ Federation federationConfigResponse `json:"federation"`
+ Notifications notificationsConfigResponse `json:"notifications"`
}
type federationConfigResponse struct {
@@ -37,6 +39,20 @@ type federationConfigResponse struct {
FollowerCount int `json:"followerCount,omitempty"`
}
+type browserNotificationsConfigResponse struct {
+ Enabled bool `json:"enabled"`
+ PublicKey string `json:"publicKey,omitempty"`
+}
+
+type textMessageNotificatoinsConfigResponse struct {
+ Enabled bool `json:"enabled"`
+}
+
+type notificationsConfigResponse struct {
+ Browser browserNotificationsConfigResponse `json:"browser"`
+ TextMessages textMessageNotificatoinsConfigResponse `json:"textMessages"`
+}
+
// GetWebConfig gets the status of the server.
func GetWebConfig(w http.ResponseWriter, r *http.Request) {
middleware.EnableCors(w)
@@ -71,6 +87,23 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
}
}
+ browserPushEnabled := data.GetBrowserPushConfig().Enabled
+ browserPushPublicKey, err := data.GetBrowserPushPublicKey()
+ if err != nil {
+ log.Errorln("unable to fetch browser push notifications public key", err)
+ browserPushEnabled = false
+ }
+
+ notificationsResponse := notificationsConfigResponse{
+ Browser: browserNotificationsConfigResponse{
+ Enabled: browserPushEnabled,
+ PublicKey: browserPushPublicKey,
+ },
+ TextMessages: textMessageNotificatoinsConfigResponse{
+ Enabled: data.GetTwilioConfig().Enabled,
+ },
+ }
+
configuration := webConfigResponse{
Name: data.GetServerName(),
Summary: serverSummary,
@@ -86,6 +119,7 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
CustomStyles: data.GetCustomStyles(),
MaxSocketPayloadSize: config.MaxSocketPayloadSize,
Federation: federationResponse,
+ Notifications: notificationsResponse,
}
if err := json.NewEncoder(w).Encode(configuration); err != nil {
diff --git a/controllers/notifications.go b/controllers/notifications.go
new file mode 100644
index 000000000..de060fb85
--- /dev/null
+++ b/controllers/notifications.go
@@ -0,0 +1,48 @@
+package controllers
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/owncast/owncast/notifications"
+ "github.com/owncast/owncast/utils"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// RegisterForLiveNotifications will register a channel + destination to be
+// notified when a stream goes live.
+func RegisterForLiveNotifications(w http.ResponseWriter, r *http.Request) {
+ if r.Method != POST {
+ WriteSimpleResponse(w, false, r.Method+" not supported")
+ return
+ }
+
+ type request struct {
+ // Channel is the notification channel (browser, sms, etc)
+ Channel string `json:"channel"`
+ // Destination is the target of the notification in the above channel.
+ Destination string `json:"destination"`
+ }
+
+ decoder := json.NewDecoder(r.Body)
+ var req request
+ if err := decoder.Decode(&req); err != nil {
+ log.Errorln(err)
+ WriteSimpleResponse(w, false, "unable to register for notifications")
+ }
+
+ // Make sure the requested channel is one we want to handle.
+ validTypes := []string{notifications.BrowserPushNotification, notifications.TextMessageNotification}
+ _, validChannel := utils.FindInSlice(validTypes, req.Channel)
+ if !validChannel {
+ WriteSimpleResponse(w, false, "invalid notification channel: "+req.Channel)
+ return
+ }
+
+ if err := notifications.AddNotification(req.Channel, req.Destination); err != nil {
+ log.Errorln(err)
+ WriteSimpleResponse(w, false, "unable to save notification")
+ return
+ }
+}
diff --git a/core/core.go b/core/core.go
index 4926eab61..b01ede6b1 100644
--- a/core/core.go
+++ b/core/core.go
@@ -15,6 +15,7 @@ import (
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/core/webhooks"
"github.com/owncast/owncast/models"
+ "github.com/owncast/owncast/notifications"
"github.com/owncast/owncast/utils"
"github.com/owncast/owncast/yp"
)
@@ -25,11 +26,8 @@ var (
_transcoder *transcoder.Transcoder
_yp *yp.YP
_broadcaster *models.Broadcaster
-)
-
-var (
- handler transcoder.HLSHandler
- fileWriter = transcoder.FileWriterReceiverService{}
+ handler transcoder.HLSHandler
+ fileWriter = transcoder.FileWriterReceiverService{}
)
// Start starts up the core processing.
@@ -80,6 +78,8 @@ func Start() error {
webhooks.InitWorkerPool()
+ notifications.Setup(data.GetStore())
+
return nil
}
diff --git a/core/data/config.go b/core/data/config.go
index 5bbb4ed1c..5ba076c45 100644
--- a/core/data/config.go
+++ b/core/data/config.go
@@ -14,44 +14,50 @@ import (
)
const (
- extraContentKey = "extra_page_content"
- streamTitleKey = "stream_title"
- streamKeyKey = "stream_key"
- logoPathKey = "logo_path"
- serverSummaryKey = "server_summary"
- serverWelcomeMessageKey = "server_welcome_message"
- serverNameKey = "server_name"
- serverURLKey = "server_url"
- httpPortNumberKey = "http_port_number"
- httpListenAddressKey = "http_listen_address"
- rtmpPortNumberKey = "rtmp_port_number"
- serverMetadataTagsKey = "server_metadata_tags"
- directoryEnabledKey = "directory_enabled"
- directoryRegistrationKeyKey = "directory_registration_key"
- socialHandlesKey = "social_handles"
- peakViewersSessionKey = "peak_viewers_session"
- peakViewersOverallKey = "peak_viewers_overall"
- lastDisconnectTimeKey = "last_disconnect_time"
- ffmpegPathKey = "ffmpeg_path"
- nsfwKey = "nsfw"
- s3StorageEnabledKey = "s3_storage_enabled"
- s3StorageConfigKey = "s3_storage_config"
- videoLatencyLevel = "video_latency_level"
- videoStreamOutputVariantsKey = "video_stream_output_variants"
- chatDisabledKey = "chat_disabled"
- externalActionsKey = "external_actions"
- customStylesKey = "custom_styles"
- videoCodecKey = "video_codec"
- blockedUsernamesKey = "blocked_usernames"
- publicKeyKey = "public_key"
- privateKeyKey = "private_key"
- serverInitDateKey = "server_init_date"
- federationEnabledKey = "federation_enabled"
- federationUsernameKey = "federation_username"
- federationPrivateKey = "federation_private"
- federationGoLiveMessageKey = "federation_go_live_message"
- federationShowEngagementKey = "federation_show_engagement"
- federationBlockedDomainsKey = "federation_blocked_domains"
+ extraContentKey = "extra_page_content"
+ streamTitleKey = "stream_title"
+ streamKeyKey = "stream_key"
+ logoPathKey = "logo_path"
+ serverSummaryKey = "server_summary"
+ serverWelcomeMessageKey = "server_welcome_message"
+ serverNameKey = "server_name"
+ serverURLKey = "server_url"
+ httpPortNumberKey = "http_port_number"
+ httpListenAddressKey = "http_listen_address"
+ rtmpPortNumberKey = "rtmp_port_number"
+ serverMetadataTagsKey = "server_metadata_tags"
+ directoryEnabledKey = "directory_enabled"
+ directoryRegistrationKeyKey = "directory_registration_key"
+ socialHandlesKey = "social_handles"
+ peakViewersSessionKey = "peak_viewers_session"
+ peakViewersOverallKey = "peak_viewers_overall"
+ lastDisconnectTimeKey = "last_disconnect_time"
+ ffmpegPathKey = "ffmpeg_path"
+ nsfwKey = "nsfw"
+ s3StorageConfigKey = "s3_storage_config"
+ videoLatencyLevel = "video_latency_level"
+ videoStreamOutputVariantsKey = "video_stream_output_variants"
+ chatDisabledKey = "chat_disabled"
+ externalActionsKey = "external_actions"
+ customStylesKey = "custom_styles"
+ videoCodecKey = "video_codec"
+ blockedUsernamesKey = "blocked_usernames"
+ publicKeyKey = "public_key"
+ privateKeyKey = "private_key"
+ serverInitDateKey = "server_init_date"
+ federationEnabledKey = "federation_enabled"
+ federationUsernameKey = "federation_username"
+ federationPrivateKey = "federation_private"
+ federationGoLiveMessageKey = "federation_go_live_message"
+ federationShowEngagementKey = "federation_show_engagement"
+ federationBlockedDomainsKey = "federation_blocked_domains"
+ notificationsEnabledKey = "notifications_enabled"
+ twilioConfigurationKey = "twilio_configuration"
+ discordConfigurationKey = "discord_configuration"
+ browserPushConfigurationKey = "browser_push_configuration"
+ browserPushPublicKeyKey = "browser_push_public_key"
+ browserPushPrivateKeyKey = "browser_push_private_key"
+ hasConfiguredInitialNotificationsKey = "has_configured_initial_notifications"
)
// GetExtraPageBodyContent will return the user-supplied body content.
@@ -413,22 +419,6 @@ func SetS3Config(config models.S3) error {
return _datastore.Save(configEntry)
}
-// GetS3StorageEnabled will return if external storage is enabled.
-func GetS3StorageEnabled() bool {
- enabled, err := _datastore.GetBool(s3StorageEnabledKey)
- if err != nil {
- log.Traceln(err)
- return false
- }
-
- return enabled
-}
-
-// SetS3StorageEnabled will enable or disable external storage.
-func SetS3StorageEnabled(enabled bool) error {
- return _datastore.SetBool(s3StorageEnabledKey, enabled)
-}
-
// GetStreamLatencyLevel will return the stream latency level.
func GetStreamLatencyLevel() models.LatencyLevel {
level, err := _datastore.GetNumber(videoLatencyLevel)
@@ -733,3 +723,108 @@ func GetBlockedFederatedDomains() []string {
return strings.Split(domains, ",")
}
+
+// SetNotificationsEnabled will save the enabled state of notifications.
+func SetNotificationsEnabled(enabled bool) error {
+ return _datastore.SetBool(notificationsEnabledKey, enabled)
+}
+
+// GetNotificationsEnabled will return the enabled state of notifications.
+func GetNotificationsEnabled() bool {
+ enabled, _ := _datastore.GetBool(notificationsEnabledKey)
+ return enabled
+}
+
+// GetTwilioConfig will return the Twilio configuration.
+func GetTwilioConfig() models.TwilioConfiguration {
+ configEntry, err := _datastore.Get(twilioConfigurationKey)
+ if err != nil {
+ return models.TwilioConfiguration{Enabled: false}
+ }
+
+ var config models.TwilioConfiguration
+ if err := configEntry.getObject(&config); err != nil {
+ return models.TwilioConfiguration{Enabled: false}
+ }
+
+ return config
+}
+
+// SetTwilioConfig will set the Twilio configuration.
+func SetTwilioConfig(config models.TwilioConfiguration) error {
+ configEntry := ConfigEntry{Key: twilioConfigurationKey, Value: config}
+ return _datastore.Save(configEntry)
+}
+
+// GetDiscordConfig will return the Discord configuration.
+func GetDiscordConfig() models.DiscordConfiguration {
+ configEntry, err := _datastore.Get(discordConfigurationKey)
+ if err != nil {
+ return models.DiscordConfiguration{Enabled: false}
+ }
+
+ var config models.DiscordConfiguration
+ if err := configEntry.getObject(&config); err != nil {
+ return models.DiscordConfiguration{Enabled: false}
+ }
+
+ return config
+}
+
+// SetDiscordConfig will set the Discord configuration.
+func SetDiscordConfig(config models.DiscordConfiguration) error {
+ configEntry := ConfigEntry{Key: discordConfigurationKey, Value: config}
+ return _datastore.Save(configEntry)
+}
+
+// GetDiscordConfig will return the browser push configuration.
+func GetBrowserPushConfig() models.BrowserNotificationConfiguration {
+ configEntry, err := _datastore.Get(browserPushConfigurationKey)
+ if err != nil {
+ return models.BrowserNotificationConfiguration{Enabled: false}
+ }
+
+ var config models.BrowserNotificationConfiguration
+ if err := configEntry.getObject(&config); err != nil {
+ return models.BrowserNotificationConfiguration{Enabled: false}
+ }
+
+ return config
+}
+
+// SetBrowserPushConfig will set the browser push configuration.
+func SetBrowserPushConfig(config models.BrowserNotificationConfiguration) error {
+ configEntry := ConfigEntry{Key: browserPushConfigurationKey, Value: config}
+ return _datastore.Save(configEntry)
+}
+
+// SetBrowserPushPublicKey will set the public key for browser pushes.
+func SetBrowserPushPublicKey(key string) error {
+ return _datastore.SetString(browserPushPublicKeyKey, key)
+}
+
+// GetBrowserPushPublicKey will return the public key for browser pushes.
+func GetBrowserPushPublicKey() (string, error) {
+ return _datastore.GetString(browserPushPublicKeyKey)
+}
+
+// SetBrowserPushPrivateKey will set the private key for browser pushes.
+func SetBrowserPushPrivateKey(key string) error {
+ return _datastore.SetString(browserPushPrivateKeyKey, key)
+}
+
+// GetBrowserPushPrivateKey will return the private key for browser pushes.
+func GetBrowserPushPrivateKey() (string, error) {
+ return _datastore.GetString(browserPushPrivateKeyKey)
+}
+
+// SetHasPerformedInitialNotificationsConfig sets when performed initial setup.
+func SetHasPerformedInitialNotificationsConfig(hasConfigured bool) error {
+ return _datastore.SetBool(hasConfiguredInitialNotificationsKey, true)
+}
+
+// GetHasPerformedInitialNotificationsConfig gets when performed initial setup.
+func GetHasPerformedInitialNotificationsConfig() bool {
+ configured, _ := _datastore.GetBool(hasConfiguredInitialNotificationsKey)
+ return configured
+}
diff --git a/core/streamState.go b/core/streamState.go
index f4184f7d2..c65f8c8a7 100644
--- a/core/streamState.go
+++ b/core/streamState.go
@@ -15,6 +15,7 @@ import (
"github.com/owncast/owncast/core/transcoder"
"github.com/owncast/owncast/core/webhooks"
"github.com/owncast/owncast/models"
+ "github.com/owncast/owncast/notifications"
"github.com/owncast/owncast/utils"
)
@@ -28,6 +29,8 @@ 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}
@@ -75,6 +78,15 @@ func setStreamAsConnected(rtmpOut *io.PipeReader) {
if data.GetFederationEnabled() {
_onlineTimerCancelFunc = startFederatedLiveStreamMessageTimer()
}
+
+ // Send alerts to those who have registered for notifications.
+ if notifier, err := notifications.New(data.GetDatastore()); err != nil {
+ log.Errorln(err)
+ } else if _lastNotified == nil || time.Since(*_lastNotified) > 5*time.Minute {
+ notifier.Notify()
+ now := time.Now()
+ _lastNotified = &now
+ }
}
// SetStreamAsDisconnected sets the stream as disconnected.
diff --git a/db/models.go b/db/models.go
index d17aecca1..493e19d5c 100644
--- a/db/models.go
+++ b/db/models.go
@@ -34,3 +34,10 @@ type ApOutbox struct {
CreatedAt sql.NullTime
LiveNotification sql.NullBool
}
+
+type Notification struct {
+ ID int32
+ Channel string
+ Destination string
+ CreatedAt sql.NullTime
+}
diff --git a/db/query.sql b/db/query.sql
index 7aae2d51f..f54cf8dc1 100644
--- a/db/query.sql
+++ b/db/query.sql
@@ -55,3 +55,9 @@ SELECT count(*) FROM ap_accepted_activities WHERE iri = $1 AND actor = $2 AND TY
-- name: UpdateFollowerByIRI :exec
UPDATE ap_followers SET inbox = $1, name = $2, username = $3, image = $4 WHERE iri = $5;
+
+-- name: AddNotification :exec
+INSERT INTO notifications (channel, destination) VALUES($1, $2);
+
+-- name: GetNotificationDestinationsForChannel :many
+SELECT destination FROM notifications WHERE channel = $1;
diff --git a/db/query.sql.go b/db/query.sql.go
index 7cec05aa6..aaf1abf51 100644
--- a/db/query.sql.go
+++ b/db/query.sql.go
@@ -36,6 +36,20 @@ func (q *Queries) AddFollower(ctx context.Context, arg AddFollowerParams) error
return err
}
+const addNotification = `-- name: AddNotification :exec
+INSERT INTO notifications (channel, destination) VALUES($1, $2)
+`
+
+type AddNotificationParams struct {
+ Channel string
+ Destination string
+}
+
+func (q *Queries) AddNotification(ctx context.Context, arg AddNotificationParams) error {
+ _, err := q.db.ExecContext(ctx, addNotification, arg.Channel, arg.Destination)
+ return err
+}
+
const addToAcceptedActivities = `-- name: AddToAcceptedActivities :exec
INSERT INTO ap_accepted_activities(iri, actor, type, timestamp) values($1, $2, $3, $4)
`
@@ -291,6 +305,33 @@ func (q *Queries) GetLocalPostCount(ctx context.Context) (int64, error) {
return count, err
}
+const getNotificationDestinationsForChannel = `-- name: GetNotificationDestinationsForChannel :many
+SELECT destination FROM notifications WHERE channel = $1
+`
+
+func (q *Queries) GetNotificationDestinationsForChannel(ctx context.Context, channel string) ([]string, error) {
+ rows, err := q.db.QueryContext(ctx, getNotificationDestinationsForChannel, channel)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []string
+ for rows.Next() {
+ var destination string
+ if err := rows.Scan(&destination); err != nil {
+ return nil, err
+ }
+ items = append(items, destination)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getObjectFromOutboxByID = `-- name: GetObjectFromOutboxByID :one
SELECT value FROM ap_outbox WHERE iri = $1
`
diff --git a/db/schema.sql b/db/schema.sql
index 739888031..1c95b70aa 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -35,3 +35,10 @@ CREATE TABLE IF NOT EXISTS ap_accepted_activities (
"timestamp" TIMESTAMP NOT NULL
);
CREATE INDEX iri_actor_index ON ap_accepted_activities (iri,actor);
+
+CREATE TABLE IF NOT EXISTS notifications (
+ "id" INTEGER NOT NULL PRIMARY KEY,
+ "channel" TEXT NOT NULL,
+ "destination" TEXT NOT NULL,
+ "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
+ CREATE INDEX channel_index ON notifications (channel);
diff --git a/go.mod b/go.mod
index 40fe70bec..eb0233d60 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,6 @@ require (
github.com/aws/aws-sdk-go v1.42.0
github.com/go-fed/activity v1.0.1-0.20210803212804-d866ba75dd0f
github.com/go-fed/httpsig v1.1.0
- github.com/go-ole/go-ole v1.2.6 // indirect
github.com/gorilla/websocket v1.4.2
github.com/grafov/m3u8 v0.11.1
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
@@ -28,21 +27,33 @@ require (
)
require (
- github.com/aymerick/douceur v0.2.0 // indirect
- github.com/gorilla/css v1.0.0 // indirect
- github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/lestrrat-go/strftime v1.0.4 // indirect
github.com/mvdan/xurls v1.1.0 // indirect
- github.com/oschwald/maxminddb-golang v1.8.0 // indirect
github.com/pkg/errors v0.9.1
github.com/tklauser/go-sysconf v0.3.5 // indirect
- github.com/tklauser/numcpus v0.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.2 // indirect
- golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
- golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect
- golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 // indirect
+ golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect
+)
+
+require github.com/SherClockHolmes/webpush-go v1.1.3
+
+require github.com/twilio/twilio-go v0.19.0
+
+require (
+ github.com/aymerick/douceur v0.2.0 // indirect
+ github.com/go-ole/go-ole v1.2.6 // indirect
+ github.com/go-test/deep v1.0.4 // indirect
+ github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
+ github.com/golang/mock v1.6.0 // indirect
+ github.com/gorilla/css v1.0.0 // indirect
+ github.com/jmespath/go-jmespath v0.4.0 // indirect
+ github.com/oschwald/maxminddb-golang v1.8.0 // indirect
+ github.com/tklauser/numcpus v0.2.2 // indirect
+ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect
+ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
+ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)
replace github.com/go-fed/activity => github.com/owncast/activity v1.0.1-0.20211229051252-7821289d4026
diff --git a/go.sum b/go.sum
index cd682d28c..ab21d3624 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,12 @@
+github.com/SherClockHolmes/webpush-go v1.1.3 h1:VucRA0rOs0fWQGaf2sp1oeKa8om9Mo5OMaRpUiCxzQE=
+github.com/SherClockHolmes/webpush-go v1.1.3/go.mod h1:w6X47YApe/B9wUz2Wh8xukxlyupaxSSEbu6yKJcHN2w=
github.com/amalfra/etag v0.0.0-20190921100247-cafc8de96bc5 h1:+ty4KYpIDUhjzsVsV+HiTeYEfufBc/4FLNiqIGU1A1U=
github.com/amalfra/etag v0.0.0-20190921100247-cafc8de96bc5/go.mod h1:Qk51jPgvIaO549MR+IvLP/uMZbZGs05QJSzEhDVZ1jc=
github.com/aws/aws-sdk-go v1.42.0 h1:BMZws0t8NAhHFsfnT3B40IwD13jVDG5KerlRksctVIw=
github.com/aws/aws-sdk-go v1.42.0/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/dave/jennifer v1.3.0/go.mod h1:fIb+770HOpJ2fmN9EPPKOqm1vMGhB+TwXKMZhrIygKg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -13,9 +16,14 @@ github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
-github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
+github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
+github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
+github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
+github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
+github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
@@ -28,6 +36,9 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGw
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc h1:RKf14vYWi2ttpEmkA4aQ3j4u9dStX2t4M8UM6qqNsG8=
github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is=
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible h1:Y6sqxHMyB1D2YSzWkLibYKgg+SwmyFU9dF2hn6MdTj4=
@@ -45,12 +56,11 @@ github.com/mvdan/xurls v1.1.0 h1:OpuDelGQ1R1ueQ6sSryzi6P+1RtBpfQHM8fJwlE45ww=
github.com/mvdan/xurls v1.1.0/go.mod h1:tQlNn3BED8bE/15hnSL2HLkDeLWpNPAwtw7wkEq44oU=
github.com/nareix/joy5 v0.0.0-20200712071056-a55089207c88 h1:CXq5QLPMcfGEZMx8uBMyLdDiUNV72vlkSiyqg+jf7AI=
github.com/nareix/joy5 v0.0.0-20200712071056-a55089207c88/go.mod h1:XmAOs6UJXpNXRwKk+KY/nv5kL6xXYXyellk+A1pTlko=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oschwald/geoip2-golang v1.5.0 h1:igg2yQIrrcRccB1ytFXqBfOHCjXWIoMv85lVJ1ONZzw=
github.com/oschwald/geoip2-golang v1.5.0/go.mod h1:xdvYt5xQzB8ORWFqPnqMwZpCpgNagttWdoZLlJQzg7s=
github.com/oschwald/maxminddb-golang v1.8.0 h1:Uh/DSnGoxsyp/KYbY1AuP0tYEwfs0sCph9p/UMXK/Hk=
github.com/oschwald/maxminddb-golang v1.8.0/go.mod h1:RXZtst0N6+FY/3qCNmZMBApR19cdQj43/NM9VkrNAis=
-github.com/owncast/activity v1.0.1-0.20210908225327-e46ee45ec09c h1:lk78BK8sLYn8nwy4ZZdQqcRdkagxQI//wF/DXuxsg1Y=
-github.com/owncast/activity v1.0.1-0.20210908225327-e46ee45ec09c/go.mod h1:v4QoPaAzjWZ8zN2VFVGL5ep9C02mst0hQYHUpQwso4Q=
github.com/owncast/activity v1.0.1-0.20211229051252-7821289d4026 h1:E1nxiX44BcMQTSSs8MHLm2rXnqXNedYZkFI31gXMsJc=
github.com/owncast/activity v1.0.1-0.20211229051252-7821289d4026/go.mod h1:v4QoPaAzjWZ8zN2VFVGL5ep9C02mst0hQYHUpQwso4Q=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -80,23 +90,34 @@ github.com/tklauser/go-sysconf v0.3.5 h1:uu3Xl4nkLzQfXNsWn15rPc/HQCJKObbt1dKJeWp
github.com/tklauser/go-sysconf v0.3.5/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI=
github.com/tklauser/numcpus v0.2.2 h1:oyhllyrScuYI6g+h/zUvNXNp1wy7x8qQy3t/piefldA=
github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZFu0T9wgjM=
+github.com/twilio/twilio-go v0.19.0 h1:ksf9y3WQlR8x5xDjRNC3bnoFBrjbEYCqiIEoH1vb4Pk=
+github.com/twilio/twilio-go v0.19.0/go.mod h1:cDIj6rv7xDcQy6Ss/hB0Tz9901wJbWK4XPw0gnt3U9Y=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs=
github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg=
github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M=
+golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -105,24 +126,35 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 h1:hZR0X1kPW+nwyJ9xRxqZk1vx5RUObAPBdKVvXPDUH/E=
-golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 h1:id054HUawV2/6IGm2IV8KZQjqtwAOo2CYlOToYqa0d0=
+golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/xurls v1.1.0 h1:kj0j2lonKseISJCiq1Tfk+iTv65dDGCl0rTbanXJGGc=
mvdan.cc/xurls v1.1.0/go.mod h1:TNWuhvo+IqbUCmtUIb/3LJSQdrzel8loVpgFm0HikbI=
diff --git a/main.go b/main.go
index 25c257d23..1fa22061b 100644
--- a/main.go
+++ b/main.go
@@ -16,16 +16,18 @@ import (
"github.com/owncast/owncast/utils"
)
-var dbFile = flag.String("database", "", "Path to the database file.")
-var logDirectory = flag.String("logdir", "", "Directory where logs will be written to")
-var backupDirectory = flag.String("backupdir", "", "Directory where backups will be written to")
-var enableDebugOptions = flag.Bool("enableDebugFeatures", false, "Enable additional debugging options.")
-var enableVerboseLogging = flag.Bool("enableVerboseLogging", false, "Enable additional logging.")
-var restoreDatabaseFile = flag.String("restoreDatabase", "", "Restore an Owncast database backup")
-var newStreamKey = flag.String("streamkey", "", "Set your stream key/admin password")
-var webServerPortOverride = flag.String("webserverport", "", "Force the web server to listen on a specific port")
-var webServerIPOverride = flag.String("webserverip", "", "Force web server to listen on this IP address")
-var rtmpPortOverride = flag.Int("rtmpport", 0, "Set listen port for the RTMP server")
+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")
+ newStreamKey = flag.String("streamkey", "", "Set your stream key/admin password")
+ 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 main() {
flag.Parse()
diff --git a/models/twilio.go b/models/twilio.go
new file mode 100644
index 000000000..4894b0973
--- /dev/null
+++ b/models/twilio.go
@@ -0,0 +1,20 @@
+package models
+
+type TwilioConfiguration struct {
+ Enabled bool `json:"enabled"`
+ AccountSid string `json:"accountSid,omitempty"`
+ AuthToken string `json:"authToken,omitempty"`
+ PhoneNumber string `json:"phoneNumber,omitempty"`
+ GoLiveMessage string `json:"goLiveMessage,omitempty"`
+}
+
+type DiscordConfiguration struct {
+ Enabled bool `json:"enabled"`
+ Webhook string `json:"webhook,omitempty"`
+ GoLiveMessage string `json:"goLiveMessage,omitempty"`
+}
+
+type BrowserNotificationConfiguration struct {
+ Enabled bool `json:"enabled"`
+ GoLiveMessage string `json:"goLiveMessage,omitempty"`
+}
diff --git a/notifications/browser/browser.go b/notifications/browser/browser.go
new file mode 100644
index 000000000..793c9286f
--- /dev/null
+++ b/notifications/browser/browser.go
@@ -0,0 +1,79 @@
+package browser
+
+import (
+ "encoding/json"
+
+ "github.com/SherClockHolmes/webpush-go"
+ "github.com/owncast/owncast/core/data"
+ "github.com/pkg/errors"
+)
+
+// Browser is an instance of the Browser service.
+type Browser struct {
+ datastore *data.Datastore
+ privateKey string
+ publicKey string
+}
+
+// New will create a new instance of the Browser service.
+func New(datastore *data.Datastore, publicKey, privateKey string) (*Browser, error) {
+ return &Browser{
+ datastore: datastore,
+ privateKey: privateKey,
+ publicKey: publicKey,
+ }, nil
+}
+
+// GenerateBrowserPushKeys will create the VAPID keys required for web push notifications.
+func GenerateBrowserPushKeys() (string, string, error) {
+ privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
+ if err != nil {
+ return "", "", errors.Wrap(err, "error generating web push keys")
+ }
+
+ return privateKey, publicKey, nil
+}
+
+// Send will send a browser push notification to the given subscription.
+func (b *Browser) Send(
+ subscription string,
+ title string,
+ body string,
+) error {
+ type message struct {
+ Title string `json:"title"`
+ Body string `json:"body"`
+ Icon string `json:"icon"`
+ }
+
+ m := message{
+ Title: title,
+ Body: body,
+ Icon: "/logo/external",
+ }
+
+ d, err := json.Marshal(m)
+ if err != nil {
+ return errors.Wrap(err, "error marshalling web push message")
+ }
+
+ // Decode subscription
+ s := &webpush.Subscription{}
+ if err := json.Unmarshal([]byte(subscription), s); err != nil {
+ return errors.Wrap(err, "error decoding destination subscription")
+ }
+
+ // Send Notification
+ resp, err := webpush.SendNotification(d, s, &webpush.Options{
+ VAPIDPublicKey: b.publicKey,
+ VAPIDPrivateKey: b.privateKey,
+ Topic: "owncast-go-live",
+ TTL: 10,
+ })
+ if err != nil {
+ return errors.Wrap(err, "error sending browser push notification")
+ }
+ defer resp.Body.Close()
+
+ return err
+}
diff --git a/notifications/channels.go b/notifications/channels.go
new file mode 100644
index 000000000..e63b2e5ba
--- /dev/null
+++ b/notifications/channels.go
@@ -0,0 +1,6 @@
+package notifications
+
+const (
+ BrowserPushNotification = "BROWSER_PUSH_NOTIFICATION"
+ TextMessageNotification = "TEXT_MESSAGE_NOTIFICATION"
+)
diff --git a/notifications/discord/discord.go b/notifications/discord/discord.go
new file mode 100644
index 000000000..d0e5918a2
--- /dev/null
+++ b/notifications/discord/discord.go
@@ -0,0 +1,61 @@
+package discord
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+
+ "github.com/pkg/errors"
+)
+
+// Discord is an instance of the Discord service.
+type Discord struct {
+ name string
+ avatar string
+ webhookURL string
+}
+
+// New will create a new instance of the Discord service.
+func New(name, avatar, webhook string) (*Discord, error) {
+ return &Discord{
+ name: name,
+ avatar: avatar,
+ webhookURL: webhook,
+ }, nil
+}
+
+// Send will send a message to a Discord channel via a webhook.
+func (t *Discord) Send(content string) error {
+ type message struct {
+ Username string `json:"username"`
+ Content string `json:"content"`
+ Avatar string `json:"avatar_url"`
+ }
+
+ msg := message{
+ Username: t.name,
+ Content: content,
+ Avatar: t.avatar,
+ }
+
+ jsonText, err := json.Marshal(msg)
+ if err != nil {
+ return errors.Wrap(err, "error marshalling discord message to json")
+ }
+
+ req, err := http.NewRequest("POST", t.webhookURL, bytes.NewReader(jsonText))
+ if err != nil {
+ return errors.Wrap(err, "error creating discord webhook request")
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{}
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return errors.Wrap(err, "error executing discord webhook")
+ }
+
+ return resp.Body.Close()
+}
diff --git a/notifications/notifications.go b/notifications/notifications.go
new file mode 100644
index 000000000..2ad624025
--- /dev/null
+++ b/notifications/notifications.go
@@ -0,0 +1,150 @@
+package notifications
+
+import (
+ "github.com/owncast/owncast/config"
+ "github.com/owncast/owncast/core/data"
+ "github.com/owncast/owncast/models"
+ "github.com/owncast/owncast/notifications/browser"
+ "github.com/owncast/owncast/notifications/discord"
+ "github.com/owncast/owncast/notifications/twilio"
+ "github.com/pkg/errors"
+ log "github.com/sirupsen/logrus"
+)
+
+// Notifier is an instance of the live stream notifier.
+type Notifier struct {
+ datastore *data.Datastore
+ browser *browser.Browser
+ discord *discord.Discord
+ twilio *twilio.Twilio
+}
+
+// Setup will perform any pre-use setup for the notifier.
+func Setup(datastore *data.Datastore) {
+ createNotificationsTable(datastore.DB)
+
+ pubKey, _ := data.GetBrowserPushPublicKey()
+ privKey, _ := data.GetBrowserPushPrivateKey()
+
+ // We need browser push keys so people can register for pushes.
+ if pubKey == "" || privKey == "" {
+ browserPrivateKey, browserPublicKey, err := browser.GenerateBrowserPushKeys()
+ if err != nil {
+ log.Errorln("unable to initialize browser push notification keys", err)
+ }
+
+ if err := data.SetBrowserPushPrivateKey(browserPrivateKey); err != nil {
+ log.Errorln("unable to set browser push private key", err)
+ }
+
+ if err := data.SetBrowserPushPublicKey(browserPublicKey); err != nil {
+ log.Errorln("unable to set browser push public key", err)
+ }
+ }
+
+ // Enable browser push notifications by default.
+ if !data.GetHasPerformedInitialNotificationsConfig() {
+ _ = data.SetBrowserPushConfig(models.BrowserNotificationConfiguration{Enabled: true, GoLiveMessage: config.GetDefaults().FederationGoLiveMessage})
+ _ = data.SetHasPerformedInitialNotificationsConfig(true)
+ }
+}
+
+// New creates a new instance of the Notifier.
+func New(datastore *data.Datastore) (*Notifier, error) {
+ notifier := Notifier{
+ datastore: datastore,
+ }
+
+ // Add browser push notifications
+ if data.GetBrowserPushConfig().Enabled {
+ publicKey, err := data.GetBrowserPushPublicKey()
+ if err != nil || publicKey == "" {
+ return nil, errors.Wrap(err, "notifier disabled, failed to get browser push public key")
+ }
+
+ privateKey, err := data.GetBrowserPushPrivateKey()
+ if err != nil || privateKey == "" {
+ return nil, errors.Wrap(err, "notifier disabled, failed to get browser push private key")
+ }
+
+ browserNotifier, err := browser.New(datastore, publicKey, privateKey)
+ if err != nil {
+ return nil, errors.Wrap(err, "error creating browser notifier")
+ }
+ notifier.browser = browserNotifier
+ }
+
+ // Add discord notifications
+ discordConfig := data.GetDiscordConfig()
+ if discordConfig.Enabled && discordConfig.Webhook != "" {
+ var image string
+ if serverURL := data.GetServerURL(); serverURL != "" {
+ image = serverURL + "/images/owncast-logo.png"
+ }
+ discordNotifier, err := discord.New(
+ data.GetServerName(),
+ image,
+ discordConfig.Webhook,
+ )
+ if err != nil {
+ return nil, errors.Wrap(err, "error creating discord notifier")
+ }
+ notifier.discord = discordNotifier
+ }
+
+ // Add Twilio notifier
+ if twilioConfig := data.GetTwilioConfig(); twilioConfig.Enabled {
+ twilioNotifier, err := twilio.New(twilioConfig.PhoneNumber, twilioConfig.AccountSid, twilioConfig.AuthToken)
+ if err != nil {
+ return nil, errors.Wrap(err, "error creating twilio notifier")
+ }
+ notifier.twilio = twilioNotifier
+ }
+
+ return &notifier, nil
+}
+
+func (n *Notifier) notifyBrowserDestinations() {
+ destinations, err := GetNotificationDestinationsForChannel(BrowserPushNotification)
+ if err != nil {
+ log.Errorln("error getting browser push notification destinations", err)
+ }
+ for _, destination := range destinations {
+ if err := n.browser.Send(destination, data.GetServerName(), data.GetBrowserPushConfig().GoLiveMessage); err != nil {
+ log.Errorln(err)
+ }
+ }
+}
+
+func (n *Notifier) notifyTwilioDestinations() {
+ destinations, err := GetNotificationDestinationsForChannel(TextMessageNotification)
+ if err != nil {
+ log.Errorln("error getting browser push notification destinations", err)
+ }
+ for _, destination := range destinations {
+ if err := n.twilio.Send(data.GetTwilioConfig().GoLiveMessage, destination); err != nil {
+ log.Errorln("error sending twilio message", err)
+ }
+ }
+}
+
+func (n *Notifier) notifyDiscord() {
+ if err := n.discord.Send(data.GetDiscordConfig().GoLiveMessage); err != nil {
+ log.Errorln("error sending discord message", err)
+ }
+}
+
+// Notify will fire the different notification channels.
+func (n *Notifier) Notify() {
+ if n.browser != nil {
+ n.notifyBrowserDestinations()
+ }
+
+ if n.discord != nil {
+ n.notifyDiscord()
+ }
+
+ if n.twilio != nil {
+ n.notifyTwilioDestinations()
+ }
+}
diff --git a/notifications/persistence.go b/notifications/persistence.go
new file mode 100644
index 000000000..bbfd88a5a
--- /dev/null
+++ b/notifications/persistence.go
@@ -0,0 +1,51 @@
+package notifications
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/owncast/owncast/core/data"
+ "github.com/owncast/owncast/db"
+ "github.com/pkg/errors"
+ log "github.com/sirupsen/logrus"
+)
+
+func createNotificationsTable(db *sql.DB) {
+ log.Traceln("Creating federation followers table...")
+
+ createTableSQL := `CREATE TABLE IF NOT EXISTS notifications (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "channel" TEXT NOT NULL,
+ "destination" TEXT NOT NULL,
+ "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
+ CREATE INDEX channel_index ON notifications (channel);`
+
+ stmt, err := db.Prepare(createTableSQL)
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer stmt.Close()
+ _, err = stmt.Exec()
+ if err != nil {
+ log.Warnln("error executing sql creating followers table", createTableSQL, err)
+ }
+}
+
+// AddNotification saves a new user notification destination.
+func AddNotification(channel string, destination string) error {
+ return data.GetDatastore().GetQueries().AddNotification(context.Background(), db.AddNotificationParams{
+ Channel: channel,
+ Destination: destination,
+ })
+}
+
+// GetNotificationDestinationsForChannel will return a collection of
+// destinations to notify for a given channel.
+func GetNotificationDestinationsForChannel(channel string) ([]string, error) {
+ result, err := data.GetDatastore().GetQueries().GetNotificationDestinationsForChannel(context.Background(), channel)
+ if err != nil {
+ return nil, errors.Wrap(err, "unable to query notification destinations for channel "+channel)
+ }
+
+ return result, nil
+}
diff --git a/notifications/twilio/twilio.go b/notifications/twilio/twilio.go
new file mode 100644
index 000000000..63489508a
--- /dev/null
+++ b/notifications/twilio/twilio.go
@@ -0,0 +1,37 @@
+package twilio
+
+import (
+ "github.com/pkg/errors"
+ "github.com/twilio/twilio-go"
+ twilioservice "github.com/twilio/twilio-go"
+ openapi "github.com/twilio/twilio-go/rest/api/v2010"
+)
+
+// Twilio is an instance of the Twilio notification service.
+type Twilio struct {
+ fromPhoneNumber string
+ client *twilio.RestClient
+}
+
+// New will create a new instance of the Twilio service given the credentials.
+func New(fromPhoneNumber, accountSid, accessToken string) (*Twilio, error) {
+ client := twilioservice.NewRestClientWithParams(twilioservice.RestClientParams{
+ Username: accountSid,
+ Password: accessToken,
+ })
+ return &Twilio{fromPhoneNumber: fromPhoneNumber, client: client}, nil
+}
+
+func (t *Twilio) Send(content, to string) error {
+ params := &openapi.CreateMessageParams{}
+ params.SetTo(to)
+ params.SetFrom(t.fromPhoneNumber)
+ params.SetBody(content)
+
+ _, err := t.client.ApiV2010.CreateMessage(params)
+ if err != nil {
+ return errors.Wrap(err, "error sending twilio message")
+ }
+
+ return nil
+}
diff --git a/router/router.go b/router/router.go
index 14665f54e..16cbf45e2 100644
--- a/router/router.go
+++ b/router/router.go
@@ -79,6 +79,9 @@ func Start() error {
// return followers
http.HandleFunc("/api/followers", controllers.GetFollowers)
+ // Register for notifications
+ http.HandleFunc("/api/notifications/register", middleware.RequireUserAccessToken(controllers.RegisterForLiveNotifications))
+
// Authenticated admin requests
// Current inbound broadcaster
@@ -302,6 +305,11 @@ func Start() error {
// Return federated activities
http.HandleFunc("/api/admin/federation/actions", middleware.RequireAdminAuth(admin.GetFederatedActions))
+ // Configure outbound notification channels.
+ http.HandleFunc("/api/admin/config/notifications/discord", middleware.RequireAdminAuth(admin.SetDiscordNotificationConfiguration))
+ http.HandleFunc("/api/admin/config/notifications/twilio", middleware.RequireAdminAuth(admin.SetTwilioNotificationConfiguration))
+ http.HandleFunc("/api/admin/config/notifications/browser", middleware.RequireAdminAuth(admin.SetBrowserNotificationConfiguration))
+
// ActivityPub has its own router
activitypub.Start(data.GetDatastore())
diff --git a/webroot/img/browser-push-notifications-settings.png b/webroot/img/browser-push-notifications-settings.png
new file mode 100644
index 000000000..c0d48d529
--- /dev/null
+++ b/webroot/img/browser-push-notifications-settings.png
Binary files differ
diff --git a/webroot/img/notification-bell.svg b/webroot/img/notification-bell.svg
new file mode 100644
index 000000000..ba8ba6a5f
--- /dev/null
+++ b/webroot/img/notification-bell.svg
@@ -0,0 +1,2 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" version="1.1" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path xmlns="http://www.w3.org/2000/svg" d="m450.201 407.453c-1.505-.977-12.832-8.912-24.174-32.917-20.829-44.082-25.201-106.18-25.201-150.511 0-.193-.004-.384-.011-.576-.227-58.589-35.31-109.095-85.514-131.756v-34.657c0-31.45-25.544-57.036-56.942-57.036h-4.719c-31.398 0-56.942 25.586-56.942 57.036v34.655c-50.372 22.734-85.525 73.498-85.525 132.334 0 44.331-4.372 106.428-25.201 150.511-11.341 24.004-22.668 31.939-24.174 32.917-6.342 2.935-9.469 9.715-8.01 16.586 1.473 6.939 7.959 11.723 15.042 11.723h109.947c.614 42.141 35.008 76.238 77.223 76.238s76.609-34.097 77.223-76.238h109.947c7.082 0 13.569-4.784 15.042-11.723 1.457-6.871-1.669-13.652-8.011-16.586zm-223.502-350.417c0-14.881 12.086-26.987 26.942-26.987h4.719c14.856 0 26.942 12.106 26.942 26.987v24.917c-9.468-1.957-19.269-2.987-29.306-2.987-10.034 0-19.832 1.029-29.296 2.984v-24.914zm29.301 424.915c-25.673 0-46.614-20.617-47.223-46.188h94.445c-.608 25.57-21.549 46.188-47.222 46.188zm60.4-76.239c-.003 0-213.385 0-213.385 0 2.595-4.044 5.236-8.623 7.861-13.798 20.104-39.643 30.298-96.129 30.298-167.889 0-63.417 51.509-115.01 114.821-115.01s114.821 51.593 114.821 115.06c0 .185.003.369.01.553.057 71.472 10.25 127.755 30.298 167.286 2.625 5.176 5.267 9.754 7.861 13.798z" fill="#ffffff" data-original="#000000" class=""/></g></svg>
diff --git a/webroot/js/app.js b/webroot/js/app.js
index 4ad2dacc8..bbe267a6d 100644
--- a/webroot/js/app.js
+++ b/webroot/js/app.js
@@ -23,6 +23,8 @@ import FediverseFollowModal, {
FediverseFollowButton,
} from './components/fediverse-follow-modal.js';
+import { NotifyButton, NotifyModal } from './components/notification.js';
+
import {
addNewlines,
checkUrlPathForDisplay,
@@ -59,6 +61,7 @@ import {
WIDTH_SINGLE_COL,
} from './utils/constants.js';
import { checkIsModerator } from './utils/chat.js';
+
import TabBar from './components/tab-bar.js';
export default class App extends Component {
@@ -136,6 +139,8 @@ export default class App extends Component {
this.displayFediverseFollowModal =
this.displayFediverseFollowModal.bind(this);
this.closeFediverseFollowModal = this.closeFediverseFollowModal.bind(this);
+ this.displayNotificationModal = this.displayNotificationModal.bind(this);
+ this.closeNotificationModal = this.closeNotificationModal.bind(this);
// player events
this.handlePlayerReady = this.handlePlayerReady.bind(this);
@@ -176,6 +181,8 @@ export default class App extends Component {
});
this.player.init();
+ this.registerServiceWorker();
+
// check routing
this.getRoute();
}
@@ -245,7 +252,7 @@ export default class App extends Component {
}
setConfigData(data = {}) {
- const { name, summary, chatDisabled } = data;
+ const { name, summary, chatDisabled, notifications } = data;
window.document.title = name;
// If this is the first time setting the config
@@ -258,6 +265,7 @@ export default class App extends Component {
this.setState({
canChat: !chatDisabled,
+ notifications,
configData: {
...data,
summary: summary && addNewlines(summary),
@@ -574,6 +582,23 @@ export default class App extends Component {
this.setState({ fediverseModalData: null });
}
+ displayNotificationModal(data) {
+ this.setState({ notificationModalData: data });
+ }
+ closeNotificationModal() {
+ this.setState({ notificationModalData: null });
+ }
+
+ async registerServiceWorker() {
+ try {
+ const reg = await navigator.serviceWorker.register('/serviceWorker.js', {
+ scope: '/',
+ });
+ } catch (err) {
+ console.error('Owncast service worker registration failed!', err);
+ }
+ }
+
handleWebsocketMessage(e) {
if (e.type === SOCKET_MESSAGE_TYPES.ERROR_USER_DISABLED) {
// User has been actively disabled on the backend. Turn off chat for them.
@@ -662,6 +687,7 @@ export default class App extends Component {
render(props, state) {
const {
+ accessToken,
chatInputEnabled,
configData,
displayChatPanel,
@@ -682,6 +708,8 @@ export default class App extends Component {
windowWidth,
fediverseModalData,
externalActionModalData,
+ notificationModalData,
+ notifications,
lastDisconnectTime,
section,
sectionId,
@@ -747,6 +775,11 @@ export default class App extends Component {
: html` <${VideoPoster} offlineImage=${logo} active=${streamOnline} /> `;
// modal buttons
+ const notificationsButton =
+ notifications &&
+ ((notifications.browser.enabled && !!window.chrome) ||
+ notifications.textMessages.enabled) &&
+ html`<${NotifyButton} onClick=${this.displayNotificationModal} />`;
const externalActionButtons = html`<div
id="external-actions-container"
class="flex flex-row flex-wrap justify-end"
@@ -768,6 +801,7 @@ export default class App extends Component {
federationInfo=${federation}
serverName=${name}
/>`}
+ ${notificationsButton}
</div>`;
// modal component
@@ -794,6 +828,19 @@ export default class App extends Component {
/>
`;
+ const notificationModal =
+ notificationModalData &&
+ html` <${ExternalActionModal}
+ onClose=${this.closeNotificationModal}
+ action=${notificationModalData}
+ useIframe=${false}
+ customContent=${html`<${NotifyModal}
+ notifications=${notifications}
+ streamName=${name}
+ accessToken=${accessToken}
+ />`}
+ />`;
+
const chat = this.state.websocket
? html`
<${Chat}
@@ -801,7 +848,7 @@ export default class App extends Component {
username=${username}
chatInputEnabled=${chatInputEnabled && !chatDisabled}
instanceTitle=${name}
- accessToken=${this.state.accessToken}
+ accessToken=${accessToken}
inputMaxBytes=${maxSocketPayloadSize - EST_SOCKET_PAYLOAD_BUFFER ||
CHAT_MAX_MESSAGE_LENGTH}
/>
@@ -968,6 +1015,7 @@ export default class App extends Component {
</footer>
${chat} ${externalActionModal} ${fediverseFollowModal}
+ ${notificationModal}
</div>
`;
}
diff --git a/webroot/js/components/notification.js b/webroot/js/components/notification.js
new file mode 100644
index 000000000..50a708fdd
--- /dev/null
+++ b/webroot/js/components/notification.js
@@ -0,0 +1,211 @@
+import { h, Component } from '/js/web_modules/preact.js';
+import { useState } from '/js/web_modules/preact/hooks.js';
+
+import htm from '/js/web_modules/htm.js';
+import { ExternalActionButton } from './external-action-modal.js';
+import {
+ registerWebPushNotifications,
+ isPushNotificationSupported,
+} from '../notification/registerWeb.js';
+import { URL_REGISTER_NOTIFICATION } from '../utils/constants.js';
+
+const html = htm.bind(h);
+
+export function NotifyModal({ notifications, streamName, accessToken }) {
+ const [error, setError] = useState(null);
+ const [loaderStyle, setLoaderStyle] = useState('none');
+ const [phoneNotificationsButtonEnabled, setPhoneNotificationsButtonEnabled] =
+ useState(false);
+ const [phoneNumber, setPhoneNumber] = useState(null);
+ const phoneNotificationButtonState = phoneNotificationsButtonEnabled
+ ? ''
+ : 'cursor-not-allowed opacity-50';
+
+ const [browserRegistrationComplete, setBrowserRegistrationComplete] =
+ useState(false);
+
+ const browserNotificationsButtonState =
+ Notification.permission === 'default'
+ ? ''
+ : 'cursor-not-allowed opacity-50';
+
+ const { browser, textMessages } = notifications;
+ const { publicKey } = browser;
+
+ let browserPushEnabled = browser.enabled;
+ let textMessagesEnabled = textMessages.enabled;
+
+ // Browser push notifications are only supported on Chrome currently.
+ // Also make sure the browser supports them.
+ if (!window.chrome || !isPushNotificationSupported()) {
+ browserPushEnabled = false;
+ }
+
+ async function saveNotificationRegistration(channel, destination) {
+ const options = {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ channel: channel, destination: destination }),
+ };
+
+ try {
+ await fetch(
+ URL_REGISTER_NOTIFICATION + `?accessToken=${accessToken}`,
+ options
+ );
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ async function registerForBrowserPushButtonPressed() {
+ // If it's already denied or granted, don't do anything.
+ if (Notification.permission !== 'default') {
+ return;
+ }
+
+ try {
+ const subscription = await registerWebPushNotifications(publicKey);
+ saveNotificationRegistration('BROWSER_PUSH_NOTIFICATION', subscription);
+ setBrowserRegistrationComplete(true);
+ setError(null);
+ } catch (e) {
+ setError(
+ `Error registering for notifications: ${e.message}. Check your browser notification permissions and try again.`
+ );
+ }
+ }
+
+ function onPhoneInput(e) {
+ const { value } = e.target;
+
+ // Since phone number validation is difficult let's just make sure
+ // it has a reasonable number of digits and doesn't include letters.
+
+ // Strip non-numeric characters.
+ const validated = value.replace(/[^\d]/g, '');
+
+ let valid = false;
+ if (validated.length >= 11 && validated.length <= 16) {
+ valid = true;
+ }
+
+ setPhoneNumber(validated);
+ setPhoneNotificationsButtonEnabled(valid);
+ }
+
+ function getBrowserPushButtonText() {
+ if (browserRegistrationComplete) {
+ return 'Done!';
+ }
+
+ let pushNotificationButtonText = 'Notify Me!';
+ if (Notification.permission === 'granted') {
+ pushNotificationButtonText = 'Enabled';
+ } else if (Notification.permission === 'denied') {
+ pushNotificationButtonText = 'Browser pushes denied';
+ }
+ return pushNotificationButtonText;
+ }
+
+ var gridColumns = 2;
+ if (browserPushEnabled && !textMessagesEnabled) {
+ gridColumns = 1;
+ } else if (!browserPushEnabled && textMessagesEnabled) {
+ gridColumns = 1;
+ }
+
+ const pushNotificationButtonText = getBrowserPushButtonText();
+
+ return html`
+ <div class="bg-gray-100 bg-center bg-no-repeat p-4">
+ <p class="text-gray-700 text-md font-semibold mb-2">
+ Never miss a stream! Get notified when ${streamName} goes live.
+ </p>
+
+ <div class="grid grid-cols-${gridColumns} gap-6">
+ <div style=${{ display: browserPushEnabled ? 'block' : 'none' }}>
+ <h2 class="text-indigo-600 text-3xl font-semibold">Browser</h2>
+ <p>Get notified inside your browser when ${streamName} goes live.</p>
+ <p class="mt-4">
+ Make sure you change the setting from ${' '}
+ <em>until I close this site</em> or you won't receive future
+ notifications.
+ </p>
+ <img
+ class="mt-4"
+ src="/img/browser-push-notifications-settings.png"
+ />
+ <div
+ style=${error ? 'display: block' : 'display: none'}
+ class="bg-red-100 border border-red-400 text-red-700 mt-5 px-4 py-3 rounded relative"
+ role="alert"
+ >
+ ${error}
+ </div>
+ <button
+ class="rounded-sm flex flex-row justify-center items-center overflow-hidden mt-5 px-3 py-1 text-base text-white bg-gray-800 ${browserNotificationsButtonState}"
+ onClick=${registerForBrowserPushButtonPressed}
+ >
+ ${pushNotificationButtonText}
+ </button>
+ </div>
+ <div style=${{ display: textMessagesEnabled ? 'block' : 'none' }}>
+ <h2 class="text-indigo-600 text-3xl font-semibold">Phone</h2>
+ <p>
+ No apps or 3rd party services required. Get notified directly when
+ ${''} ${streamName} goes live.
+ </p>
+ <p class="mt-4">
+ Respond to notifications with <strong>stop</strong> to stop
+ receiving them.
+ </p>
+ <input
+ class="border bg-white rounded w-full py-2 px-3 mb-2 mt-10 text-indigo-700 leading-tight focus:outline-none focus:shadow-outline"
+ type="tel"
+ maxlength="16"
+ value=${phoneNumber}
+ onInput=${onPhoneInput}
+ placeholder="Your phone number 14023133798"
+ />
+ <p class="text-gray-600 text-xs italic">
+ Provide your phone number with the international country code.
+ </p>
+
+ <button
+ class="rounded-sm flex flex-row justify-center items-center overflow-hidden mt-5 px-3 py-1 text-base text-white bg-gray-800 ${phoneNotificationButtonState}"
+ onClick=${registerForBrowserPushButtonPressed}
+ >
+ Notify Me!
+ </button>
+ </div>
+ </div>
+
+ <div
+ id="follow-loading-spinner-container"
+ style="display: ${loaderStyle}"
+ >
+ <img id="follow-loading-spinner" src="/img/loading.gif" />
+ <p class="text-gray-700 text-lg">Contacting your server.</p>
+ <p class="text-gray-600 text-lg">Please wait...</p>
+ </div>
+ </div>
+ `;
+}
+
+export function NotifyButton({ serverName, federationInfo, onClick }) {
+ const notifyAction = {
+ // color: 'rgba(28, 26, 59, 1)',
+ description: `Notify`,
+ icon: '/img/notification-bell.svg',
+ openExternally: false,
+ };
+
+ return html`
+ <span id="fediverse-follow-button-container">
+ <${ExternalActionButton} onClick=${onClick} action=${notifyAction} />
+ </span>
+ `;
+}
diff --git a/webroot/js/notification/registerWeb.js b/webroot/js/notification/registerWeb.js
new file mode 100644
index 000000000..32340f9ae
--- /dev/null
+++ b/webroot/js/notification/registerWeb.js
@@ -0,0 +1,26 @@
+export async function registerWebPushNotifications(vapidPublicKey) {
+ const registration = await navigator.serviceWorker.ready;
+ let subscription = await registration.pushManager.getSubscription();
+
+ if (!subscription) {
+ subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
+ });
+ }
+
+ return JSON.stringify(subscription);
+}
+
+export function isPushNotificationSupported() {
+ return 'serviceWorker' in navigator && 'PushManager' in window;
+}
+
+function urlBase64ToUint8Array(base64String) {
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
+ const base64 = (base64String + padding)
+ .replace(/\-/g, '+')
+ .replace(/_/g, '/');
+ const rawData = atob(base64);
+ return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
+}
diff --git a/webroot/js/utils/constants.js b/webroot/js/utils/constants.js
index adb59e04e..11189f412 100644
--- a/webroot/js/utils/constants.js
+++ b/webroot/js/utils/constants.js
@@ -18,6 +18,8 @@ export const URL_WEBSOCKET = `${
export const URL_CHAT_REGISTRATION = `/api/chat/register`;
export const URL_FOLLOWERS = `/api/followers`;
+export const URL_REGISTER_NOTIFICATION = `/api/notifications/register`;
+
export const TIMER_STATUS_UPDATE = 5000; // ms
export const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins
export const TIMER_STREAM_DURATION_COUNTER = 1000;
diff --git a/webroot/js/web_modules/htm.js b/webroot/js/web_modules/htm.js
index e986361d6..4c86298da 100644
--- a/webroot/js/web_modules/htm.js
+++ b/webroot/js/web_modules/htm.js
@@ -1,3 +1,3 @@
var n=function(t,s,r,e){var u;s[0]=0;for(var h=1;h<s.length;h++){var p=s[h++],a=s[h]?(s[0]|=p?1:2,r[s[h++]]):s[++h];3===p?e[0]=a:4===p?e[1]=Object.assign(e[1]||{},a):5===p?(e[1]=e[1]||{})[s[++h]]=a:6===p?e[1][s[++h]]+=a+"":p?(u=t.apply(a,n(t,a,r,["",null])),e.push(u),a[0]?s[0]|=2:(s[h-2]=0,s[h]=u)):e.push(a);}return e},t=new Map;function htm_module(s){var r=t.get(this);return r||(r=new Map,t.set(this,r)),(r=n(this,r.get(s)||(r.set(s,r=function(n){for(var t,s,r=1,e="",u="",h=[0],p=function(n){1===r&&(n||(e=e.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?h.push(0,n,e):3===r&&(n||e)?(h.push(3,n,e),r=2):2===r&&"..."===e&&n?h.push(4,n,0):2===r&&e&&!n?h.push(5,0,!0,e):r>=5&&((e||!n&&5===r)&&(h.push(r,0,e,s),r=6),n&&(h.push(r,n,0,s),r=6)),e="";},a=0;a<n.length;a++){a&&(1===r&&p(),p(a));for(var l=0;l<n[a].length;l++)t=n[a][l],1===r?"<"===t?(p(),h=[h],r=3):e+=t:4===r?"--"===e&&">"===t?(r=1,e=""):e=t+e[0]:u?t===u?u="":e+=t:'"'===t||"'"===t?u=t:">"===t?(p(),r=1):r&&("="===t?(r=5,s=e,e=""):"/"===t&&(r<5||">"===n[a][l+1])?(p(),3===r&&(h=h[0]),r=h,(h=h[0]).push(2,0,r),r=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(p(),r=2):e+=t),3===r&&"!--"===e&&(r=4,h=h[0]);}return p(),h}(s)),r),arguments,[])).length>1?r:r[0]}
-export { htm_module as default };
+export default htm_module;
diff --git a/webroot/js/web_modules/import-map.json b/webroot/js/web_modules/import-map.json
index f646538a0..12013ed2e 100644
--- a/webroot/js/web_modules/import-map.json
+++ b/webroot/js/web_modules/import-map.json
@@ -6,6 +6,7 @@
"mark.js/dist/mark.es6.min.js": "./markjs/dist/mark.es6.min.js",
"micromodal/dist/micromodal.min.js": "./micromodal/dist/micromodal.min.js",
"preact": "./preact.js",
+ "preact/hooks": "./preact/hooks.js",
"tailwindcss/dist/tailwind.min.css": "./tailwindcss/dist/tailwind.min.css",
"video.js/dist/video-js.min.css": "./videojs/dist/video-js.min.css",
"video.js/dist/video.min.js": "./videojs/dist/video.min.js"
diff --git a/webroot/js/web_modules/markjs/dist/mark.es6.min.js b/webroot/js/web_modules/markjs/dist/mark.es6.min.js
index 71d25821c..fd4c676ff 100644
--- a/webroot/js/web_modules/markjs/dist/mark.es6.min.js
+++ b/webroot/js/web_modules/markjs/dist/mark.es6.min.js
@@ -7,7 +7,7 @@ var mark_es6_min = createCommonjsModule(function (module, exports) {
* Copyright (c) 2014–2018, Julian Kühnel
* Released under the MIT license https://git.io/vwTVl
*****************************************************/
-!function(e,t){module.exports=t();}(commonjsGlobal,function(){class e{constructor(e,t=!0,s=[],r=5e3){this.ctx=e,this.iframes=t,this.exclude=s,this.iframesTimeout=r;}static matches(e,t){const s="string"==typeof t?[t]:t,r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.oMatchesSelector||e.webkitMatchesSelector;if(r){let t=!1;return s.every(s=>!r.call(e,s)||(t=!0,!1)),t}return !1}getContexts(){let t=[];return (void 0!==this.ctx&&this.ctx?NodeList.prototype.isPrototypeOf(this.ctx)?Array.prototype.slice.call(this.ctx):Array.isArray(this.ctx)?this.ctx:"string"==typeof this.ctx?Array.prototype.slice.call(document.querySelectorAll(this.ctx)):[this.ctx]:[]).forEach(e=>{const s=t.filter(t=>t.contains(e)).length>0;-1!==t.indexOf(e)||s||t.push(e);}),t}getIframeContents(e,t,s=(()=>{})){let r;try{const t=e.contentWindow;if(r=t.document,!t||!r)throw new Error("iframe inaccessible")}catch(e){s();}r&&t(r);}isIframeBlank(e){const t="about:blank",s=e.getAttribute("src").trim(),r=e.contentWindow.location.href;return r===t&&s!==t&&s}observeIframeLoad(e,t,s){let r=!1,i=null;const o=()=>{if(!r){r=!0,clearTimeout(i);try{this.isIframeBlank(e)||(e.removeEventListener("load",o),this.getIframeContents(e,t,s));}catch(e){s();}}};e.addEventListener("load",o),i=setTimeout(o,this.iframesTimeout);}onIframeReady(e,t,s){try{"complete"===e.contentWindow.document.readyState?this.isIframeBlank(e)?this.observeIframeLoad(e,t,s):this.getIframeContents(e,t,s):this.observeIframeLoad(e,t,s);}catch(e){s();}}waitForIframes(e,t){let s=0;this.forEachIframe(e,()=>!0,e=>{s++,this.waitForIframes(e.querySelector("html"),()=>{--s||t();});},e=>{e||t();});}forEachIframe(t,s,r,i=(()=>{})){let o=t.querySelectorAll("iframe"),n=o.length,a=0;o=Array.prototype.slice.call(o);const c=()=>{--n<=0&&i(a);};n||c(),o.forEach(t=>{e.matches(t,this.exclude)?c():this.onIframeReady(t,e=>{s(t)&&(a++,r(e)),c();},c);});}createIterator(e,t,s){return document.createNodeIterator(e,t,s,!1)}createInstanceOnIframe(t){return new e(t.querySelector("html"),this.iframes)}compareNodeIframe(e,t,s){const r=e.compareDocumentPosition(s),i=Node.DOCUMENT_POSITION_PRECEDING;if(r&i){if(null===t)return !0;if(t.compareDocumentPosition(s)&Node.DOCUMENT_POSITION_FOLLOWING)return !0}return !1}getIteratorNode(e){const t=e.previousNode();return {prevNode:t,node:null===t?e.nextNode():e.nextNode()&&e.nextNode()}}checkIframeFilter(e,t,s,r){let i=!1,o=!1;return r.forEach((e,t)=>{e.val===s&&(i=t,o=e.handled);}),this.compareNodeIframe(e,t,s)?(!1!==i||o?!1===i||o||(r[i].handled=!0):r.push({val:s,handled:!0}),!0):(!1===i&&r.push({val:s,handled:!1}),!1)}handleOpenIframes(e,t,s,r){e.forEach(e=>{e.handled||this.getIframeContents(e.val,e=>{this.createInstanceOnIframe(e).forEachNode(t,s,r);});});}iterateThroughNodes(e,t,s,r,i){const o=this.createIterator(t,e,r);let n,a,c=[],h=[],l=()=>((({prevNode:a,node:n}=this.getIteratorNode(o))),n);for(;l();)this.iframes&&this.forEachIframe(t,e=>this.checkIframeFilter(n,a,e,c),t=>{this.createInstanceOnIframe(t).forEachNode(e,e=>h.push(e),r);}),h.push(n);h.forEach(e=>{s(e);}),this.iframes&&this.handleOpenIframes(c,e,s,r),i();}forEachNode(e,t,s,r=(()=>{})){const i=this.getContexts();let o=i.length;o||r(),i.forEach(i=>{const n=()=>{this.iterateThroughNodes(e,i,t,s,()=>{--o<=0&&r();});};this.iframes?this.waitForIframes(i,n):n();});}}class t{constructor(e){this.ctx=e,this.ie=!1;const t=window.navigator.userAgent;(t.indexOf("MSIE")>-1||t.indexOf("Trident")>-1)&&(this.ie=!0);}set opt(e){this._opt=Object.assign({},{element:"",className:"",exclude:[],iframes:!1,iframesTimeout:5e3,separateWordSearch:!0,diacritics:!0,synonyms:{},accuracy:"partially",acrossElements:!1,caseSensitive:!1,ignoreJoiners:!1,ignoreGroups:0,ignorePunctuation:[],wildcards:"disabled",each:()=>{},noMatch:()=>{},filter:()=>!0,done:()=>{},debug:!1,log:window.console},e);}get opt(){return this._opt}get iterator(){return new e(this.ctx,this.opt.iframes,this.opt.exclude,this.opt.iframesTimeout)}log(e,t="debug"){const s=this.opt.log;this.opt.debug&&"object"==typeof s&&"function"==typeof s[t]&&s[t](`mark.js: ${e}`);}escapeStr(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")}createRegExp(e){return "disabled"!==this.opt.wildcards&&(e=this.setupWildcardsRegExp(e)),e=this.escapeStr(e),Object.keys(this.opt.synonyms).length&&(e=this.createSynonymsRegExp(e)),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),this.opt.diacritics&&(e=this.createDiacriticsRegExp(e)),e=this.createMergedBlanksRegExp(e),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.createJoinersRegExp(e)),"disabled"!==this.opt.wildcards&&(e=this.createWildcardsRegExp(e)),e=this.createAccuracyRegExp(e)}createSynonymsRegExp(e){const t=this.opt.synonyms,s=this.opt.caseSensitive?"":"i",r=this.opt.ignoreJoiners||this.opt.ignorePunctuation.length?"\0":"";for(let i in t)if(t.hasOwnProperty(i)){const o=t[i],n="disabled"!==this.opt.wildcards?this.setupWildcardsRegExp(i):this.escapeStr(i),a="disabled"!==this.opt.wildcards?this.setupWildcardsRegExp(o):this.escapeStr(o);""!==n&&""!==a&&(e=e.replace(new RegExp(`(${this.escapeStr(n)}|${this.escapeStr(a)})`,`gm${s}`),r+`(${this.processSynomyms(n)}|`+`${this.processSynomyms(a)})`+r));}return e}processSynomyms(e){return (this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),e}setupWildcardsRegExp(e){return (e=e.replace(/(?:\\)*\?/g,e=>"\\"===e.charAt(0)?"?":"")).replace(/(?:\\)*\*/g,e=>"\\"===e.charAt(0)?"*":"")}createWildcardsRegExp(e){let t="withSpaces"===this.opt.wildcards;return e.replace(/\u0001/g,t?"[\\S\\s]?":"\\S?").replace(/\u0002/g,t?"[\\S\\s]*?":"\\S*")}setupIgnoreJoinersRegExp(e){return e.replace(/[^(|)\\]/g,(e,t,s)=>{let r=s.charAt(t+1);return /[(|)\\]/.test(r)||""===r?e:e+"\0"})}createJoinersRegExp(e){let t=[];const s=this.opt.ignorePunctuation;return Array.isArray(s)&&s.length&&t.push(this.escapeStr(s.join(""))),this.opt.ignoreJoiners&&t.push("\\u00ad\\u200b\\u200c\\u200d"),t.length?e.split(/\u0000+/).join(`[${t.join("")}]*`):e}createDiacriticsRegExp(e){const t=this.opt.caseSensitive?"":"i",s=this.opt.caseSensitive?["aàáảãạăằắẳẵặâầấẩẫậäåāą","AÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćč","CÇĆČ","dđď","DĐĎ","eèéẻẽẹêềếểễệëěēę","EÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïī","IÌÍỈĨỊÎÏĪ","lł","LŁ","nñňń","NÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøō","OÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rř","RŘ","sšśșş","SŠŚȘŞ","tťțţ","TŤȚŢ","uùúủũụưừứửữựûüůū","UÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿ","YÝỲỶỸỴŸ","zžżź","ZŽŻŹ"]:["aàáảãạăằắẳẵặâầấẩẫậäåāąAÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćčCÇĆČ","dđďDĐĎ","eèéẻẽẹêềếểễệëěēęEÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïīIÌÍỈĨỊÎÏĪ","lłLŁ","nñňńNÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøōOÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rřRŘ","sšśșşSŠŚȘŞ","tťțţTŤȚŢ","uùúủũụưừứửữựûüůūUÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿYÝỲỶỸỴŸ","zžżźZŽŻŹ"];let r=[];return e.split("").forEach(i=>{s.every(s=>{if(-1!==s.indexOf(i)){if(r.indexOf(s)>-1)return !1;e=e.replace(new RegExp(`[${s}]`,`gm${t}`),`[${s}]`),r.push(s);}return !0});}),e}createMergedBlanksRegExp(e){return e.replace(/[\s]+/gim,"[\\s]+")}createAccuracyRegExp(e){const t="!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~¡¿";let s=this.opt.accuracy,r="string"==typeof s?s:s.value,i="string"==typeof s?[]:s.limiters,o="";switch(i.forEach(e=>{o+=`|${this.escapeStr(e)}`;}),r){case"partially":default:return `()(${e})`;case"complementary":return `()([^${o="\\s"+(o||this.escapeStr(t))}]*${e}[^${o}]*)`;case"exactly":return `(^|\\s${o})(${e})(?=$|\\s${o})`}}getSeparatedKeywords(e){let t=[];return e.forEach(e=>{this.opt.separateWordSearch?e.split(" ").forEach(e=>{e.trim()&&-1===t.indexOf(e)&&t.push(e);}):e.trim()&&-1===t.indexOf(e)&&t.push(e);}),{keywords:t.sort((e,t)=>t.length-e.length),length:t.length}}isNumeric(e){return Number(parseFloat(e))==e}checkRanges(e){if(!Array.isArray(e)||"[object Object]"!==Object.prototype.toString.call(e[0]))return this.log("markRanges() will only accept an array of objects"),this.opt.noMatch(e),[];const t=[];let s=0;return e.sort((e,t)=>e.start-t.start).forEach(e=>{let{start:r,end:i,valid:o}=this.callNoMatchOnInvalidRanges(e,s);o&&(e.start=r,e.length=i-r,t.push(e),s=i);}),t}callNoMatchOnInvalidRanges(e,t){let s,r,i=!1;return e&&void 0!==e.start?(r=(s=parseInt(e.start,10))+parseInt(e.length,10),this.isNumeric(e.start)&&this.isNumeric(e.length)&&r-t>0&&r-s>0?i=!0:(this.log("Ignoring invalid or overlapping range: "+`${JSON.stringify(e)}`),this.opt.noMatch(e))):(this.log(`Ignoring invalid range: ${JSON.stringify(e)}`),this.opt.noMatch(e)),{start:s,end:r,valid:i}}checkWhitespaceRanges(e,t,s){let r,i=!0,o=s.length,n=t-o,a=parseInt(e.start,10)-n;return (r=(a=a>o?o:a)+parseInt(e.length,10))>o&&(r=o,this.log(`End range automatically set to the max value of ${o}`)),a<0||r-a<0||a>o||r>o?(i=!1,this.log(`Invalid range: ${JSON.stringify(e)}`),this.opt.noMatch(e)):""===s.substring(a,r).replace(/\s+/g,"")&&(i=!1,this.log("Skipping whitespace only range: "+JSON.stringify(e)),this.opt.noMatch(e)),{start:a,end:r,valid:i}}getTextNodes(e){let t="",s=[];this.iterator.forEachNode(NodeFilter.SHOW_TEXT,e=>{s.push({start:t.length,end:(t+=e.textContent).length,node:e});},e=>this.matchesExclude(e.parentNode)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT,()=>{e({value:t,nodes:s});});}matchesExclude(t){return e.matches(t,this.opt.exclude.concat(["script","style","title","head","html"]))}wrapRangeInTextNode(e,t,s){const r=this.opt.element?this.opt.element:"mark",i=e.splitText(t),o=i.splitText(s-t);let n=document.createElement(r);return n.setAttribute("data-markjs","true"),this.opt.className&&n.setAttribute("class",this.opt.className),n.textContent=i.textContent,i.parentNode.replaceChild(n,i),o}wrapRangeInMappedTextNode(e,t,s,r,i){e.nodes.every((o,n)=>{const a=e.nodes[n+1];if(void 0===a||a.start>t){if(!r(o.node))return !1;const a=t-o.start,c=(s>o.end?o.end:s)-o.start,h=e.value.substr(0,o.start),l=e.value.substr(c+o.start);if(o.node=this.wrapRangeInTextNode(o.node,a,c),e.value=h+l,e.nodes.forEach((t,s)=>{s>=n&&(e.nodes[s].start>0&&s!==n&&(e.nodes[s].start-=c),e.nodes[s].end-=c);}),s-=c,i(o.node.previousSibling,o.start),!(s>o.end))return !1;t=o.end;}return !0});}wrapMatches(e,t,s,r,i){const o=0===t?0:t+1;this.getTextNodes(t=>{t.nodes.forEach(t=>{let i;for(t=t.node;null!==(i=e.exec(t.textContent))&&""!==i[o];){if(!s(i[o],t))continue;let n=i.index;if(0!==o)for(let e=1;e<o;e++)n+=i[e].length;t=this.wrapRangeInTextNode(t,n,n+i[o].length),r(t.previousSibling),e.lastIndex=0;}}),i();});}wrapMatchesAcrossElements(e,t,s,r,i){const o=0===t?0:t+1;this.getTextNodes(t=>{let n;for(;null!==(n=e.exec(t.value))&&""!==n[o];){let i=n.index;if(0!==o)for(let e=1;e<o;e++)i+=n[e].length;const a=i+n[o].length;this.wrapRangeInMappedTextNode(t,i,a,e=>s(n[o],e),(t,s)=>{e.lastIndex=s,r(t);});}i();});}wrapRangeFromIndex(e,t,s,r){this.getTextNodes(i=>{const o=i.value.length;e.forEach((e,r)=>{let{start:n,end:a,valid:c}=this.checkWhitespaceRanges(e,o,i.value);c&&this.wrapRangeInMappedTextNode(i,n,a,s=>t(s,e,i.value.substring(n,a),r),t=>{s(t,e);});}),r();});}unwrapMatches(e){const t=e.parentNode;let s=document.createDocumentFragment();for(;e.firstChild;)s.appendChild(e.removeChild(e.firstChild));t.replaceChild(s,e),this.ie?this.normalizeTextNode(t):t.normalize();}normalizeTextNode(e){if(e){if(3===e.nodeType)for(;e.nextSibling&&3===e.nextSibling.nodeType;)e.nodeValue+=e.nextSibling.nodeValue,e.parentNode.removeChild(e.nextSibling);else this.normalizeTextNode(e.firstChild);this.normalizeTextNode(e.nextSibling);}}markRegExp(e,t){this.opt=t,this.log(`Searching with expression "${e}"`);let s=0,r="wrapMatches";const i=e=>{s++,this.opt.each(e);};this.opt.acrossElements&&(r="wrapMatchesAcrossElements"),this[r](e,this.opt.ignoreGroups,(e,t)=>this.opt.filter(t,e,s),i,()=>{0===s&&this.opt.noMatch(e),this.opt.done(s);});}mark(e,t){this.opt=t;let s=0,r="wrapMatches";const{keywords:i,length:o}=this.getSeparatedKeywords("string"==typeof e?[e]:e),n=this.opt.caseSensitive?"":"i",a=e=>{let t=new RegExp(this.createRegExp(e),`gm${n}`),c=0;this.log(`Searching with expression "${t}"`),this[r](t,1,(t,r)=>this.opt.filter(r,e,s,c),e=>{c++,s++,this.opt.each(e);},()=>{0===c&&this.opt.noMatch(e),i[o-1]===e?this.opt.done(s):a(i[i.indexOf(e)+1]);});};this.opt.acrossElements&&(r="wrapMatchesAcrossElements"),0===o?this.opt.done(s):a(i[0]);}markRanges(e,t){this.opt=t;let s=0,r=this.checkRanges(e);r&&r.length?(this.log("Starting to mark with the following ranges: "+JSON.stringify(r)),this.wrapRangeFromIndex(r,(e,t,s,r)=>this.opt.filter(e,t,s,r),(e,t)=>{s++,this.opt.each(e,t);},()=>{this.opt.done(s);})):this.opt.done(s);}unmark(t){this.opt=t;let s=this.opt.element?this.opt.element:"*";s+="[data-markjs]",this.opt.className&&(s+=`.${this.opt.className}`),this.log(`Removal selector "${s}"`),this.iterator.forEachNode(NodeFilter.SHOW_ELEMENT,e=>{this.unwrapMatches(e);},t=>{const r=e.matches(t,s),i=this.matchesExclude(t);return !r||i?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT},this.opt.done);}}return function(e){const s=new t(e);return this.mark=((e,t)=>(s.mark(e,t),this)),this.markRegExp=((e,t)=>(s.markRegExp(e,t),this)),this.markRanges=((e,t)=>(s.markRanges(e,t),this)),this.unmark=(e=>(s.unmark(e),this)),this}});
+!function(e,t){module.exports=t();}(commonjsGlobal,function(){class e{constructor(e,t=!0,s=[],r=5e3){this.ctx=e,this.iframes=t,this.exclude=s,this.iframesTimeout=r;}static matches(e,t){const s="string"==typeof t?[t]:t,r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.oMatchesSelector||e.webkitMatchesSelector;if(r){let t=!1;return s.every(s=>!r.call(e,s)||(t=!0,!1)),t}return !1}getContexts(){let t=[];return (void 0!==this.ctx&&this.ctx?NodeList.prototype.isPrototypeOf(this.ctx)?Array.prototype.slice.call(this.ctx):Array.isArray(this.ctx)?this.ctx:"string"==typeof this.ctx?Array.prototype.slice.call(document.querySelectorAll(this.ctx)):[this.ctx]:[]).forEach(e=>{const s=t.filter(t=>t.contains(e)).length>0;-1!==t.indexOf(e)||s||t.push(e);}),t}getIframeContents(e,t,s=(()=>{})){let r;try{const t=e.contentWindow;if(r=t.document,!t||!r)throw new Error("iframe inaccessible")}catch(e){s();}r&&t(r);}isIframeBlank(e){const t="about:blank",s=e.getAttribute("src").trim(),r=e.contentWindow.location.href;return r===t&&s!==t&&s}observeIframeLoad(e,t,s){let r=!1,i=null;const o=()=>{if(!r){r=!0,clearTimeout(i);try{this.isIframeBlank(e)||(e.removeEventListener("load",o),this.getIframeContents(e,t,s));}catch(e){s();}}};e.addEventListener("load",o),i=setTimeout(o,this.iframesTimeout);}onIframeReady(e,t,s){try{"complete"===e.contentWindow.document.readyState?this.isIframeBlank(e)?this.observeIframeLoad(e,t,s):this.getIframeContents(e,t,s):this.observeIframeLoad(e,t,s);}catch(e){s();}}waitForIframes(e,t){let s=0;this.forEachIframe(e,()=>!0,e=>{s++,this.waitForIframes(e.querySelector("html"),()=>{--s||t();});},e=>{e||t();});}forEachIframe(t,s,r,i=(()=>{})){let o=t.querySelectorAll("iframe"),n=o.length,a=0;o=Array.prototype.slice.call(o);const c=()=>{--n<=0&&i(a);};n||c(),o.forEach(t=>{e.matches(t,this.exclude)?c():this.onIframeReady(t,e=>{s(t)&&(a++,r(e)),c();},c);});}createIterator(e,t,s){return document.createNodeIterator(e,t,s,!1)}createInstanceOnIframe(t){return new e(t.querySelector("html"),this.iframes)}compareNodeIframe(e,t,s){const r=e.compareDocumentPosition(s),i=Node.DOCUMENT_POSITION_PRECEDING;if(r&i){if(null===t)return !0;if(t.compareDocumentPosition(s)&Node.DOCUMENT_POSITION_FOLLOWING)return !0}return !1}getIteratorNode(e){const t=e.previousNode();return {prevNode:t,node:null===t?e.nextNode():e.nextNode()&&e.nextNode()}}checkIframeFilter(e,t,s,r){let i=!1,o=!1;return r.forEach((e,t)=>{e.val===s&&(i=t,o=e.handled);}),this.compareNodeIframe(e,t,s)?(!1!==i||o?!1===i||o||(r[i].handled=!0):r.push({val:s,handled:!0}),!0):(!1===i&&r.push({val:s,handled:!1}),!1)}handleOpenIframes(e,t,s,r){e.forEach(e=>{e.handled||this.getIframeContents(e.val,e=>{this.createInstanceOnIframe(e).forEachNode(t,s,r);});});}iterateThroughNodes(e,t,s,r,i){const o=this.createIterator(t,e,r);let n,a,c=[],h=[],l=()=>(({prevNode:a,node:n}=this.getIteratorNode(o)),n);for(;l();)this.iframes&&this.forEachIframe(t,e=>this.checkIframeFilter(n,a,e,c),t=>{this.createInstanceOnIframe(t).forEachNode(e,e=>h.push(e),r);}),h.push(n);h.forEach(e=>{s(e);}),this.iframes&&this.handleOpenIframes(c,e,s,r),i();}forEachNode(e,t,s,r=(()=>{})){const i=this.getContexts();let o=i.length;o||r(),i.forEach(i=>{const n=()=>{this.iterateThroughNodes(e,i,t,s,()=>{--o<=0&&r();});};this.iframes?this.waitForIframes(i,n):n();});}}class t{constructor(e){this.ctx=e,this.ie=!1;const t=window.navigator.userAgent;(t.indexOf("MSIE")>-1||t.indexOf("Trident")>-1)&&(this.ie=!0);}set opt(e){this._opt=Object.assign({},{element:"",className:"",exclude:[],iframes:!1,iframesTimeout:5e3,separateWordSearch:!0,diacritics:!0,synonyms:{},accuracy:"partially",acrossElements:!1,caseSensitive:!1,ignoreJoiners:!1,ignoreGroups:0,ignorePunctuation:[],wildcards:"disabled",each:()=>{},noMatch:()=>{},filter:()=>!0,done:()=>{},debug:!1,log:window.console},e);}get opt(){return this._opt}get iterator(){return new e(this.ctx,this.opt.iframes,this.opt.exclude,this.opt.iframesTimeout)}log(e,t="debug"){const s=this.opt.log;this.opt.debug&&"object"==typeof s&&"function"==typeof s[t]&&s[t](`mark.js: ${e}`);}escapeStr(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")}createRegExp(e){return "disabled"!==this.opt.wildcards&&(e=this.setupWildcardsRegExp(e)),e=this.escapeStr(e),Object.keys(this.opt.synonyms).length&&(e=this.createSynonymsRegExp(e)),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),this.opt.diacritics&&(e=this.createDiacriticsRegExp(e)),e=this.createMergedBlanksRegExp(e),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.createJoinersRegExp(e)),"disabled"!==this.opt.wildcards&&(e=this.createWildcardsRegExp(e)),e=this.createAccuracyRegExp(e)}createSynonymsRegExp(e){const t=this.opt.synonyms,s=this.opt.caseSensitive?"":"i",r=this.opt.ignoreJoiners||this.opt.ignorePunctuation.length?"\0":"";for(let i in t)if(t.hasOwnProperty(i)){const o=t[i],n="disabled"!==this.opt.wildcards?this.setupWildcardsRegExp(i):this.escapeStr(i),a="disabled"!==this.opt.wildcards?this.setupWildcardsRegExp(o):this.escapeStr(o);""!==n&&""!==a&&(e=e.replace(new RegExp(`(${this.escapeStr(n)}|${this.escapeStr(a)})`,`gm${s}`),r+`(${this.processSynomyms(n)}|`+`${this.processSynomyms(a)})`+r));}return e}processSynomyms(e){return (this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),e}setupWildcardsRegExp(e){return (e=e.replace(/(?:\\)*\?/g,e=>"\\"===e.charAt(0)?"?":"")).replace(/(?:\\)*\*/g,e=>"\\"===e.charAt(0)?"*":"")}createWildcardsRegExp(e){let t="withSpaces"===this.opt.wildcards;return e.replace(/\u0001/g,t?"[\\S\\s]?":"\\S?").replace(/\u0002/g,t?"[\\S\\s]*?":"\\S*")}setupIgnoreJoinersRegExp(e){return e.replace(/[^(|)\\]/g,(e,t,s)=>{let r=s.charAt(t+1);return /[(|)\\]/.test(r)||""===r?e:e+"\0"})}createJoinersRegExp(e){let t=[];const s=this.opt.ignorePunctuation;return Array.isArray(s)&&s.length&&t.push(this.escapeStr(s.join(""))),this.opt.ignoreJoiners&&t.push("\\u00ad\\u200b\\u200c\\u200d"),t.length?e.split(/\u0000+/).join(`[${t.join("")}]*`):e}createDiacriticsRegExp(e){const t=this.opt.caseSensitive?"":"i",s=this.opt.caseSensitive?["aàáảãạăằắẳẵặâầấẩẫậäåāą","AÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćč","CÇĆČ","dđď","DĐĎ","eèéẻẽẹêềếểễệëěēę","EÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïī","IÌÍỈĨỊÎÏĪ","lł","LŁ","nñňń","NÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøō","OÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rř","RŘ","sšśșş","SŠŚȘŞ","tťțţ","TŤȚŢ","uùúủũụưừứửữựûüůū","UÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿ","YÝỲỶỸỴŸ","zžżź","ZŽŻŹ"]:["aàáảãạăằắẳẵặâầấẩẫậäåāąAÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćčCÇĆČ","dđďDĐĎ","eèéẻẽẹêềếểễệëěēęEÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïīIÌÍỈĨỊÎÏĪ","lłLŁ","nñňńNÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøōOÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rřRŘ","sšśșşSŠŚȘŞ","tťțţTŤȚŢ","uùúủũụưừứửữựûüůūUÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿYÝỲỶỸỴŸ","zžżźZŽŻŹ"];let r=[];return e.split("").forEach(i=>{s.every(s=>{if(-1!==s.indexOf(i)){if(r.indexOf(s)>-1)return !1;e=e.replace(new RegExp(`[${s}]`,`gm${t}`),`[${s}]`),r.push(s);}return !0});}),e}createMergedBlanksRegExp(e){return e.replace(/[\s]+/gim,"[\\s]+")}createAccuracyRegExp(e){const t="!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~¡¿";let s=this.opt.accuracy,r="string"==typeof s?s:s.value,i="string"==typeof s?[]:s.limiters,o="";switch(i.forEach(e=>{o+=`|${this.escapeStr(e)}`;}),r){case"partially":default:return `()(${e})`;case"complementary":return `()([^${o="\\s"+(o||this.escapeStr(t))}]*${e}[^${o}]*)`;case"exactly":return `(^|\\s${o})(${e})(?=$|\\s${o})`}}getSeparatedKeywords(e){let t=[];return e.forEach(e=>{this.opt.separateWordSearch?e.split(" ").forEach(e=>{e.trim()&&-1===t.indexOf(e)&&t.push(e);}):e.trim()&&-1===t.indexOf(e)&&t.push(e);}),{keywords:t.sort((e,t)=>t.length-e.length),length:t.length}}isNumeric(e){return Number(parseFloat(e))==e}checkRanges(e){if(!Array.isArray(e)||"[object Object]"!==Object.prototype.toString.call(e[0]))return this.log("markRanges() will only accept an array of objects"),this.opt.noMatch(e),[];const t=[];let s=0;return e.sort((e,t)=>e.start-t.start).forEach(e=>{let{start:r,end:i,valid:o}=this.callNoMatchOnInvalidRanges(e,s);o&&(e.start=r,e.length=i-r,t.push(e),s=i);}),t}callNoMatchOnInvalidRanges(e,t){let s,r,i=!1;return e&&void 0!==e.start?(r=(s=parseInt(e.start,10))+parseInt(e.length,10),this.isNumeric(e.start)&&this.isNumeric(e.length)&&r-t>0&&r-s>0?i=!0:(this.log("Ignoring invalid or overlapping range: "+`${JSON.stringify(e)}`),this.opt.noMatch(e))):(this.log(`Ignoring invalid range: ${JSON.stringify(e)}`),this.opt.noMatch(e)),{start:s,end:r,valid:i}}checkWhitespaceRanges(e,t,s){let r,i=!0,o=s.length,n=t-o,a=parseInt(e.start,10)-n;return (r=(a=a>o?o:a)+parseInt(e.length,10))>o&&(r=o,this.log(`End range automatically set to the max value of ${o}`)),a<0||r-a<0||a>o||r>o?(i=!1,this.log(`Invalid range: ${JSON.stringify(e)}`),this.opt.noMatch(e)):""===s.substring(a,r).replace(/\s+/g,"")&&(i=!1,this.log("Skipping whitespace only range: "+JSON.stringify(e)),this.opt.noMatch(e)),{start:a,end:r,valid:i}}getTextNodes(e){let t="",s=[];this.iterator.forEachNode(NodeFilter.SHOW_TEXT,e=>{s.push({start:t.length,end:(t+=e.textContent).length,node:e});},e=>this.matchesExclude(e.parentNode)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT,()=>{e({value:t,nodes:s});});}matchesExclude(t){return e.matches(t,this.opt.exclude.concat(["script","style","title","head","html"]))}wrapRangeInTextNode(e,t,s){const r=this.opt.element?this.opt.element:"mark",i=e.splitText(t),o=i.splitText(s-t);let n=document.createElement(r);return n.setAttribute("data-markjs","true"),this.opt.className&&n.setAttribute("class",this.opt.className),n.textContent=i.textContent,i.parentNode.replaceChild(n,i),o}wrapRangeInMappedTextNode(e,t,s,r,i){e.nodes.every((o,n)=>{const a=e.nodes[n+1];if(void 0===a||a.start>t){if(!r(o.node))return !1;const a=t-o.start,c=(s>o.end?o.end:s)-o.start,h=e.value.substr(0,o.start),l=e.value.substr(c+o.start);if(o.node=this.wrapRangeInTextNode(o.node,a,c),e.value=h+l,e.nodes.forEach((t,s)=>{s>=n&&(e.nodes[s].start>0&&s!==n&&(e.nodes[s].start-=c),e.nodes[s].end-=c);}),s-=c,i(o.node.previousSibling,o.start),!(s>o.end))return !1;t=o.end;}return !0});}wrapMatches(e,t,s,r,i){const o=0===t?0:t+1;this.getTextNodes(t=>{t.nodes.forEach(t=>{let i;for(t=t.node;null!==(i=e.exec(t.textContent))&&""!==i[o];){if(!s(i[o],t))continue;let n=i.index;if(0!==o)for(let e=1;e<o;e++)n+=i[e].length;t=this.wrapRangeInTextNode(t,n,n+i[o].length),r(t.previousSibling),e.lastIndex=0;}}),i();});}wrapMatchesAcrossElements(e,t,s,r,i){const o=0===t?0:t+1;this.getTextNodes(t=>{let n;for(;null!==(n=e.exec(t.value))&&""!==n[o];){let i=n.index;if(0!==o)for(let e=1;e<o;e++)i+=n[e].length;const a=i+n[o].length;this.wrapRangeInMappedTextNode(t,i,a,e=>s(n[o],e),(t,s)=>{e.lastIndex=s,r(t);});}i();});}wrapRangeFromIndex(e,t,s,r){this.getTextNodes(i=>{const o=i.value.length;e.forEach((e,r)=>{let{start:n,end:a,valid:c}=this.checkWhitespaceRanges(e,o,i.value);c&&this.wrapRangeInMappedTextNode(i,n,a,s=>t(s,e,i.value.substring(n,a),r),t=>{s(t,e);});}),r();});}unwrapMatches(e){const t=e.parentNode;let s=document.createDocumentFragment();for(;e.firstChild;)s.appendChild(e.removeChild(e.firstChild));t.replaceChild(s,e),this.ie?this.normalizeTextNode(t):t.normalize();}normalizeTextNode(e){if(e){if(3===e.nodeType)for(;e.nextSibling&&3===e.nextSibling.nodeType;)e.nodeValue+=e.nextSibling.nodeValue,e.parentNode.removeChild(e.nextSibling);else this.normalizeTextNode(e.firstChild);this.normalizeTextNode(e.nextSibling);}}markRegExp(e,t){this.opt=t,this.log(`Searching with expression "${e}"`);let s=0,r="wrapMatches";const i=e=>{s++,this.opt.each(e);};this.opt.acrossElements&&(r="wrapMatchesAcrossElements"),this[r](e,this.opt.ignoreGroups,(e,t)=>this.opt.filter(t,e,s),i,()=>{0===s&&this.opt.noMatch(e),this.opt.done(s);});}mark(e,t){this.opt=t;let s=0,r="wrapMatches";const{keywords:i,length:o}=this.getSeparatedKeywords("string"==typeof e?[e]:e),n=this.opt.caseSensitive?"":"i",a=e=>{let t=new RegExp(this.createRegExp(e),`gm${n}`),c=0;this.log(`Searching with expression "${t}"`),this[r](t,1,(t,r)=>this.opt.filter(r,e,s,c),e=>{c++,s++,this.opt.each(e);},()=>{0===c&&this.opt.noMatch(e),i[o-1]===e?this.opt.done(s):a(i[i.indexOf(e)+1]);});};this.opt.acrossElements&&(r="wrapMatchesAcrossElements"),0===o?this.opt.done(s):a(i[0]);}markRanges(e,t){this.opt=t;let s=0,r=this.checkRanges(e);r&&r.length?(this.log("Starting to mark with the following ranges: "+JSON.stringify(r)),this.wrapRangeFromIndex(r,(e,t,s,r)=>this.opt.filter(e,t,s,r),(e,t)=>{s++,this.opt.each(e,t);},()=>{this.opt.done(s);})):this.opt.done(s);}unmark(t){this.opt=t;let s=this.opt.element?this.opt.element:"*";s+="[data-markjs]",this.opt.className&&(s+=`.${this.opt.className}`),this.log(`Removal selector "${s}"`),this.iterator.forEachNode(NodeFilter.SHOW_ELEMENT,e=>{this.unwrapMatches(e);},t=>{const r=e.matches(t,s),i=this.matchesExclude(t);return !r||i?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT},this.opt.done);}}return function(e){const s=new t(e);return this.mark=((e,t)=>(s.mark(e,t),this)),this.markRegExp=((e,t)=>(s.markRegExp(e,t),this)),this.markRanges=((e,t)=>(s.markRanges(e,t),this)),this.unmark=(e=>(s.unmark(e),this)),this}});
});
-export { mark_es6_min as default };
+export default mark_es6_min;
diff --git a/webroot/js/web_modules/micromodal/dist/micromodal.min.js b/webroot/js/web_modules/micromodal/dist/micromodal.min.js
index 931ba8591..abb52d3e7 100644
--- a/webroot/js/web_modules/micromodal/dist/micromodal.min.js
+++ b/webroot/js/web_modules/micromodal/dist/micromodal.min.js
@@ -5,6 +5,7 @@ var micromodal_min = createCommonjsModule(function (module, exports) {
});
var close = micromodal_min.close;
+export default micromodal_min;
var init = micromodal_min.init;
var show = micromodal_min.show;
-export { micromodal_min as __moduleExports, close, micromodal_min as default, init, show };
+export { micromodal_min as __moduleExports, close, init, show };
diff --git a/webroot/js/web_modules/preact/hooks.js b/webroot/js/web_modules/preact/hooks.js
new file mode 100644
index 000000000..74ae57744
--- /dev/null
+++ b/webroot/js/web_modules/preact/hooks.js
@@ -0,0 +1,5 @@
+import { options as l$1 } from '../preact.js';
+
+var t,u,r,o=0,i=[],c=l$1.__b,f=l$1.__r,e=l$1.diffed,a=l$1.__c,v=l$1.unmount;function m(t,r){l$1.__h&&l$1.__h(u,t,o||r),o=0;var i=u.__H||(u.__H={__:[],__h:[]});return t>=i.__.length&&i.__.push({}),i.__[t]}function l(n){return o=1,p(w,n)}function p(n,r,o){var i=m(t++,2);return i.t=n,i.__c||(i.__=[o?o(r):w(void 0,r),function(n){var t=i.t(i.__[0],n);i.__[0]!==t&&(i.__=[t,i.__[1]],i.__c.setState({}));}],i.__c=u),i.__}function y(r,o){var i=m(t++,3);!l$1.__s&&k(i.__H,o)&&(i.__=r,i.__H=o,u.__H.__h.push(i));}function h(r,o){var i=m(t++,4);!l$1.__s&&k(i.__H,o)&&(i.__=r,i.__H=o,u.__h.push(i));}function s(n){return o=5,d(function(){return {current:n}},[])}function _(n,t,u){o=6,h(function(){"function"==typeof n?n(t()):n&&(n.current=t());},null==u?u:u.concat(n));}function d(n,u){var r=m(t++,7);return k(r.__H,u)&&(r.__=n(),r.__H=u,r.__h=n),r.__}function A(n,t){return o=8,d(function(){return n},t)}function F(n){var r=u.context[n.__c],o=m(t++,9);return o.c=n,r?(null==o.__&&(o.__=!0,r.sub(u)),r.props.value):n.__}function T(t,u){l$1.useDebugValue&&l$1.useDebugValue(u?u(t):t);}function q(n){var r=m(t++,10),o=l();return r.__=n,u.componentDidCatch||(u.componentDidCatch=function(n){r.__&&r.__(n),o[1](n);}),[o[0],function(){o[1](void 0);}]}function x(){var t;for(i.sort(function(n,t){return n.__v.__b-t.__v.__b});t=i.pop();)if(t.__P)try{t.__H.__h.forEach(g),t.__H.__h.forEach(j),t.__H.__h=[];}catch(u){t.__H.__h=[],l$1.__e(u,t.__v);}}l$1.__b=function(n){u=null,c&&c(n);},l$1.__r=function(n){f&&f(n),t=0;var r=(u=n.__c).__H;r&&(r.__h.forEach(g),r.__h.forEach(j),r.__h=[]);},l$1.diffed=function(t){e&&e(t);var o=t.__c;o&&o.__H&&o.__H.__h.length&&(1!==i.push(o)&&r===l$1.requestAnimationFrame||((r=l$1.requestAnimationFrame)||function(n){var t,u=function(){clearTimeout(r),b&&cancelAnimationFrame(t),setTimeout(n);},r=setTimeout(u,100);b&&(t=requestAnimationFrame(u));})(x)),u=null;},l$1.__c=function(t,u){u.some(function(t){try{t.__h.forEach(g),t.__h=t.__h.filter(function(n){return !n.__||j(n)});}catch(r){u.some(function(n){n.__h&&(n.__h=[]);}),u=[],l$1.__e(r,t.__v);}}),a&&a(t,u);},l$1.unmount=function(t){v&&v(t);var u,r=t.__c;r&&r.__H&&(r.__H.__.forEach(function(n){try{g(n);}catch(n){u=n;}}),u&&l$1.__e(u,r.__v));};var b="function"==typeof requestAnimationFrame;function g(n){var t=u,r=n.__c;"function"==typeof r&&(n.__c=void 0,r()),u=t;}function j(n){var t=u;n.__c=n.__(),u=t;}function k(n,t){return !n||n.length!==t.length||t.some(function(t,u){return t!==n[u]})}function w(n,t){return "function"==typeof t?t(n):t}
+
+export { A as useCallback, F as useContext, T as useDebugValue, y as useEffect, q as useErrorBoundary, _ as useImperativeHandle, h as useLayoutEffect, d as useMemo, p as useReducer, s as useRef, l as useState };
diff --git a/webroot/js/web_modules/videojs/dist/video.min.js b/webroot/js/web_modules/videojs/dist/video.min.js
index 5f0e65ae7..e9686e259 100644
--- a/webroot/js/web_modules/videojs/dist/video.min.js
+++ b/webroot/js/web_modules/videojs/dist/video.min.js
@@ -30,4 +30,4 @@ var e;n(null,(e=s).subarray(0,e.byteLength-e[e.byteLength-1]));});}return u.prot
var video_min$1 = /*@__PURE__*/getDefaultExportFromCjs(video_min);
-export { video_min$1 as default };
+export default video_min$1;
diff --git a/webroot/serviceWorker.js b/webroot/serviceWorker.js
new file mode 100644
index 000000000..b2b7a4be9
--- /dev/null
+++ b/webroot/serviceWorker.js
@@ -0,0 +1,26 @@
+// self.addEventListener('activate', (event) => {
+// console.log('Owncast service worker activated', event);
+// });
+
+// self.addEventListener('install', (event) => {
+// console.log('installing Owncast service worker...', event);
+// });
+
+self.addEventListener('push', (event) => {
+ const data = JSON.parse(event.data.text());
+ const { title, body, icon, tag } = data;
+ const options = {
+ title: title || 'Live!',
+ body: body || 'This live stream has started.',
+ icon: icon || '/logo/external',
+ tag: tag,
+ };
+
+ event.waitUntil(self.registration.showNotification(options.title, options));
+});
+
+self.addEventListener('notificationclick', (event) => {
+ let notification = event.notification;
+ console.log(notification);
+ clients.openWindow('/');
+});