safing-portbase/notifications/notification.go
2020-05-26 10:16:02 +02:00

331 lines
7.9 KiB
Go

package notifications
import (
"fmt"
"sync"
"time"
"github.com/safing/portbase/database"
"github.com/safing/portbase/database/record"
"github.com/safing/portbase/log"
"github.com/safing/portbase/utils"
)
// Notification types
const (
Info uint8 = 0
Warning uint8 = 1
Prompt uint8 = 2
)
// Notification represents a notification that is to be delivered to the user.
type Notification struct {
record.Base
ID string
GUID string
Message string
// MessageTemplate string
// MessageData []string
DataSubject sync.Locker
Type uint8
Persistent bool // this notification persists until it is handled and survives restarts
Created int64 // creation timestamp, notification "starts"
Expires int64 // expiry timestamp, notification is expected to be canceled at this time and may be cleaned up afterwards
Responded int64 // response timestamp, notification "ends"
Executed int64 // execution timestamp, notification will be deleted soon
AvailableActions []*Action
SelectedActionID string
lock sync.Mutex
actionFunction func(*Notification) // 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 string
Text string
}
func noOpAction(n *Notification) {
}
// 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
}
// NotifyInfo is a helper method for quickly showing a info
// notification. The notification is already shown. If id is
// an empty string a new UUIDv4 will be generated.
func NotifyInfo(id, msg string, actions ...Action) *Notification {
return notify(Info, id, msg, actions...)
}
// NotifyWarn is a helper method for quickly showing a warning
// notification. The notification is already shown. If id is
// an empty string a new UUIDv4 will be generated.
func NotifyWarn(id, msg string, actions ...Action) *Notification {
return notify(Warning, id, msg, actions...)
}
// NotifyPrompt is a helper method for quickly showing a prompt
// notification. The notification is already shown. If id is
// an empty string a new UUIDv4 will be generated.
func NotifyPrompt(id, msg string, actions ...Action) *Notification {
return notify(Prompt, id, msg, actions...)
}
func notify(nType uint8, id string, msg string, actions ...Action) *Notification {
acts := make([]*Action, len(actions))
for idx := range actions {
a := actions[idx]
acts[idx] = &a
}
if id == "" {
id = utils.DerivedInstanceUUID(msg).String()
}
n := Notification{
ID: id,
Message: msg,
Type: nType,
AvailableActions: acts,
}
return n.Save()
}
// Save saves the notification and returns it.
func (n *Notification) Save() *Notification {
notsLock.Lock()
defer notsLock.Unlock()
n.Lock()
defer n.Unlock()
// initialize
if n.Created == 0 {
n.Created = time.Now().Unix()
}
if n.GUID == "" {
n.GUID = utils.RandomUUID(n.ID).String()
}
// make ack notification if there are no defined actions
if len(n.AvailableActions) == 0 {
n.AvailableActions = []*Action{
{
ID: "ack",
Text: "OK",
},
}
n.actionFunction = noOpAction
}
// check key
if n.DatabaseKey() == "" {
n.SetKey(fmt.Sprintf("notifications:all/%s", n.ID))
}
// update meta
n.UpdateMeta()
// assign to data map
nots[n.ID] = n
// push update
log.Tracef("notifications: pushing update for %s to subscribers", n.Key())
dbController.PushUpdate(n)
// persist
if n.Persistent && persistentBasePath != "" {
duplicate := &Notification{
ID: n.ID,
Message: n.Message,
DataSubject: n.DataSubject,
AvailableActions: duplicateActions(n.AvailableActions),
SelectedActionID: n.SelectedActionID,
Persistent: n.Persistent,
Created: n.Created,
Expires: n.Expires,
Responded: n.Responded,
Executed: n.Executed,
}
duplicate.SetMeta(n.Meta().Duplicate())
key := fmt.Sprintf("%s/%s", persistentBasePath, n.ID)
duplicate.SetKey(key)
go func() {
err := dbInterface.Put(duplicate)
if err != nil {
log.Warningf("notifications: failed to persist notification %s: %s", key, err)
}
}()
}
return 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 func(*Notification)) *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) {
responded := true
n.lock.Lock()
if n.Responded == 0 {
responded = false
n.Expires = expires
}
n.lock.Unlock()
// save if not yet responded
if !responded {
n.Save()
}
}
// Delete (prematurely) cancels and deletes a notification.
func (n *Notification) Delete() error {
notsLock.Lock()
defer notsLock.Unlock()
n.Lock()
defer n.Unlock()
// mark as deleted
n.Meta().Delete()
// delete from internal storage
delete(nots, n.ID)
// close expired
if n.expiredTrigger != nil {
close(n.expiredTrigger)
n.expiredTrigger = nil
}
// push update
dbController.PushUpdate(n)
// delete from persistent storage
if n.Persistent && persistentBasePath != "" {
key := fmt.Sprintf("%s/%s", persistentBasePath, n.ID)
err := dbInterface.Delete(key)
if err != nil && err != database.ErrNotFound {
return fmt.Errorf("failed to delete persisted notification %s from database: %s", key, err)
}
}
return nil
}
// 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) {
// abort if already executed
if n.Executed != 0 {
return
}
// set response
n.Responded = time.Now().Unix()
n.SelectedActionID = id
// execute
executed := false
if n.actionFunction != nil {
go n.actionFunction(n)
executed = true
}
if n.actionTrigger != nil {
// satisfy all listeners
triggerAll:
for {
select {
case n.actionTrigger <- n.SelectedActionID:
executed = true
case <-time.After(100 * time.Millisecond): // mitigate race conditions
break triggerAll
}
}
}
// save execution time
if executed {
n.Executed = time.Now().Unix()
}
}
// AddDataSubject adds the data subject to the notification. This is the only way how a data subject should be added - it avoids locking problems.
func (n *Notification) AddDataSubject(ds sync.Locker) {
n.lock.Lock()
defer n.lock.Unlock()
n.DataSubject = ds
}
// Lock locks the Notification and the DataSubject, if available.
func (n *Notification) Lock() {
n.lock.Lock()
if n.DataSubject != nil {
n.DataSubject.Lock()
}
}
// Unlock unlocks the Notification and the DataSubject, if available.
func (n *Notification) Unlock() {
n.lock.Unlock()
if n.DataSubject != nil {
n.DataSubject.Unlock()
}
}
func duplicateActions(original []*Action) (duplicate []*Action) {
duplicate = make([]*Action, len(original))
for _, action := range original {
duplicate = append(duplicate, &Action{
ID: action.ID,
Text: action.Text,
})
}
return
}