github.com/safing/portbase@v0.19.5/notifications/notification.go (about) 1 package notifications 2 3 import ( 4 "context" 5 "fmt" 6 "sync" 7 "time" 8 9 "github.com/safing/portbase/database/record" 10 "github.com/safing/portbase/log" 11 "github.com/safing/portbase/modules" 12 "github.com/safing/portbase/utils" 13 ) 14 15 // Type describes the type of a notification. 16 type Type uint8 17 18 // Notification types. 19 const ( 20 Info Type = 0 21 Warning Type = 1 22 Prompt Type = 2 23 Error Type = 3 24 ) 25 26 // State describes the state of a notification. 27 type State string 28 29 // NotificationActionFn defines the function signature for notification action 30 // functions. 31 type NotificationActionFn func(context.Context, *Notification) error 32 33 // Possible notification states. 34 // State transitions can only happen from top to bottom. 35 const ( 36 // Active describes a notification that is active, no expired and, 37 // if actions are available, still waits for the user to select an 38 // action. 39 Active State = "active" 40 // Responded describes a notification where the user has already 41 // selected which action to take but that action is still to be 42 // performed. 43 Responded State = "responded" 44 // Executes describes a notification where the user has selected 45 // and action and that action has been performed. 46 Executed State = "executed" 47 ) 48 49 // Notification represents a notification that is to be delivered to the user. 50 type Notification struct { //nolint:maligned 51 record.Base 52 // EventID is used to identify a specific notification. It consists of 53 // the module name and a per-module unique event id. 54 // The following format is recommended: 55 // <module-id>:<event-id> 56 EventID string 57 // GUID is a unique identifier for each notification instance. That is 58 // two notifications with the same EventID must still have unique GUIDs. 59 // The GUID is mainly used for system (Windows) integration and is 60 // automatically populated by the notification package. Average users 61 // don't need to care about this field. 62 GUID string 63 // Type is the notification type. It can be one of Info, Warning or Prompt. 64 Type Type 65 // Title is an optional and very short title for the message that gives a 66 // hint about what the notification is about. 67 Title string 68 // Category is an optional category for the notification that allows for 69 // tagging and grouping notifications by category. 70 Category string 71 // Message is the default message shown to the user if no localized version 72 // of the notification is available. Note that the message should already 73 // have any paramerized values replaced. 74 Message string 75 // ShowOnSystem specifies if the notification should be also shown on the 76 // operating system. Notifications shown on the operating system level are 77 // more focus-intrusive and should only be used for important notifications. 78 // If the configuration option "Desktop Notifications" is switched off, this 79 // will be forced to false on the first save. 80 ShowOnSystem bool 81 // EventData contains an additional payload for the notification. This payload 82 // may contain contextual data and may be used by a localization framework 83 // to populate the notification message template. 84 // If EventData implements sync.Locker it will be locked and unlocked together with the 85 // notification. Otherwise, EventData is expected to be immutable once the 86 // notification has been saved and handed over to the notification or database package. 87 EventData interface{} 88 // Expires holds the unix epoch timestamp at which the notification expires 89 // and can be cleaned up. 90 // Users can safely ignore expired notifications and should handle expiry the 91 // same as deletion. 92 Expires int64 93 // State describes the current state of a notification. See State for 94 // a list of available values and their meaning. 95 State State 96 // AvailableActions defines a list of actions that a user can choose from. 97 AvailableActions []*Action 98 // SelectedActionID is updated to match the ID of one of the AvailableActions 99 // based on the user selection. 100 SelectedActionID string 101 102 // belongsTo holds the module this notification belongs to. The notification 103 // lifecycle will be mirrored to the module's failure status. 104 belongsTo *modules.Module 105 106 lock sync.Mutex 107 actionFunction NotificationActionFn // call function to process action 108 actionTrigger chan string // and/or send to a channel 109 expiredTrigger chan struct{} // closed on expire 110 } 111 112 // Action describes an action that can be taken for a notification. 113 type Action struct { 114 // ID specifies a unique ID for the action. If an action is selected, the ID 115 // is written to SelectedActionID and the notification is saved. 116 // If the action type is not ActionTypeNone, the ID may be empty, signifying 117 // that this action is merely additional and selecting it does not dismiss the 118 // notification. 119 ID string 120 // Text on the button. 121 Text string 122 // Type specifies the action type. Implementing interfaces should only 123 // display action types they can handle. 124 Type ActionType 125 // Payload holds additional data for special action types. 126 Payload interface{} 127 } 128 129 // ActionType defines a specific type of action. 130 type ActionType string 131 132 // Action Types. 133 const ( 134 ActionTypeNone = "" // Report selected ID back to backend. 135 ActionTypeOpenURL = "open-url" // Open external URL 136 ActionTypeOpenPage = "open-page" // Payload: Page ID 137 ActionTypeOpenSetting = "open-setting" // Payload: See struct definition below. 138 ActionTypeOpenProfile = "open-profile" // Payload: Scoped Profile ID 139 ActionTypeInjectEvent = "inject-event" // Payload: Event ID 140 ActionTypeWebhook = "call-webhook" // Payload: See struct definition below. 141 ) 142 143 // ActionTypeOpenSettingPayload defines the payload for the OpenSetting Action Type. 144 type ActionTypeOpenSettingPayload struct { 145 // Key is the key of the setting. 146 Key string 147 // Profile is the scoped ID of the profile. 148 // Leaving this empty opens the global settings. 149 Profile string 150 } 151 152 // ActionTypeWebhookPayload defines the payload for the WebhookPayload Action Type. 153 type ActionTypeWebhookPayload struct { 154 // HTTP Method to use. Defaults to "GET", or "POST" if a Payload is supplied. 155 Method string 156 // URL to call. 157 // If the URL is relative, prepend the current API endpoint base path. 158 // If the URL is absolute, send request to the Portmaster. 159 URL string 160 // Payload holds arbitrary payload data. 161 Payload interface{} 162 // ResultAction defines what should be done with successfully returned data. 163 // Must one of: 164 // - `ignore`: do nothing (default) 165 // - `display`: the result is a human readable message, display it in a success message. 166 ResultAction string 167 } 168 169 // Get returns the notification identifed by the given id or nil if it doesn't exist. 170 func Get(id string) *Notification { 171 notsLock.RLock() 172 defer notsLock.RUnlock() 173 n, ok := nots[id] 174 if ok { 175 return n 176 } 177 return nil 178 } 179 180 // Delete deletes the notification with the given id. 181 func Delete(id string) { 182 // Delete notification in defer to enable deferred unlocking. 183 var n *Notification 184 var ok bool 185 defer func() { 186 if ok { 187 n.Delete() 188 } 189 }() 190 191 notsLock.Lock() 192 defer notsLock.Unlock() 193 n, ok = nots[id] 194 } 195 196 // NotifyInfo is a helper method for quickly showing an info notification. 197 // The notification will be activated immediately. 198 // If the provided id is empty, an id will derived from msg. 199 // ShowOnSystem is disabled. 200 // If no actions are defined, a default "OK" (ID:"ack") action will be added. 201 func NotifyInfo(id, title, msg string, actions ...Action) *Notification { 202 return notify(Info, id, title, msg, false, actions...) 203 } 204 205 // NotifyWarn is a helper method for quickly showing a warning notification 206 // The notification will be activated immediately. 207 // If the provided id is empty, an id will derived from msg. 208 // ShowOnSystem is enabled. 209 // If no actions are defined, a default "OK" (ID:"ack") action will be added. 210 func NotifyWarn(id, title, msg string, actions ...Action) *Notification { 211 return notify(Warning, id, title, msg, true, actions...) 212 } 213 214 // NotifyError is a helper method for quickly showing an error notification. 215 // The notification will be activated immediately. 216 // If the provided id is empty, an id will derived from msg. 217 // ShowOnSystem is enabled. 218 // If no actions are defined, a default "OK" (ID:"ack") action will be added. 219 func NotifyError(id, title, msg string, actions ...Action) *Notification { 220 return notify(Error, id, title, msg, true, actions...) 221 } 222 223 // NotifyPrompt is a helper method for quickly showing a prompt notification. 224 // The notification will be activated immediately. 225 // If the provided id is empty, an id will derived from msg. 226 // ShowOnSystem is disabled. 227 // If no actions are defined, a default "OK" (ID:"ack") action will be added. 228 func NotifyPrompt(id, title, msg string, actions ...Action) *Notification { 229 return notify(Prompt, id, title, msg, false, actions...) 230 } 231 232 func notify(nType Type, id, title, msg string, showOnSystem bool, actions ...Action) *Notification { 233 // Process actions. 234 var acts []*Action 235 if len(actions) == 0 { 236 // Create ack action if there are no defined actions. 237 acts = []*Action{ 238 { 239 ID: "ack", 240 Text: "OK", 241 }, 242 } 243 } else { 244 // Reference given actions for notification. 245 acts = make([]*Action, len(actions)) 246 for index := range actions { 247 a := actions[index] 248 acts[index] = &a 249 } 250 } 251 252 return Notify(&Notification{ 253 EventID: id, 254 Type: nType, 255 Title: title, 256 Message: msg, 257 ShowOnSystem: showOnSystem, 258 AvailableActions: acts, 259 }) 260 } 261 262 // Notify sends the given notification. 263 func Notify(n *Notification) *Notification { 264 // While this function is very similar to Save(), it is much nicer to use in 265 // order to just fire off one notification, as it does not require some more 266 // uncommon Go syntax. 267 268 n.save(true) 269 return n 270 } 271 272 // Save saves the notification. 273 func (n *Notification) Save() { 274 n.save(true) 275 } 276 277 // save saves the notification to the internal storage. It locks the 278 // notification, so it must not be locked when save is called. 279 func (n *Notification) save(pushUpdate bool) { 280 var id string 281 282 // Save notification after pre-save processing. 283 defer func() { 284 if id != "" { 285 // Lock and save to notification storage. 286 notsLock.Lock() 287 defer notsLock.Unlock() 288 nots[id] = n 289 } 290 }() 291 292 // We do not access EventData here, so it is enough to just lock the 293 // notification itself. 294 n.lock.Lock() 295 defer n.lock.Unlock() 296 297 // Check if required data is present. 298 if n.Title == "" && n.Message == "" { 299 log.Warning("notifications: ignoring notification without Title or Message") 300 return 301 } 302 303 // Derive EventID from Message if not given. 304 if n.EventID == "" { 305 n.EventID = fmt.Sprintf( 306 "unknown:%s", 307 utils.DerivedInstanceUUID(n.Message).String(), 308 ) 309 } 310 311 // Save ID for deletion 312 id = n.EventID 313 314 // Generate random GUID if not set. 315 if n.GUID == "" { 316 n.GUID = utils.RandomUUID(n.EventID).String() 317 } 318 319 // Make sure we always have a notification state assigned. 320 if n.State == "" { 321 n.State = Active 322 } 323 324 // Initialize on first save. 325 if !n.KeyIsSet() { 326 // Set database key. 327 n.SetKey(fmt.Sprintf("notifications:all/%s", n.EventID)) 328 329 // Check if notifications should be shown on the system at all. 330 if !useSystemNotifications() { 331 n.ShowOnSystem = false 332 } 333 } 334 335 // Update meta data. 336 n.UpdateMeta() 337 338 // Push update via the database system if needed. 339 if pushUpdate { 340 log.Tracef("notifications: pushing update for %s to subscribers", n.Key()) 341 dbController.PushUpdate(n) 342 } 343 } 344 345 // SetActionFunction sets a trigger function to be executed when the user reacted on the notification. 346 // The provided function will be started as its own goroutine and will have to lock everything it accesses, even the provided notification. 347 func (n *Notification) SetActionFunction(fn NotificationActionFn) *Notification { 348 n.lock.Lock() 349 defer n.lock.Unlock() 350 n.actionFunction = fn 351 return n 352 } 353 354 // Response waits for the user to respond to the notification and returns the selected action. 355 func (n *Notification) Response() <-chan string { 356 n.lock.Lock() 357 defer n.lock.Unlock() 358 359 if n.actionTrigger == nil { 360 n.actionTrigger = make(chan string) 361 } 362 363 return n.actionTrigger 364 } 365 366 // Update updates/resends a notification if it was not already responded to. 367 func (n *Notification) Update(expires int64) { 368 // Save when we're finished, if needed. 369 save := false 370 defer func() { 371 if save { 372 n.save(true) 373 } 374 }() 375 376 n.lock.Lock() 377 defer n.lock.Unlock() 378 379 // Don't update if notification isn't active. 380 if n.State != Active { 381 return 382 } 383 384 // Don't update too quickly. 385 if n.Meta().Modified > time.Now().Add(-10*time.Second).Unix() { 386 return 387 } 388 389 // Update expiry and save. 390 n.Expires = expires 391 save = true 392 } 393 394 // Delete (prematurely) cancels and deletes a notification. 395 func (n *Notification) Delete() { 396 // Dismiss notification. 397 func() { 398 n.lock.Lock() 399 defer n.lock.Unlock() 400 401 if n.actionTrigger != nil { 402 close(n.actionTrigger) 403 n.actionTrigger = nil 404 } 405 }() 406 407 n.delete(true) 408 } 409 410 // delete deletes the notification from the internal storage. It locks the 411 // notification, so it must not be locked when delete is called. 412 func (n *Notification) delete(pushUpdate bool) { 413 var id string 414 415 // Delete notification after processing deletion. 416 defer func() { 417 // Lock and delete from notification storage. 418 notsLock.Lock() 419 defer notsLock.Unlock() 420 delete(nots, id) 421 }() 422 423 // We do not access EventData here, so it is enough to just lock the 424 // notification itself. 425 n.lock.Lock() 426 defer n.lock.Unlock() 427 428 // Save ID for deletion 429 id = n.EventID 430 431 // Mark notification as deleted. 432 n.Meta().Delete() 433 434 // Close expiry channel if available. 435 if n.expiredTrigger != nil { 436 close(n.expiredTrigger) 437 n.expiredTrigger = nil 438 } 439 440 // Push update via the database system if needed. 441 if pushUpdate { 442 dbController.PushUpdate(n) 443 } 444 445 n.resolveModuleFailure() 446 } 447 448 // Expired notifies the caller when the notification has expired. 449 func (n *Notification) Expired() <-chan struct{} { 450 n.lock.Lock() 451 defer n.lock.Unlock() 452 453 if n.expiredTrigger == nil { 454 n.expiredTrigger = make(chan struct{}) 455 } 456 457 return n.expiredTrigger 458 } 459 460 // selectAndExecuteAction sets the user response and executes/triggers the action, if possible. 461 func (n *Notification) selectAndExecuteAction(id string) { 462 if n.State != Active { 463 return 464 } 465 466 n.State = Responded 467 n.SelectedActionID = id 468 469 executed := false 470 if n.actionFunction != nil { 471 module.StartWorker("notification action execution", func(ctx context.Context) error { 472 return n.actionFunction(ctx, n) 473 }) 474 executed = true 475 } 476 477 if n.actionTrigger != nil { 478 // satisfy all listeners (if they are listening) 479 // TODO(ppacher): if we miss to notify the waiter here (because 480 // nobody is listeing on actionTrigger) we wil likely 481 // never be able to execute the action again (simply because 482 // we won't try). May consider replacing the single actionTrigger 483 // channel with a per-listener (buffered) one so we just send 484 // the value and close the channel. 485 triggerAll: 486 for { 487 select { 488 case n.actionTrigger <- n.SelectedActionID: 489 executed = true 490 case <-time.After(100 * time.Millisecond): // mitigate race conditions 491 break triggerAll 492 } 493 } 494 } 495 496 if executed { 497 n.State = Executed 498 n.resolveModuleFailure() 499 } 500 } 501 502 // Lock locks the Notification. If EventData is set and 503 // implements sync.Locker it is locked as well. Users that 504 // want to replace the EventData on a notification must 505 // ensure to unlock the current value on their own. If the 506 // new EventData implements sync.Locker as well, it must 507 // be locked prior to unlocking the notification. 508 func (n *Notification) Lock() { 509 n.lock.Lock() 510 if locker, ok := n.EventData.(sync.Locker); ok { 511 locker.Lock() 512 } 513 } 514 515 // Unlock unlocks the Notification and the EventData, if 516 // it implements sync.Locker. See Lock() for more information 517 // on how to replace and work with EventData. 518 func (n *Notification) Unlock() { 519 n.lock.Unlock() 520 if locker, ok := n.EventData.(sync.Locker); ok { 521 locker.Unlock() 522 } 523 }