mirror of
https://github.com/safing/portbase
synced 2025-04-23 02:39:09 +00:00
523 lines
16 KiB
Go
523 lines
16 KiB
Go
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/safing/portbase/database/record"
|
|
"github.com/safing/portbase/log"
|
|
"github.com/safing/portbase/modules"
|
|
"github.com/safing/portbase/utils"
|
|
)
|
|
|
|
// Type describes the type of a notification.
|
|
type Type uint8
|
|
|
|
// Notification types.
|
|
const (
|
|
Info Type = 0
|
|
Warning Type = 1
|
|
Prompt Type = 2
|
|
Error Type = 3
|
|
)
|
|
|
|
// State describes the state of a notification.
|
|
type State string
|
|
|
|
// NotificationActionFn defines the function signature for notification action
|
|
// functions.
|
|
type NotificationActionFn func(context.Context, *Notification) error
|
|
|
|
// Possible notification states.
|
|
// State transitions can only happen from top to bottom.
|
|
const (
|
|
// Active describes a notification that is active, no expired and,
|
|
// if actions are available, still waits for the user to select an
|
|
// action.
|
|
Active State = "active"
|
|
// Responded describes a notification where the user has already
|
|
// selected which action to take but that action is still to be
|
|
// performed.
|
|
Responded State = "responded"
|
|
// Executes describes a notification where the user has selected
|
|
// and action and that action has been performed.
|
|
Executed State = "executed"
|
|
)
|
|
|
|
// Notification represents a notification that is to be delivered to the user.
|
|
type Notification struct { //nolint:maligned
|
|
record.Base
|
|
// EventID is used to identify a specific notification. It consists of
|
|
// the module name and a per-module unique event id.
|
|
// The following format is recommended:
|
|
// <module-id>:<event-id>
|
|
EventID string
|
|
// GUID is a unique identifier for each notification instance. That is
|
|
// two notifications with the same EventID must still have unique GUIDs.
|
|
// The GUID is mainly used for system (Windows) integration and is
|
|
// automatically populated by the notification package. Average users
|
|
// don't need to care about this field.
|
|
GUID string
|
|
// Type is the notification type. It can be one of Info, Warning or Prompt.
|
|
Type Type
|
|
// Title is an optional and very short title for the message that gives a
|
|
// hint about what the notification is about.
|
|
Title string
|
|
// Category is an optional category for the notification that allows for
|
|
// tagging and grouping notifications by category.
|
|
Category string
|
|
// Message is the default message shown to the user if no localized version
|
|
// of the notification is available. Note that the message should already
|
|
// have any paramerized values replaced.
|
|
Message string
|
|
// ShowOnSystem specifies if the notification should be also shown on the
|
|
// operating system. Notifications shown on the operating system level are
|
|
// more focus-intrusive and should only be used for important notifications.
|
|
// If the configuration option "Desktop Notifications" is switched off, this
|
|
// will be forced to false on the first save.
|
|
ShowOnSystem bool
|
|
// EventData contains an additional payload for the notification. This payload
|
|
// may contain contextual data and may be used by a localization framework
|
|
// to populate the notification message template.
|
|
// If EventData implements sync.Locker it will be locked and unlocked together with the
|
|
// notification. Otherwise, EventData is expected to be immutable once the
|
|
// notification has been saved and handed over to the notification or database package.
|
|
EventData interface{}
|
|
// Expires holds the unix epoch timestamp at which the notification expires
|
|
// and can be cleaned up.
|
|
// Users can safely ignore expired notifications and should handle expiry the
|
|
// same as deletion.
|
|
Expires int64
|
|
// State describes the current state of a notification. See State for
|
|
// a list of available values and their meaning.
|
|
State State
|
|
// AvailableActions defines a list of actions that a user can choose from.
|
|
AvailableActions []*Action
|
|
// SelectedActionID is updated to match the ID of one of the AvailableActions
|
|
// based on the user selection.
|
|
SelectedActionID string
|
|
|
|
// belongsTo holds the module this notification belongs to. The notification
|
|
// lifecycle will be mirrored to the module's failure status.
|
|
belongsTo *modules.Module
|
|
|
|
lock sync.Mutex
|
|
actionFunction NotificationActionFn // call function to process action
|
|
actionTrigger chan string // and/or send to a channel
|
|
expiredTrigger chan struct{} // closed on expire
|
|
}
|
|
|
|
// Action describes an action that can be taken for a notification.
|
|
type Action struct {
|
|
// ID specifies a unique ID for the action. If an action is selected, the ID
|
|
// is written to SelectedActionID and the notification is saved.
|
|
// If the action type is not ActionTypeNone, the ID may be empty, signifying
|
|
// that this action is merely additional and selecting it does not dismiss the
|
|
// notification.
|
|
ID string
|
|
// Text on the button.
|
|
Text string
|
|
// Type specifies the action type. Implementing interfaces should only
|
|
// display action types they can handle.
|
|
Type ActionType
|
|
// Payload holds additional data for special action types.
|
|
Payload interface{}
|
|
}
|
|
|
|
// ActionType defines a specific type of action.
|
|
type ActionType string
|
|
|
|
// Action Types.
|
|
const (
|
|
ActionTypeNone = "" // Report selected ID back to backend.
|
|
ActionTypeOpenURL = "open-url" // Open external URL
|
|
ActionTypeOpenPage = "open-page" // Payload: Page ID
|
|
ActionTypeOpenSetting = "open-setting" // Payload: See struct definition below.
|
|
ActionTypeOpenProfile = "open-profile" // Payload: Scoped Profile ID
|
|
ActionTypeInjectEvent = "inject-event" // Payload: Event ID
|
|
ActionTypeWebhook = "call-webhook" // Payload: See struct definition below.
|
|
)
|
|
|
|
// ActionTypeOpenSettingPayload defines the payload for the OpenSetting Action Type.
|
|
type ActionTypeOpenSettingPayload struct {
|
|
// Key is the key of the setting.
|
|
Key string
|
|
// Profile is the scoped ID of the profile.
|
|
// Leaving this empty opens the global settings.
|
|
Profile string
|
|
}
|
|
|
|
// ActionTypeWebhookPayload defines the payload for the WebhookPayload Action Type.
|
|
type ActionTypeWebhookPayload struct {
|
|
// HTTP Method to use. Defaults to "GET", or "POST" if a Payload is supplied.
|
|
Method string
|
|
// URL to call.
|
|
// If the URL is relative, prepend the current API endpoint base path.
|
|
// If the URL is absolute, send request to the Portmaster.
|
|
URL string
|
|
// Payload holds arbitrary payload data.
|
|
Payload interface{}
|
|
// ResultAction defines what should be done with successfully returned data.
|
|
// Must one of:
|
|
// - `ignore`: do nothing (default)
|
|
// - `display`: the result is a human readable message, display it in a success message.
|
|
ResultAction string
|
|
}
|
|
|
|
// Get returns the notification identifed by the given id or nil if it doesn't exist.
|
|
func Get(id string) *Notification {
|
|
notsLock.RLock()
|
|
defer notsLock.RUnlock()
|
|
n, ok := nots[id]
|
|
if ok {
|
|
return n
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete deletes the notification with the given id.
|
|
func Delete(id string) {
|
|
// Delete notification in defer to enable deferred unlocking.
|
|
var n *Notification
|
|
var ok bool
|
|
defer func() {
|
|
if ok {
|
|
n.Delete()
|
|
}
|
|
}()
|
|
|
|
notsLock.Lock()
|
|
defer notsLock.Unlock()
|
|
n, ok = nots[id]
|
|
}
|
|
|
|
// NotifyInfo is a helper method for quickly showing an info notification.
|
|
// The notification will be activated immediately.
|
|
// If the provided id is empty, an id will derived from msg.
|
|
// ShowOnSystem is disabled.
|
|
// If no actions are defined, a default "OK" (ID:"ack") action will be added.
|
|
func NotifyInfo(id, title, msg string, actions ...Action) *Notification {
|
|
return notify(Info, id, title, msg, false, actions...)
|
|
}
|
|
|
|
// NotifyWarn is a helper method for quickly showing a warning notification
|
|
// The notification will be activated immediately.
|
|
// If the provided id is empty, an id will derived from msg.
|
|
// ShowOnSystem is enabled.
|
|
// If no actions are defined, a default "OK" (ID:"ack") action will be added.
|
|
func NotifyWarn(id, title, msg string, actions ...Action) *Notification {
|
|
return notify(Warning, id, title, msg, true, actions...)
|
|
}
|
|
|
|
// NotifyError is a helper method for quickly showing an error notification.
|
|
// The notification will be activated immediately.
|
|
// If the provided id is empty, an id will derived from msg.
|
|
// ShowOnSystem is enabled.
|
|
// If no actions are defined, a default "OK" (ID:"ack") action will be added.
|
|
func NotifyError(id, title, msg string, actions ...Action) *Notification {
|
|
return notify(Error, id, title, msg, true, actions...)
|
|
}
|
|
|
|
// NotifyPrompt is a helper method for quickly showing a prompt notification.
|
|
// The notification will be activated immediately.
|
|
// If the provided id is empty, an id will derived from msg.
|
|
// ShowOnSystem is disabled.
|
|
// If no actions are defined, a default "OK" (ID:"ack") action will be added.
|
|
func NotifyPrompt(id, title, msg string, actions ...Action) *Notification {
|
|
return notify(Prompt, id, title, msg, false, actions...)
|
|
}
|
|
|
|
func notify(nType Type, id, title, msg string, showOnSystem bool, actions ...Action) *Notification {
|
|
// Process actions.
|
|
var acts []*Action
|
|
if len(actions) == 0 {
|
|
// Create ack action if there are no defined actions.
|
|
acts = []*Action{
|
|
{
|
|
ID: "ack",
|
|
Text: "OK",
|
|
},
|
|
}
|
|
} else {
|
|
// Reference given actions for notification.
|
|
acts = make([]*Action, len(actions))
|
|
for index := range actions {
|
|
a := actions[index]
|
|
acts[index] = &a
|
|
}
|
|
}
|
|
|
|
return Notify(&Notification{
|
|
EventID: id,
|
|
Type: nType,
|
|
Title: title,
|
|
Message: msg,
|
|
ShowOnSystem: showOnSystem,
|
|
AvailableActions: acts,
|
|
})
|
|
}
|
|
|
|
// Notify sends the given notification.
|
|
func Notify(n *Notification) *Notification {
|
|
// While this function is very similar to Save(), it is much nicer to use in
|
|
// order to just fire off one notification, as it does not require some more
|
|
// uncommon Go syntax.
|
|
|
|
n.save(true)
|
|
return n
|
|
}
|
|
|
|
// Save saves the notification.
|
|
func (n *Notification) Save() {
|
|
n.save(true)
|
|
}
|
|
|
|
// save saves the notification to the internal storage. It locks the
|
|
// notification, so it must not be locked when save is called.
|
|
func (n *Notification) save(pushUpdate bool) {
|
|
var id string
|
|
|
|
// Save notification after pre-save processing.
|
|
defer func() {
|
|
if id != "" {
|
|
// Lock and save to notification storage.
|
|
notsLock.Lock()
|
|
defer notsLock.Unlock()
|
|
nots[id] = n
|
|
}
|
|
}()
|
|
|
|
// We do not access EventData here, so it is enough to just lock the
|
|
// notification itself.
|
|
n.lock.Lock()
|
|
defer n.lock.Unlock()
|
|
|
|
// Check if required data is present.
|
|
if n.Title == "" && n.Message == "" {
|
|
log.Warning("notifications: ignoring notification without Title or Message")
|
|
return
|
|
}
|
|
|
|
// Derive EventID from Message if not given.
|
|
if n.EventID == "" {
|
|
n.EventID = fmt.Sprintf(
|
|
"unknown:%s",
|
|
utils.DerivedInstanceUUID(n.Message).String(),
|
|
)
|
|
}
|
|
|
|
// Save ID for deletion
|
|
id = n.EventID
|
|
|
|
// Generate random GUID if not set.
|
|
if n.GUID == "" {
|
|
n.GUID = utils.RandomUUID(n.EventID).String()
|
|
}
|
|
|
|
// Make sure we always have a notification state assigned.
|
|
if n.State == "" {
|
|
n.State = Active
|
|
}
|
|
|
|
// Initialize on first save.
|
|
if !n.KeyIsSet() {
|
|
// Set database key.
|
|
n.SetKey(fmt.Sprintf("notifications:all/%s", n.EventID))
|
|
|
|
// Check if notifications should be shown on the system at all.
|
|
if !useSystemNotifications() {
|
|
n.ShowOnSystem = false
|
|
}
|
|
}
|
|
|
|
// Update meta data.
|
|
n.UpdateMeta()
|
|
|
|
// Push update via the database system if needed.
|
|
if pushUpdate {
|
|
log.Tracef("notifications: pushing update for %s to subscribers", n.Key())
|
|
dbController.PushUpdate(n)
|
|
}
|
|
}
|
|
|
|
// SetActionFunction sets a trigger function to be executed when the user reacted on the notification.
|
|
// The provided function will be started as its own goroutine and will have to lock everything it accesses, even the provided notification.
|
|
func (n *Notification) SetActionFunction(fn NotificationActionFn) *Notification {
|
|
n.lock.Lock()
|
|
defer n.lock.Unlock()
|
|
n.actionFunction = fn
|
|
return n
|
|
}
|
|
|
|
// Response waits for the user to respond to the notification and returns the selected action.
|
|
func (n *Notification) Response() <-chan string {
|
|
n.lock.Lock()
|
|
defer n.lock.Unlock()
|
|
|
|
if n.actionTrigger == nil {
|
|
n.actionTrigger = make(chan string)
|
|
}
|
|
|
|
return n.actionTrigger
|
|
}
|
|
|
|
// Update updates/resends a notification if it was not already responded to.
|
|
func (n *Notification) Update(expires int64) {
|
|
// Save when we're finished, if needed.
|
|
save := false
|
|
defer func() {
|
|
if save {
|
|
n.save(true)
|
|
}
|
|
}()
|
|
|
|
n.lock.Lock()
|
|
defer n.lock.Unlock()
|
|
|
|
// Don't update if notification isn't active.
|
|
if n.State != Active {
|
|
return
|
|
}
|
|
|
|
// Don't update too quickly.
|
|
if n.Meta().Modified > time.Now().Add(-10*time.Second).Unix() {
|
|
return
|
|
}
|
|
|
|
// Update expiry and save.
|
|
n.Expires = expires
|
|
save = true
|
|
}
|
|
|
|
// Delete (prematurely) cancels and deletes a notification.
|
|
func (n *Notification) Delete() {
|
|
// Dismiss notification.
|
|
func() {
|
|
n.lock.Lock()
|
|
defer n.lock.Unlock()
|
|
|
|
if n.actionTrigger != nil {
|
|
close(n.actionTrigger)
|
|
n.actionTrigger = nil
|
|
}
|
|
}()
|
|
|
|
n.delete(true)
|
|
}
|
|
|
|
// delete deletes the notification from the internal storage. It locks the
|
|
// notification, so it must not be locked when delete is called.
|
|
func (n *Notification) delete(pushUpdate bool) {
|
|
var id string
|
|
|
|
// Delete notification after processing deletion.
|
|
defer func() {
|
|
// Lock and delete from notification storage.
|
|
notsLock.Lock()
|
|
defer notsLock.Unlock()
|
|
delete(nots, id)
|
|
}()
|
|
|
|
// We do not access EventData here, so it is enough to just lock the
|
|
// notification itself.
|
|
n.lock.Lock()
|
|
defer n.lock.Unlock()
|
|
|
|
// Save ID for deletion
|
|
id = n.EventID
|
|
|
|
// Mark notification as deleted.
|
|
n.Meta().Delete()
|
|
|
|
// Close expiry channel if available.
|
|
if n.expiredTrigger != nil {
|
|
close(n.expiredTrigger)
|
|
n.expiredTrigger = nil
|
|
}
|
|
|
|
// Push update via the database system if needed.
|
|
if pushUpdate {
|
|
dbController.PushUpdate(n)
|
|
}
|
|
|
|
n.resolveModuleFailure()
|
|
}
|
|
|
|
// Expired notifies the caller when the notification has expired.
|
|
func (n *Notification) Expired() <-chan struct{} {
|
|
n.lock.Lock()
|
|
defer n.lock.Unlock()
|
|
|
|
if n.expiredTrigger == nil {
|
|
n.expiredTrigger = make(chan struct{})
|
|
}
|
|
|
|
return n.expiredTrigger
|
|
}
|
|
|
|
// selectAndExecuteAction sets the user response and executes/triggers the action, if possible.
|
|
func (n *Notification) selectAndExecuteAction(id string) {
|
|
if n.State != Active {
|
|
return
|
|
}
|
|
|
|
n.State = Responded
|
|
n.SelectedActionID = id
|
|
|
|
executed := false
|
|
if n.actionFunction != nil {
|
|
module.StartWorker("notification action execution", func(ctx context.Context) error {
|
|
return n.actionFunction(ctx, n)
|
|
})
|
|
executed = true
|
|
}
|
|
|
|
if n.actionTrigger != nil {
|
|
// satisfy all listeners (if they are listening)
|
|
// TODO(ppacher): if we miss to notify the waiter here (because
|
|
// nobody is listeing on actionTrigger) we wil likely
|
|
// never be able to execute the action again (simply because
|
|
// we won't try). May consider replacing the single actionTrigger
|
|
// channel with a per-listener (buffered) one so we just send
|
|
// the value and close the channel.
|
|
triggerAll:
|
|
for {
|
|
select {
|
|
case n.actionTrigger <- n.SelectedActionID:
|
|
executed = true
|
|
case <-time.After(100 * time.Millisecond): // mitigate race conditions
|
|
break triggerAll
|
|
}
|
|
}
|
|
}
|
|
|
|
if executed {
|
|
n.State = Executed
|
|
n.resolveModuleFailure()
|
|
}
|
|
}
|
|
|
|
// Lock locks the Notification. If EventData is set and
|
|
// implements sync.Locker it is locked as well. Users that
|
|
// want to replace the EventData on a notification must
|
|
// ensure to unlock the current value on their own. If the
|
|
// new EventData implements sync.Locker as well, it must
|
|
// be locked prior to unlocking the notification.
|
|
func (n *Notification) Lock() {
|
|
n.lock.Lock()
|
|
if locker, ok := n.EventData.(sync.Locker); ok {
|
|
locker.Lock()
|
|
}
|
|
}
|
|
|
|
// Unlock unlocks the Notification and the EventData, if
|
|
// it implements sync.Locker. See Lock() for more information
|
|
// on how to replace and work with EventData.
|
|
func (n *Notification) Unlock() {
|
|
n.lock.Unlock()
|
|
if locker, ok := n.EventData.(sync.Locker); ok {
|
|
locker.Unlock()
|
|
}
|
|
}
|