[NLnet] Core developments: notifications system #716
50 changed files with 5502 additions and 18 deletions
110
docs/checker-notifications.md
Normal file
110
docs/checker-notifications.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Checker notifications
|
||||
|
||||
Notifications turn checker results into user-visible alerts. The system watches
|
||||
every completed `Execution`, decides whether the status transition deserves an
|
||||
alert under the user's preferences, and fans the message out to one or more
|
||||
delivery channels (email, webhook, UnifiedPush). Audit records and per-issue
|
||||
acknowledgements are kept so the user can see what was sent, and silence noisy
|
||||
incidents until they recover.
|
||||
|
||||
## Goals
|
||||
|
||||
- **Transition driven, not poll driven.** Only state changes notify. Identical
|
||||
back to back results stay silent. This is what keeps a flapping checker from
|
||||
paging on every run.
|
||||
- **Per user, per scope policy.** A user can configure preferences globally,
|
||||
per domain, or per service, and the most specific rule wins (service over
|
||||
domain over global). Channels are owned by the user, and may be allow listed
|
||||
per preference.
|
||||
- **Opt in by default.** A user with no configured preference still receives
|
||||
`warn` and above alerts on all enabled channels, so onboarding does not
|
||||
require authoring rules. A configured preference can lower or raise that
|
||||
bar, suppress recovery notices, or set quiet hours.
|
||||
- **Acknowledgement closes the loop.** A user can acknowledge an active issue
|
||||
and stop further alerts at the same severity until the incident recovers or
|
||||
escalates.
|
||||
- **Auditable delivery.** Every send, and every failure (including back
|
||||
pressure drops), is recorded so users can confirm an alert really left the
|
||||
building.
|
||||
- **Decoupled from the checker engine.** Notification dispatch hangs off a
|
||||
callback the engine fires after each execution, so a slow channel cannot
|
||||
wedge a checker run.
|
||||
|
||||
## Architecture
|
||||
|
||||
The checker engine fires a registered callback after each `ExecutionDone`. The
|
||||
callback enters the notification subsystem, which loads the user's state and
|
||||
preferences, runs a pure policy function over the status transition, persists
|
||||
the new state, and on a positive decision enqueues sends onto a bounded worker
|
||||
pool. Workers resolve the matching sender from a type indexed registry, and
|
||||
write a record of the result into the audit log.
|
||||
|
||||
State, preferences, channels, and records each have a dedicated storage
|
||||
interface. A per `(checker, target, user)` mutex serialises the read modify
|
||||
write done by the dispatcher and by manual acknowledgement actions, so an ack
|
||||
cannot be silently overwritten by a concurrent checker run. The HTTP layer
|
||||
(`internal/api/route/notification.go`) exposes CRUD on channels, preferences,
|
||||
history, and the acknowledge and clear endpoints scoped to a checker. The
|
||||
Svelte UI under `web/src/routes/me/notifications/` drives all of this.
|
||||
|
||||
Key types:
|
||||
|
||||
- `Dispatcher` (`internal/usecase/notification/dispatcher.go`): the seam
|
||||
between checker and notification, glues every collaborator together and
|
||||
owns no I/O of its own.
|
||||
- `Resolver`: picks the most specific preference for a target, and the
|
||||
channels (filtered by the preference allow list) that should carry the
|
||||
alert.
|
||||
- `policy.decide`: pure function returning skip, advance, or notify, plus
|
||||
recovery, escalation, and clear ack flags. Unit tested.
|
||||
- `StateLocker`: in process per key mutex shared by the dispatcher and the
|
||||
ack service.
|
||||
- `Pool`: bounded queue (256) and fixed worker pool (4) with a 15s send
|
||||
timeout, records every result and writes an audit row on saturation rather
|
||||
than dropping silently.
|
||||
- `Registry` and `ChannelSender` (`internal/notification/sender.go`): typed
|
||||
registry of transports, with `TypedSender[C]` and `Adapt` providing
|
||||
decode, validate, redact, merge, and send test for free.
|
||||
- `AckService`: acknowledge, clear, get, and list state, behind the same
|
||||
state lock.
|
||||
- Models (`model/notification.go`): `NotificationChannel`,
|
||||
`NotificationPreference`, `NotificationState`, `NotificationRecord`.
|
||||
|
||||
### Decision flow inside `policy.decide`
|
||||
|
||||
1. `oldStatus == newStatus` returns skip.
|
||||
2. Compute `isRecovery` (`newStatus < warn` while `oldStatus >= warn`) and
|
||||
`isEscalation` (`newStatus > oldStatus && newStatus >= warn`). Either one
|
||||
sets `ClearAck`, since the incident is over or has worsened.
|
||||
3. No preference, or `pref.Enabled=false`, returns advance (record the
|
||||
transition, do not send).
|
||||
4. Non recovery below `pref.MinStatus` returns advance.
|
||||
5. Recovery while `!pref.NotifyRecovery` returns advance.
|
||||
6. Active acknowledgement, with `!ClearAck`, and not a recovery, returns
|
||||
advance. The user already knows.
|
||||
7. Inside `pref.QuietStart..QuietEnd` (UTC, wraps midnight if start is
|
||||
greater than end) returns advance.
|
||||
8. Otherwise, notify.
|
||||
|
||||
Advance updates `LastStatus` only. Notify also stamps `LastNotifiedAt`, and
|
||||
this stamp is written **before** enqueuing, so a fast re run sees the new
|
||||
status and skips, even if the worker pool has not yet drained.
|
||||
|
||||
## Existing channel implementations
|
||||
|
||||
All senders live in `internal/notification/`. They share `safe_http.go`
|
||||
(outbound URL validation against private and loopback ranges, redirect
|
||||
bounds), and where relevant `httpjson.go` for JSON POSTs. Each registers a
|
||||
constant of type `happydns.NotificationChannelType`.
|
||||
|
||||
| Type | Constant | Config (JSON) | Notes |
|
||||
|---------------|-------------------------|---------------------------------------------|-----------------------------------------------------------------------------------|
|
||||
| `email` | `ChannelTypeEmail` | `{ "address"?: string }` | Falls back to the user's account email. Renders via the existing `Mailer`. Base URL captured at construction. |
|
||||
| `webhook` | `ChannelTypeWebhook` | `{ "url": string, "secret"?: string, ... }` | POSTs JSON. Optional HMAC SHA256 signature header derived from `secret`. |
|
||||
| `unifiedpush` | `ChannelTypeUnifiedPush`| `{ "endpoint": string }` | POST to the distributor provided endpoint. URL is validated against the safe HTTP allow list. |
|
||||
|
||||
Each sender is implemented as a `TypedSender[C]` and exposed through `Adapt`,
|
||||
so JSON decode, `Validate`, redact, merge, and `SendTest` are uniform across
|
||||
transports. Senders that carry secrets implement `ConfigRedactor[C]`, so the
|
||||
client never sees the secret, and `ConfigMerger[C]`, so an empty value on
|
||||
update preserves the stored secret rather than wiping it.
|
||||
468
internal/api/controller/notification.go
Normal file
468
internal/api/controller/notification.go
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
notifPkg "git.happydns.org/happyDomain/internal/notification"
|
||||
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Caps ?limit= so an unbounded request can't OOM the in-memory slice.
|
||||
const maxHistoryLimit = 500
|
||||
|
||||
// Bounds the persisted annotation to prevent state bloat.
|
||||
const maxAnnotationLength = 1024
|
||||
|
||||
// Storage errors may contain keys/internals; never echo them back.
|
||||
func internalError(c *gin.Context, err error) {
|
||||
log.Printf("notification controller: %v", err)
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{
|
||||
Message: "internal server error",
|
||||
})
|
||||
}
|
||||
|
||||
type NotificationController struct {
|
||||
dispatcher *notifUC.Dispatcher
|
||||
registry *notifPkg.Registry
|
||||
channelStore notifUC.NotificationChannelStorage
|
||||
prefStore notifUC.NotificationPreferenceStorage
|
||||
recordStore notifUC.NotificationRecordStorage
|
||||
}
|
||||
|
||||
func NewNotificationController(
|
||||
dispatcher *notifUC.Dispatcher,
|
||||
registry *notifPkg.Registry,
|
||||
channelStore notifUC.NotificationChannelStorage,
|
||||
prefStore notifUC.NotificationPreferenceStorage,
|
||||
recordStore notifUC.NotificationRecordStorage,
|
||||
) *NotificationController {
|
||||
return &NotificationController{
|
||||
dispatcher: dispatcher,
|
||||
registry: registry,
|
||||
channelStore: channelStore,
|
||||
prefStore: prefStore,
|
||||
recordStore: recordStore,
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary List supported notification channel types
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Router /notifications/channel-types [get]
|
||||
func (nc *NotificationController) ListChannelTypes(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, nc.registry.Types())
|
||||
}
|
||||
|
||||
// @Summary List notification channels
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
// @Success 200 {array} happydns.NotificationChannel
|
||||
// @Router /notifications/channels [get]
|
||||
func (nc *NotificationController) ListChannels(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
channels, err := nc.channelStore.ListChannelsByUser(user.Id)
|
||||
if err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
redacted, err := nc.registry.RedactChannels(channels)
|
||||
if err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
if redacted == nil {
|
||||
redacted = []*happydns.NotificationChannel{}
|
||||
}
|
||||
c.JSON(http.StatusOK, redacted)
|
||||
}
|
||||
|
||||
// @Summary Create a notification channel
|
||||
// @Tags notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body happydns.NotificationChannel true "Channel configuration"
|
||||
// @Success 201 {object} happydns.NotificationChannel
|
||||
// @Router /notifications/channels [post]
|
||||
func (nc *NotificationController) CreateChannel(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
|
||||
var ch happydns.NotificationChannel
|
||||
if err := c.ShouldBindJSON(&ch); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
ch.UserId = user.Id
|
||||
|
||||
if _, err := nc.registry.DecodeChannelConfig(&ch); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := nc.channelStore.CreateChannel(&ch); err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
redacted, err := nc.registry.RedactChannel(&ch)
|
||||
if err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, redacted)
|
||||
}
|
||||
|
||||
// @Summary Get a notification channel
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
// @Param channelId path string true "Channel ID"
|
||||
// @Success 200 {object} happydns.NotificationChannel
|
||||
// @Router /notifications/channels/{channelId} [get]
|
||||
func (nc *NotificationController) GetChannel(c *gin.Context) {
|
||||
redacted, err := nc.registry.RedactChannel(middleware.MyNotificationChannel(c))
|
||||
if err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, redacted)
|
||||
}
|
||||
|
||||
// Absent body fields are preserved so omitting one (e.g. "enabled") doesn't silently zero it.
|
||||
//
|
||||
// @Summary Update a notification channel
|
||||
// @Tags notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param channelId path string true "Channel ID"
|
||||
// @Param body body happydns.NotificationChannel true "Channel configuration"
|
||||
// @Success 200 {object} happydns.NotificationChannel
|
||||
// @Router /notifications/channels/{channelId} [put]
|
||||
func (nc *NotificationController) UpdateChannel(c *gin.Context) {
|
||||
existing := middleware.MyNotificationChannel(c)
|
||||
|
||||
// Bind onto a copy so json.Unmarshal only overwrites present fields; identity fields are forced back below.
|
||||
ch := *existing
|
||||
if err := c.ShouldBindJSON(&ch); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
ch.Id = existing.Id
|
||||
ch.UserId = existing.UserId
|
||||
|
||||
// Carry forward stored secrets so a GET → PUT round-trip does not wipe them.
|
||||
merged, err := nc.registry.MergeChannelForUpdate(existing, &ch)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
ch.Config = merged
|
||||
|
||||
if _, err := nc.registry.DecodeChannelConfig(&ch); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := nc.channelStore.UpdateChannel(&ch); err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
redacted, err := nc.registry.RedactChannel(&ch)
|
||||
if err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, redacted)
|
||||
}
|
||||
|
||||
// @Summary Delete a notification channel
|
||||
// @Tags notifications
|
||||
// @Param channelId path string true "Channel ID"
|
||||
// @Success 204
|
||||
// @Router /notifications/channels/{channelId} [delete]
|
||||
func (nc *NotificationController) DeleteChannel(c *gin.Context) {
|
||||
ch := middleware.MyNotificationChannel(c)
|
||||
|
||||
if err := nc.channelStore.DeleteChannel(ch.Id); err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// @Summary Send a test notification
|
||||
// @Tags notifications
|
||||
// @Param channelId path string true "Channel ID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /notifications/channels/{channelId}/test [post]
|
||||
func (nc *NotificationController) TestChannel(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
ch := middleware.MyNotificationChannel(c)
|
||||
|
||||
if err := nc.dispatcher.SendTestNotification(ch, user); err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
|
||||
}
|
||||
|
||||
// Hours, when set, must be in 0–23. Timezone, when set, must be a valid IANA name.
|
||||
func validateQuietHours(p *happydns.NotificationPreference) error {
|
||||
if p.QuietStart != nil && (*p.QuietStart < 0 || *p.QuietStart > 23) {
|
||||
return fmt.Errorf("quietStart must be between 0 and 23")
|
||||
}
|
||||
if p.QuietEnd != nil && (*p.QuietEnd < 0 || *p.QuietEnd > 23) {
|
||||
return fmt.Errorf("quietEnd must be between 0 and 23")
|
||||
}
|
||||
if p.Timezone != "" {
|
||||
if _, err := time.LoadLocation(p.Timezone); err != nil {
|
||||
return fmt.Errorf("timezone %q is not a valid IANA name", p.Timezone)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// @Summary List notification preferences
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
// @Success 200 {array} happydns.NotificationPreference
|
||||
// @Router /notifications/preferences [get]
|
||||
func (nc *NotificationController) ListPreferences(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
prefs, err := nc.prefStore.ListPreferencesByUser(user.Id)
|
||||
if err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
if prefs == nil {
|
||||
prefs = []*happydns.NotificationPreference{}
|
||||
}
|
||||
c.JSON(http.StatusOK, prefs)
|
||||
}
|
||||
|
||||
// @Summary Create a notification preference
|
||||
// @Tags notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body happydns.NotificationPreference true "Preference configuration"
|
||||
// @Success 201 {object} happydns.NotificationPreference
|
||||
// @Router /notifications/preferences [post]
|
||||
func (nc *NotificationController) CreatePreference(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
|
||||
var pref happydns.NotificationPreference
|
||||
if err := c.ShouldBindJSON(&pref); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
pref.UserId = user.Id
|
||||
|
||||
if err := validateQuietHours(&pref); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := nc.prefStore.CreatePreference(&pref); err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, pref)
|
||||
}
|
||||
|
||||
// @Summary Get a notification preference
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
// @Param prefId path string true "Preference ID"
|
||||
// @Success 200 {object} happydns.NotificationPreference
|
||||
// @Router /notifications/preferences/{prefId} [get]
|
||||
func (nc *NotificationController) GetPreference(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, middleware.MyNotificationPreference(c))
|
||||
}
|
||||
|
||||
// Absent body fields preserved (see UpdateChannel).
|
||||
//
|
||||
// @Summary Update a notification preference
|
||||
// @Tags notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param prefId path string true "Preference ID"
|
||||
// @Param body body happydns.NotificationPreference true "Preference configuration"
|
||||
// @Success 200 {object} happydns.NotificationPreference
|
||||
// @Router /notifications/preferences/{prefId} [put]
|
||||
func (nc *NotificationController) UpdatePreference(c *gin.Context) {
|
||||
existing := middleware.MyNotificationPreference(c)
|
||||
|
||||
pref := *existing
|
||||
if err := c.ShouldBindJSON(&pref); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
pref.Id = existing.Id
|
||||
pref.UserId = existing.UserId
|
||||
|
||||
if err := validateQuietHours(&pref); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := nc.prefStore.UpdatePreference(&pref); err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, pref)
|
||||
}
|
||||
|
||||
// @Summary Delete a notification preference
|
||||
// @Tags notifications
|
||||
// @Param prefId path string true "Preference ID"
|
||||
// @Success 204
|
||||
// @Router /notifications/preferences/{prefId} [delete]
|
||||
func (nc *NotificationController) DeletePreference(c *gin.Context) {
|
||||
pref := middleware.MyNotificationPreference(c)
|
||||
|
||||
if err := nc.prefStore.DeletePreference(pref.Id); err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// @Summary List notification history
|
||||
// @Tags notifications
|
||||
// @Produce json
|
||||
// @Param limit query int false "Maximum number of records (capped at 500)" default(50)
|
||||
// @Success 200 {array} happydns.NotificationRecord
|
||||
// @Router /notifications/history [get]
|
||||
func (nc *NotificationController) ListHistory(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
|
||||
limit := 50
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
if limit > maxHistoryLimit {
|
||||
limit = maxHistoryLimit
|
||||
}
|
||||
|
||||
records, err := nc.recordStore.ListRecordsByUser(user.Id, limit)
|
||||
if err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
if records == nil {
|
||||
records = []*happydns.NotificationRecord{}
|
||||
}
|
||||
c.JSON(http.StatusOK, records)
|
||||
}
|
||||
|
||||
// @Summary Acknowledge a checker issue
|
||||
// @Tags checkers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Param body body happydns.AcknowledgeRequest true "Acknowledgement"
|
||||
// @Success 200 {object} happydns.NotificationState
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/acknowledge [post]
|
||||
func (nc *NotificationController) AcknowledgeIssue(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
|
||||
var req happydns.AcknowledgeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// Body is optional for acknowledgement.
|
||||
req = happydns.AcknowledgeRequest{}
|
||||
}
|
||||
if len(req.Annotation) > maxAnnotationLength {
|
||||
req.Annotation = req.Annotation[:maxAnnotationLength]
|
||||
}
|
||||
|
||||
if err := nc.dispatcher.AcknowledgeIssue(user.Id, checkerID, target, user.Email, req.Annotation); err != nil {
|
||||
if errors.Is(err, happydns.ErrNotificationStateNotFound) {
|
||||
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := nc.dispatcher.GetState(user.Id, checkerID, target)
|
||||
if err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, state)
|
||||
}
|
||||
|
||||
// @Summary Clear acknowledgement
|
||||
// @Tags checkers
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param checkerId path string true "Checker ID"
|
||||
// @Success 200 {object} happydns.NotificationState
|
||||
// @Router /domains/{domain}/checkers/{checkerId}/acknowledge [delete]
|
||||
func (nc *NotificationController) ClearAcknowledgement(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
target := targetFromContext(c)
|
||||
checkerID := c.Param("checkerId")
|
||||
|
||||
if err := nc.dispatcher.ClearAcknowledgement(user.Id, checkerID, target); err != nil {
|
||||
if errors.Is(err, happydns.ErrNotificationStateNotFound) {
|
||||
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := nc.dispatcher.GetState(user.Id, checkerID, target)
|
||||
if err != nil {
|
||||
internalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, state)
|
||||
}
|
||||
116
internal/api/middleware/notification.go
Normal file
116
internal/api/middleware/notification.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
const (
|
||||
ctxKeyNotificationChannel = "notification_channel"
|
||||
ctxKeyNotificationPreference = "notification_preference"
|
||||
)
|
||||
|
||||
// Centralizes ownership check so per-channel endpoints cannot forget it.
|
||||
func NotificationChannelHandler(store notifUC.NotificationChannelStorage) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user := MyUser(c)
|
||||
if user == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "User not defined."})
|
||||
return
|
||||
}
|
||||
|
||||
channelId, err := happydns.NewIdentifierFromString(c.Param("channelId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid channel ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := store.GetChannel(channelId)
|
||||
if err != nil {
|
||||
if errors.Is(err, happydns.ErrNotificationChannelNotFound) {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Channel not found"})
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if !ch.UserId.Equals(user.Id) {
|
||||
// 404 not 403: do not leak the existence of channels owned by others.
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Channel not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(ctxKeyNotificationChannel, ch)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// Panics if middleware not installed — wiring bug, not runtime.
|
||||
func MyNotificationChannel(c *gin.Context) *happydns.NotificationChannel {
|
||||
return c.MustGet(ctxKeyNotificationChannel).(*happydns.NotificationChannel)
|
||||
}
|
||||
|
||||
func NotificationPreferenceHandler(store notifUC.NotificationPreferenceStorage) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user := MyUser(c)
|
||||
if user == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"errmsg": "User not defined."})
|
||||
return
|
||||
}
|
||||
|
||||
prefId, err := happydns.NewIdentifierFromString(c.Param("prefId"))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid preference ID"})
|
||||
return
|
||||
}
|
||||
|
||||
pref, err := store.GetPreference(prefId)
|
||||
if err != nil {
|
||||
if errors.Is(err, happydns.ErrNotificationPreferenceNotFound) {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Preference not found"})
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if !pref.UserId.Equals(user.Id) {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Preference not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(ctxKeyNotificationPreference, pref)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func MyNotificationPreference(c *gin.Context) *happydns.NotificationPreference {
|
||||
return c.MustGet(ctxKeyNotificationPreference).(*happydns.NotificationPreference)
|
||||
}
|
||||
|
|
@ -68,7 +68,8 @@ func DeclareCheckerRoutes(
|
|||
|
||||
// DeclareScopedCheckerRoutes registers checker routes scoped to a domain or service.
|
||||
// Called for both /api/domains/:domain/checkers and .../services/:serviceid/checkers.
|
||||
func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController) {
|
||||
// nc may be nil if the notification system is not configured.
|
||||
func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.CheckerController, nc *controller.NotificationController) {
|
||||
checkers := scopedRouter.Group("/checkers")
|
||||
checkers.GET("", cc.ListAvailableChecks)
|
||||
checkers.GET("/metrics", cc.GetDomainMetrics)
|
||||
|
|
@ -113,4 +114,10 @@ func DeclareScopedCheckerRoutes(scopedRouter *gin.RouterGroup, cc *controller.Ch
|
|||
// Results (under execution).
|
||||
executionID.GET("/results", cc.GetExecutionResults)
|
||||
executionID.GET("/results/:ruleName", cc.GetExecutionResult)
|
||||
|
||||
// Acknowledgement (requires notification system).
|
||||
if nc != nil {
|
||||
checkerID.POST("/acknowledge", nc.AcknowledgeIssue)
|
||||
checkerID.DELETE("/acknowledge", nc.ClearAcknowledgement)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ func DeclareDomainRoutes(
|
|||
cc *controller.CheckerController,
|
||||
checkStatusUC *checkerUC.CheckStatusUsecase,
|
||||
domainInfoUC happydns.DomainInfoUsecase,
|
||||
nc *controller.NotificationController,
|
||||
) {
|
||||
dc := controller.NewDomainController(
|
||||
domainUC,
|
||||
|
|
@ -72,7 +73,7 @@ func DeclareDomainRoutes(
|
|||
|
||||
// Mount domain-scoped checker routes.
|
||||
if cc != nil {
|
||||
DeclareScopedCheckerRoutes(apiDomainsRoutes, cc)
|
||||
DeclareScopedCheckerRoutes(apiDomainsRoutes, cc, nc)
|
||||
}
|
||||
|
||||
DeclareZoneRoutes(
|
||||
|
|
@ -83,5 +84,6 @@ func DeclareDomainRoutes(
|
|||
zoneServiceUC,
|
||||
serviceUC,
|
||||
cc,
|
||||
nc,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
99
internal/api/route/notification.go
Normal file
99
internal/api/route/notification.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
ratelimit "github.com/JGLTechnologies/gin-rate-limit"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
notifPkg "git.happydns.org/happyDomain/internal/notification"
|
||||
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func DeclareNotificationRoutes(
|
||||
apiAuthRoutes *gin.RouterGroup,
|
||||
dispatcher *notifUC.Dispatcher,
|
||||
registry *notifPkg.Registry,
|
||||
channelStore notifUC.NotificationChannelStorage,
|
||||
prefStore notifUC.NotificationPreferenceStorage,
|
||||
recordStore notifUC.NotificationRecordStorage,
|
||||
) *controller.NotificationController {
|
||||
nc := controller.NewNotificationController(dispatcher, registry, channelStore, prefStore, recordStore)
|
||||
|
||||
notif := apiAuthRoutes.Group("/notifications")
|
||||
|
||||
// Channel types (advertised by the registry).
|
||||
notif.GET("/channel-types", nc.ListChannelTypes)
|
||||
|
||||
// Channels
|
||||
channels := notif.Group("/channels")
|
||||
channels.GET("", nc.ListChannels)
|
||||
channels.POST("", nc.CreateChannel)
|
||||
|
||||
channelID := channels.Group("/:channelId", middleware.NotificationChannelHandler(channelStore))
|
||||
channelID.GET("", nc.GetChannel)
|
||||
channelID.PUT("", nc.UpdateChannel)
|
||||
channelID.DELETE("", nc.DeleteChannel)
|
||||
|
||||
// Rate-limit per user: each test triggers an outbound request and channels are user-owned.
|
||||
testRLStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{
|
||||
Rate: time.Minute,
|
||||
Limit: 5,
|
||||
})
|
||||
testRLMiddleware := ratelimit.RateLimiter(testRLStore, &ratelimit.Options{
|
||||
ErrorHandler: func(c *gin.Context, info ratelimit.Info) {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, happydns.ErrorResponse{
|
||||
Message: "Too many test notifications. Please try again later.",
|
||||
})
|
||||
},
|
||||
KeyFunc: func(c *gin.Context) string {
|
||||
user := middleware.MyUser(c)
|
||||
if user == nil {
|
||||
return c.ClientIP()
|
||||
}
|
||||
return user.Id.String()
|
||||
},
|
||||
})
|
||||
channelID.POST("/test", testRLMiddleware, nc.TestChannel)
|
||||
|
||||
// Preferences
|
||||
prefs := notif.Group("/preferences")
|
||||
prefs.GET("", nc.ListPreferences)
|
||||
prefs.POST("", nc.CreatePreference)
|
||||
|
||||
prefID := prefs.Group("/:prefId", middleware.NotificationPreferenceHandler(prefStore))
|
||||
prefID.GET("", nc.GetPreference)
|
||||
prefID.PUT("", nc.UpdatePreference)
|
||||
prefID.DELETE("", nc.DeletePreference)
|
||||
|
||||
// History
|
||||
notif.GET("/history", nc.ListHistory)
|
||||
|
||||
return nc
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +31,9 @@ import (
|
|||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
notifPkg "git.happydns.org/happyDomain/internal/notification"
|
||||
checkerUC "git.happydns.org/happyDomain/internal/usecase/checker"
|
||||
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
|
|
@ -67,6 +69,12 @@ type Dependencies struct {
|
|||
PlannedProvider checkerUC.PlannedJobProvider
|
||||
BudgetChecker checkerUC.BudgetChecker
|
||||
CountManualTriggers bool
|
||||
|
||||
NotificationDispatcher *notifUC.Dispatcher
|
||||
NotificationRegistry *notifPkg.Registry
|
||||
NotificationChannels notifUC.NotificationChannelStorage
|
||||
NotificationPrefs notifUC.NotificationPreferenceStorage
|
||||
NotificationRecords notifUC.NotificationRecordStorage
|
||||
}
|
||||
|
||||
// @title happyDomain API
|
||||
|
|
@ -154,6 +162,19 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
|
|||
)
|
||||
}
|
||||
|
||||
// Initialize notification controller if dispatcher is available.
|
||||
var nc *controller.NotificationController
|
||||
if dep.NotificationDispatcher != nil {
|
||||
nc = DeclareNotificationRoutes(
|
||||
apiAuthRoutes,
|
||||
dep.NotificationDispatcher,
|
||||
dep.NotificationRegistry,
|
||||
dep.NotificationChannels,
|
||||
dep.NotificationPrefs,
|
||||
dep.NotificationRecords,
|
||||
)
|
||||
}
|
||||
|
||||
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
|
||||
DeclareDomainRoutes(
|
||||
apiAuthRoutes,
|
||||
|
|
@ -168,6 +189,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
|
|||
cc,
|
||||
dep.CheckStatusUC,
|
||||
dep.DomainInfo,
|
||||
nc,
|
||||
)
|
||||
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
|
||||
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ func DeclareZoneServiceRoutes(
|
|||
serviceUC happydns.ServiceUsecase,
|
||||
zoneUC happydns.ZoneUsecase,
|
||||
cc *controller.CheckerController,
|
||||
nc *controller.NotificationController,
|
||||
) {
|
||||
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC)
|
||||
|
||||
|
|
@ -51,6 +52,6 @@ func DeclareZoneServiceRoutes(
|
|||
|
||||
// Mount service-scoped checker routes.
|
||||
if cc != nil {
|
||||
DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc)
|
||||
DeclareScopedCheckerRoutes(apiZonesSubdomainServiceIDRoutes, cc, nc)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ func DeclareZoneRoutes(
|
|||
zoneServiceUC happydns.ZoneServiceUsecase,
|
||||
serviceUC happydns.ServiceUsecase,
|
||||
cc *controller.CheckerController,
|
||||
nc *controller.NotificationController,
|
||||
) {
|
||||
var checkStatusUC *checkerUC.CheckStatusUsecase
|
||||
if cc != nil {
|
||||
|
|
@ -74,6 +75,7 @@ func DeclareZoneRoutes(
|
|||
serviceUC,
|
||||
zoneUC,
|
||||
cc,
|
||||
nc,
|
||||
)
|
||||
|
||||
apiZonesRoutes.POST("/records", zc.AddRecords)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import (
|
|||
"git.happydns.org/happyDomain/internal/mailer"
|
||||
"git.happydns.org/happyDomain/internal/metrics"
|
||||
"git.happydns.org/happyDomain/internal/newsletter"
|
||||
notifPkg "git.happydns.org/happyDomain/internal/notification"
|
||||
"git.happydns.org/happyDomain/internal/session"
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/internal/usecase"
|
||||
|
|
@ -43,6 +44,7 @@ import (
|
|||
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
|
||||
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||
emailAutoconfigUC "git.happydns.org/happyDomain/internal/usecase/emailautoconfig"
|
||||
notifUC "git.happydns.org/happyDomain/internal/usecase/notification"
|
||||
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
|
||||
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
|
||||
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
|
||||
|
|
@ -84,6 +86,9 @@ type Usecases struct {
|
|||
checkerScheduler *checkerUC.Scheduler
|
||||
checkerJanitor *checkerUC.Janitor
|
||||
checkerUserGater *checkerUC.UserGater
|
||||
|
||||
notificationDispatcher *notifUC.Dispatcher
|
||||
notificationRegistry *notifPkg.Registry
|
||||
}
|
||||
|
||||
type App struct {
|
||||
|
|
@ -331,6 +336,33 @@ func (app *App) initUsecases() {
|
|||
// Wire scheduler notifications for incremental queue updates.
|
||||
domainService.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
app.usecases.orchestrator.SetSchedulerNotifier(app.usecases.checkerScheduler)
|
||||
|
||||
// Notification system: dispatcher fans out checker results to user
|
||||
// channels (email/webhook/UnifiedPush) based on per-target preferences.
|
||||
baseURL := app.cfg.GetBaseURL()
|
||||
registry := notifPkg.NewRegistry()
|
||||
registry.Register(notifPkg.Adapt(notifPkg.NewEmailSender(app.mailer, baseURL)))
|
||||
registry.Register(notifPkg.Adapt(notifPkg.NewWebhookSender(baseURL)))
|
||||
registry.Register(notifPkg.Adapt(notifPkg.NewUnifiedPushSender(baseURL)))
|
||||
app.usecases.notificationRegistry = registry
|
||||
resolver := notifUC.NewResolver(app.store, app.store)
|
||||
pool := notifUC.NewPool(registry, app.store)
|
||||
tester := notifUC.NewTester(registry)
|
||||
stateLocker := notifUC.NewStateLocker()
|
||||
ack := notifUC.NewAckService(app.store, stateLocker)
|
||||
app.usecases.notificationDispatcher = notifUC.NewDispatcher(
|
||||
app.store,
|
||||
app.store,
|
||||
app.store,
|
||||
resolver,
|
||||
pool,
|
||||
tester,
|
||||
ack,
|
||||
stateLocker,
|
||||
)
|
||||
if cb, ok := app.usecases.checkerEngine.(checkerUC.ExecutionCallbackSetter); ok {
|
||||
cb.SetExecutionCallback(app.usecases.notificationDispatcher.OnExecutionComplete)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) setupRouter() {
|
||||
|
|
@ -386,6 +418,12 @@ func (app *App) setupRouter() {
|
|||
PlannedProvider: app.usecases.checkerScheduler,
|
||||
BudgetChecker: app.usecases.checkerUserGater,
|
||||
CountManualTriggers: app.cfg.CheckerCountManualTriggers,
|
||||
|
||||
NotificationDispatcher: app.usecases.notificationDispatcher,
|
||||
NotificationRegistry: app.usecases.notificationRegistry,
|
||||
NotificationChannels: app.store,
|
||||
NotificationPrefs: app.store,
|
||||
NotificationRecords: app.store,
|
||||
},
|
||||
)
|
||||
web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier)
|
||||
|
|
@ -428,6 +466,10 @@ func (app *App) Start() {
|
|||
app.usecases.checkerUserGater.Start(context.Background())
|
||||
}
|
||||
|
||||
if app.usecases.notificationDispatcher != nil {
|
||||
app.usecases.notificationDispatcher.Start()
|
||||
}
|
||||
|
||||
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
|
||||
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
|
|
@ -450,6 +492,12 @@ func (app *App) Stop() {
|
|||
app.usecases.checkerUserGater.Stop()
|
||||
}
|
||||
|
||||
// Drain in-flight notification sends after the scheduler is stopped
|
||||
// so no new jobs can be enqueued while we wait.
|
||||
if app.usecases.notificationDispatcher != nil {
|
||||
app.usecases.notificationDispatcher.Stop()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := app.srv.Shutdown(ctx); err != nil {
|
||||
|
|
|
|||
|
|
@ -137,6 +137,11 @@ func (s *instrumentedStorage) CreateAuthUser(user *happydns.UserAuth) (err error
|
|||
return s.inner.CreateAuthUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateChannel(ch *happydns.NotificationChannel) (err error) {
|
||||
defer observe("create", "notification_channel")(&err)
|
||||
return s.inner.CreateChannel(ch)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateCheckPlan(plan *happydns.CheckPlan) (err error) {
|
||||
defer observe("create", "check_plan")(&err)
|
||||
return s.inner.CreateCheckPlan(plan)
|
||||
|
|
@ -167,11 +172,21 @@ func (s *instrumentedStorage) CreateOrUpdateUser(user *happydns.User) (err error
|
|||
return s.inner.CreateOrUpdateUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreatePreference(pref *happydns.NotificationPreference) (err error) {
|
||||
defer observe("create", "notification_preference")(&err)
|
||||
return s.inner.CreatePreference(pref)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateProvider(prvd *happydns.Provider) (err error) {
|
||||
defer observe("create", "provider")(&err)
|
||||
return s.inner.CreateProvider(prvd)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateRecord(rec *happydns.NotificationRecord) (err error) {
|
||||
defer observe("create", "notification_record")(&err)
|
||||
return s.inner.CreateRecord(rec)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) CreateSnapshot(snap *happydns.ObservationSnapshot) (err error) {
|
||||
defer observe("create", "observation_snapshot")(&err)
|
||||
return s.inner.CreateSnapshot(snap)
|
||||
|
|
@ -187,6 +202,11 @@ func (s *instrumentedStorage) DeleteAuthUser(user *happydns.UserAuth) (err error
|
|||
return s.inner.DeleteAuthUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteChannel(channelId happydns.Identifier) (err error) {
|
||||
defer observe("delete", "notification_channel")(&err)
|
||||
return s.inner.DeleteChannel(channelId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteCheckPlan(planID happydns.Identifier) (err error) {
|
||||
defer observe("delete", "check_plan")(&err)
|
||||
return s.inner.DeleteCheckPlan(planID)
|
||||
|
|
@ -237,11 +257,21 @@ func (s *instrumentedStorage) DeleteExecutionsByChecker(checkerID string, target
|
|||
return s.inner.DeleteExecutionsByChecker(checkerID, target)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeletePreference(prefId happydns.Identifier) (err error) {
|
||||
defer observe("delete", "notification_preference")(&err)
|
||||
return s.inner.DeletePreference(prefId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteProvider(prvdid happydns.Identifier) (err error) {
|
||||
defer observe("delete", "provider")(&err)
|
||||
return s.inner.DeleteProvider(prvdid)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteRecordsOlderThan(before time.Time) (err error) {
|
||||
defer observe("delete", "notification_record")(&err)
|
||||
return s.inner.DeleteRecordsOlderThan(before)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteSession(sessionid string) (err error) {
|
||||
defer observe("delete", "session")(&err)
|
||||
return s.inner.DeleteSession(sessionid)
|
||||
|
|
@ -252,6 +282,11 @@ func (s *instrumentedStorage) DeleteSnapshot(snapID happydns.Identifier) (err er
|
|||
return s.inner.DeleteSnapshot(snapID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (err error) {
|
||||
defer observe("delete", "notification_state")(&err)
|
||||
return s.inner.DeleteState(checkerID, target, userId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) DeleteUser(userid happydns.Identifier) (err error) {
|
||||
defer observe("delete", "user")(&err)
|
||||
return s.inner.DeleteUser(userid)
|
||||
|
|
@ -282,6 +317,11 @@ func (s *instrumentedStorage) GetCachedObservation(target happydns.CheckTarget,
|
|||
return s.inner.GetCachedObservation(target, key)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetChannel(channelId happydns.Identifier) (ret *happydns.NotificationChannel, err error) {
|
||||
defer observe("get", "notification_channel")(&err)
|
||||
return s.inner.GetChannel(channelId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetCheckPlan(planID happydns.Identifier) (ret *happydns.CheckPlan, err error) {
|
||||
defer observe("get", "check_plan")(&err)
|
||||
return s.inner.GetCheckPlan(planID)
|
||||
|
|
@ -322,6 +362,11 @@ func (s *instrumentedStorage) GetLatestEvaluation(planID happydns.Identifier) (r
|
|||
return s.inner.GetLatestEvaluation(planID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetPreference(prefId happydns.Identifier) (ret *happydns.NotificationPreference, err error) {
|
||||
defer observe("get", "notification_preference")(&err)
|
||||
return s.inner.GetPreference(prefId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetProvider(prvdid happydns.Identifier) (ret *happydns.ProviderMessage, err error) {
|
||||
defer observe("get", "provider")(&err)
|
||||
return s.inner.GetProvider(prvdid)
|
||||
|
|
@ -337,6 +382,11 @@ func (s *instrumentedStorage) GetSnapshot(snapID happydns.Identifier) (ret *happ
|
|||
return s.inner.GetSnapshot(snapID)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (ret *happydns.NotificationState, err error) {
|
||||
defer observe("get", "notification_state")(&err)
|
||||
return s.inner.GetState(checkerID, target, userId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) GetUser(userid happydns.Identifier) (ret *happydns.User, err error) {
|
||||
defer observe("get", "user")(&err)
|
||||
return s.inner.GetUser(userid)
|
||||
|
|
@ -447,6 +497,11 @@ func (s *instrumentedStorage) ListAuthUserSessions(user *happydns.UserAuth) (ret
|
|||
return s.inner.ListAuthUserSessions(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListChannelsByUser(userId happydns.Identifier) (ret []*happydns.NotificationChannel, err error) {
|
||||
defer observe("list", "notification_channel")(&err)
|
||||
return s.inner.ListChannelsByUser(userId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListCheckPlansByChecker(checkerID string) (ret []*happydns.CheckPlan, err error) {
|
||||
defer observe("list", "check_plan")(&err)
|
||||
return s.inner.ListCheckPlansByChecker(checkerID)
|
||||
|
|
@ -522,11 +577,26 @@ func (s *instrumentedStorage) ListExecutionsByUser(userId happydns.Identifier, l
|
|||
return s.inner.ListExecutionsByUser(userId, limit, filter)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListPreferencesByUser(userId happydns.Identifier) (ret []*happydns.NotificationPreference, err error) {
|
||||
defer observe("list", "notification_preference")(&err)
|
||||
return s.inner.ListPreferencesByUser(userId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListProviders(user *happydns.User) (ret happydns.ProviderMessages, err error) {
|
||||
defer observe("list", "provider")(&err)
|
||||
return s.inner.ListProviders(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListRecordsByUser(userId happydns.Identifier, limit int) (ret []*happydns.NotificationRecord, err error) {
|
||||
defer observe("list", "notification_record")(&err)
|
||||
return s.inner.ListRecordsByUser(userId, limit)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListStatesByUser(userId happydns.Identifier) (ret []*happydns.NotificationState, err error) {
|
||||
defer observe("list", "notification_state")(&err)
|
||||
return s.inner.ListStatesByUser(userId)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ListUserSessions(userid happydns.Identifier) (ret []*happydns.Session, err error) {
|
||||
defer observe("list", "session")(&err)
|
||||
return s.inner.ListUserSessions(userid)
|
||||
|
|
@ -544,6 +614,11 @@ func (s *instrumentedStorage) PutDiscoveryObservationRef(ref *happydns.Discovery
|
|||
return s.inner.PutDiscoveryObservationRef(ref)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) PutState(state *happydns.NotificationState) (err error) {
|
||||
defer observe("put", "notification_state")(&err)
|
||||
return s.inner.PutState(state)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) ReplaceDiscoveryEntries(producerID string, target happydns.CheckTarget, entries []happydns.DiscoveryEntry) (err error) {
|
||||
defer observe("update", "discovery_entry")(&err)
|
||||
return s.inner.ReplaceDiscoveryEntries(producerID, target, entries)
|
||||
|
|
@ -601,6 +676,11 @@ func (s *instrumentedStorage) UpdateAuthUser(user *happydns.UserAuth) (err error
|
|||
return s.inner.UpdateAuthUser(user)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateChannel(ch *happydns.NotificationChannel) (err error) {
|
||||
defer observe("update", "notification_channel")(&err)
|
||||
return s.inner.UpdateChannel(ch)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateCheckPlan(plan *happydns.CheckPlan) (err error) {
|
||||
defer observe("update", "check_plan")(&err)
|
||||
return s.inner.UpdateCheckPlan(plan)
|
||||
|
|
@ -626,6 +706,11 @@ func (s *instrumentedStorage) UpdateExecution(exec *happydns.Execution) (err err
|
|||
return s.inner.UpdateExecution(exec)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdatePreference(pref *happydns.NotificationPreference) (err error) {
|
||||
defer observe("update", "notification_preference")(&err)
|
||||
return s.inner.UpdatePreference(pref)
|
||||
}
|
||||
|
||||
func (s *instrumentedStorage) UpdateProvider(prvd *happydns.Provider) (err error) {
|
||||
defer observe("update", "provider")(&err)
|
||||
return s.inner.UpdateProvider(prvd)
|
||||
|
|
|
|||
110
internal/notification/email_sender.go
Normal file
110
internal/notification/email_sender.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
const ChannelTypeEmail happydns.NotificationChannelType = "email"
|
||||
|
||||
type EmailConfig struct {
|
||||
// Empty means fall back to the user's account email.
|
||||
Address string `json:"address,omitempty"`
|
||||
}
|
||||
|
||||
func (c EmailConfig) Validate() error {
|
||||
if c.Address == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := mail.ParseAddress(c.Address); err != nil {
|
||||
return fmt.Errorf("invalid email address: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// baseURL is captured here — server identity, not per-notification data.
|
||||
type EmailSender struct {
|
||||
mailer happydns.Mailer
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewEmailSender(mailer happydns.Mailer, baseURL string) *EmailSender {
|
||||
return &EmailSender{mailer: mailer, baseURL: baseURL}
|
||||
}
|
||||
|
||||
func (s *EmailSender) Type() happydns.NotificationChannelType { return ChannelTypeEmail }
|
||||
|
||||
func (s *EmailSender) Send(_ context.Context, c EmailConfig, payload *NotificationPayload) error {
|
||||
addr := c.Address
|
||||
if addr == "" {
|
||||
addr = payload.Recipient.Email
|
||||
}
|
||||
if addr == "" {
|
||||
return errors.New("no email address available")
|
||||
}
|
||||
|
||||
to := &mail.Address{Address: addr}
|
||||
|
||||
// Strip CR/LF to prevent RFC 5322 header injection.
|
||||
safeDomain := stripCRLF(payload.DomainName)
|
||||
subject := fmt.Sprintf("[happyDomain] %s: %s", safeDomain, payload.NewStatus)
|
||||
|
||||
// Wrap third-party-sourced fields as Markdown code spans to neutralize injected link syntax in DKIM-signed mail; Annotation is user-authored, no boundary.
|
||||
var body strings.Builder
|
||||
fmt.Fprintf(&body, "## Status Change: %s -> %s\n\n", payload.OldStatus, payload.NewStatus)
|
||||
fmt.Fprintf(&body, "**Domain:** %s\n\n", mdLiteral(payload.DomainName))
|
||||
fmt.Fprintf(&body, "**Checker:** %s\n\n", mdLiteral(payload.CheckerID))
|
||||
|
||||
if len(payload.States) > 0 {
|
||||
body.WriteString("### Rule Results\n\n")
|
||||
for _, state := range payload.States {
|
||||
fmt.Fprintf(&body, "- %s (%s): %s\n", mdLiteral(state.Code), state.Status, mdLiteral(state.Message))
|
||||
}
|
||||
body.WriteString("\n")
|
||||
}
|
||||
|
||||
if payload.Annotation != "" {
|
||||
fmt.Fprintf(&body, "**Note:** %s\n\n", payload.Annotation)
|
||||
}
|
||||
|
||||
if s.baseURL != "" {
|
||||
fmt.Fprintf(&body, "[View in happyDomain](%s)\n", s.baseURL)
|
||||
}
|
||||
|
||||
return s.mailer.SendMail(to, subject, body.String())
|
||||
}
|
||||
|
||||
func stripCRLF(s string) string {
|
||||
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
|
||||
}
|
||||
|
||||
// Wraps s as a code span; backticks become apostrophes to avoid fence accounting.
|
||||
func mdLiteral(s string) string {
|
||||
return "`" + strings.ReplaceAll(s, "`", "'") + "`"
|
||||
}
|
||||
101
internal/notification/httpjson.go
Normal file
101
internal/notification/httpjson.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Shared by both webhook and UnifiedPush.
|
||||
type httpJSONPayload struct {
|
||||
Event string `json:"event"`
|
||||
Checker string `json:"checker"`
|
||||
Domain string `json:"domain"`
|
||||
Target happydns.CheckTarget `json:"target"`
|
||||
OldStatus happydns.Status `json:"oldStatus"`
|
||||
NewStatus happydns.Status `json:"newStatus"`
|
||||
States []happydns.CheckState `json:"states,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
DashboardURL string `json:"dashboardUrl,omitempty"`
|
||||
}
|
||||
|
||||
func buildHTTPPayload(p *NotificationPayload, dashboardURL string) httpJSONPayload {
|
||||
return httpJSONPayload{
|
||||
Event: "status_change",
|
||||
Checker: p.CheckerID,
|
||||
Domain: p.DomainName,
|
||||
Target: p.Target,
|
||||
OldStatus: p.OldStatus,
|
||||
NewStatus: p.NewStatus,
|
||||
States: p.States,
|
||||
Timestamp: time.Now(),
|
||||
DashboardURL: dashboardURL,
|
||||
}
|
||||
}
|
||||
|
||||
// decorate runs after marshal so it can sign the exact bytes (e.g. HMAC).
|
||||
func postJSON(ctx context.Context, client *http.Client, url string, body any, decorate func(*http.Request, []byte)) error {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if decorate != nil {
|
||||
decorate(req, raw)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBodyBytes))
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("endpoint returned status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func testPayload(rcpt Recipient) *NotificationPayload {
|
||||
return &NotificationPayload{
|
||||
Recipient: rcpt,
|
||||
CheckerID: "test",
|
||||
DomainName: "example.com",
|
||||
OldStatus: happydns.StatusOK,
|
||||
NewStatus: happydns.StatusWarn,
|
||||
Annotation: "This is a test notification from happyDomain.",
|
||||
}
|
||||
}
|
||||
132
internal/notification/safe_http.go
Normal file
132
internal/notification/safe_http.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// We drain the response only for keep-alive; body is unused.
|
||||
const maxResponseBodyBytes = 64 * 1024
|
||||
|
||||
var errBlockedAddress = errors.New("address resolves to a blocked range")
|
||||
|
||||
// Reject non-http(s), missing host, or private/loopback IP literals; DNS hosts re-checked at dial time.
|
||||
func validateOutboundURL(rawURL string) (*url.URL, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, fmt.Errorf("unsupported scheme %q (only http and https are allowed)", u.Scheme)
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return nil, errors.New("URL has no host")
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if !isPublicIP(ip) {
|
||||
return nil, errBlockedAddress
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func isPublicIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() ||
|
||||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||
ip.IsPrivate() || ip.IsInterfaceLocalMulticast() {
|
||||
return false
|
||||
}
|
||||
// Block IPv4 broadcast 255.255.255.255 explicitly (not covered above).
|
||||
if v4 := ip.To4(); v4 != nil && v4[0] == 255 && v4[1] == 255 && v4[2] == 255 && v4[3] == 255 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Re-check resolved IP to defeat DNS rebinding.
|
||||
func safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
resolver := net.DefaultResolver
|
||||
ips, err := resolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !isPublicIP(ip) {
|
||||
return nil, fmt.Errorf("dial %s: %w", address, errBlockedAddress)
|
||||
}
|
||||
}
|
||||
// Pin the dial to a validated IP rather than letting the dialer re-resolve.
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
if errors.Is(err, syscall.ECONNREFUSED) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf("dial %s: no usable address", address)
|
||||
}
|
||||
|
||||
// Refuses private/loopback addresses and re-validates each redirect hop.
|
||||
func newSafeHTTPClient(timeout time.Duration) *http.Client {
|
||||
transport := &http.Transport{
|
||||
DialContext: safeDialContext,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
if _, err := validateOutboundURL(req.URL.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
231
internal/notification/sender.go
Normal file
231
internal/notification/sender.go
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Carries only what at least one transport needs, so other transports cannot leak the user record.
|
||||
type Recipient struct {
|
||||
// May be empty for transports that don't need it (webhook, UnifiedPush).
|
||||
Email string
|
||||
}
|
||||
|
||||
// Senders receive only render-needed data — no user object, no server config — so adding a transport cannot leak privileged data.
|
||||
type NotificationPayload struct {
|
||||
Recipient Recipient
|
||||
CheckerID string
|
||||
Target happydns.CheckTarget
|
||||
DomainName string
|
||||
OldStatus happydns.Status
|
||||
NewStatus happydns.Status
|
||||
States []happydns.CheckState
|
||||
Annotation string
|
||||
}
|
||||
|
||||
type ChannelConfig interface {
|
||||
Validate() error
|
||||
}
|
||||
|
||||
// Senders own their config shape so adding a transport is a one-file change.
|
||||
// Most implementations should embed TypedSender[C] via Adapt rather than implementing this directly.
|
||||
type ChannelSender interface {
|
||||
Type() happydns.NotificationChannelType
|
||||
DecodeConfig(raw json.RawMessage) (ChannelConfig, error)
|
||||
Send(ctx context.Context, cfg ChannelConfig, payload *NotificationPayload) error
|
||||
SendTest(ctx context.Context, cfg ChannelConfig, user *happydns.User) error
|
||||
// Strip secrets to presence booleans before echoing config back to clients.
|
||||
RedactConfig(raw json.RawMessage) (json.RawMessage, error)
|
||||
// Preserve stored secrets when client submits empty fields (client never sees them on read).
|
||||
MergeForUpdate(existing, incoming json.RawMessage) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// Optional capability: senders with secret fields opt in by implementing this on their TypedSender.
|
||||
type ConfigRedactor[C ChannelConfig] interface {
|
||||
RedactConfig(cfg C) C
|
||||
}
|
||||
|
||||
type ConfigMerger[C ChannelConfig] interface {
|
||||
MergeForUpdate(existing, incoming C) C
|
||||
}
|
||||
|
||||
// Strongly-typed contract; Adapt wraps it as ChannelSender, providing JSON decode, validation, type-asserted dispatch, and SendTest.
|
||||
type TypedSender[C ChannelConfig] interface {
|
||||
Type() happydns.NotificationChannelType
|
||||
Send(ctx context.Context, cfg C, payload *NotificationPayload) error
|
||||
}
|
||||
|
||||
func Adapt[C ChannelConfig](s TypedSender[C]) ChannelSender {
|
||||
return &typedAdapter[C]{inner: s}
|
||||
}
|
||||
|
||||
type typedAdapter[C ChannelConfig] struct {
|
||||
inner TypedSender[C]
|
||||
}
|
||||
|
||||
func (a *typedAdapter[C]) Type() happydns.NotificationChannelType { return a.inner.Type() }
|
||||
|
||||
func (a *typedAdapter[C]) DecodeConfig(raw json.RawMessage) (ChannelConfig, error) {
|
||||
var c C
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
return nil, fmt.Errorf("decoding %s config: %w", a.inner.Type(), err)
|
||||
}
|
||||
}
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (a *typedAdapter[C]) Send(ctx context.Context, cfg ChannelConfig, payload *NotificationPayload) error {
|
||||
typed, ok := cfg.(C)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s sender: unexpected config type %T", a.inner.Type(), cfg)
|
||||
}
|
||||
return a.inner.Send(ctx, typed, payload)
|
||||
}
|
||||
|
||||
func (a *typedAdapter[C]) SendTest(ctx context.Context, cfg ChannelConfig, user *happydns.User) error {
|
||||
return a.Send(ctx, cfg, testPayload(Recipient{Email: user.Email}))
|
||||
}
|
||||
|
||||
func (a *typedAdapter[C]) RedactConfig(raw json.RawMessage) (json.RawMessage, error) {
|
||||
redactor, ok := a.inner.(ConfigRedactor[C])
|
||||
if !ok {
|
||||
return raw, nil
|
||||
}
|
||||
var c C
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
return nil, fmt.Errorf("decoding %s config: %w", a.inner.Type(), err)
|
||||
}
|
||||
}
|
||||
c = redactor.RedactConfig(c)
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
func (a *typedAdapter[C]) MergeForUpdate(existing, incoming json.RawMessage) (json.RawMessage, error) {
|
||||
merger, ok := a.inner.(ConfigMerger[C])
|
||||
if !ok {
|
||||
return incoming, nil
|
||||
}
|
||||
var ec, ic C
|
||||
if len(existing) > 0 {
|
||||
if err := json.Unmarshal(existing, &ec); err != nil {
|
||||
return nil, fmt.Errorf("decoding existing %s config: %w", a.inner.Type(), err)
|
||||
}
|
||||
}
|
||||
if len(incoming) > 0 {
|
||||
if err := json.Unmarshal(incoming, &ic); err != nil {
|
||||
return nil, fmt.Errorf("decoding %s config: %w", a.inner.Type(), err)
|
||||
}
|
||||
}
|
||||
return json.Marshal(merger.MergeForUpdate(ec, ic))
|
||||
}
|
||||
|
||||
// Senders self-register at startup; adding a transport requires no changes here.
|
||||
type Registry struct {
|
||||
senders map[happydns.NotificationChannelType]ChannelSender
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{senders: make(map[happydns.NotificationChannelType]ChannelSender)}
|
||||
}
|
||||
|
||||
// Panics on duplicate — programming error.
|
||||
func (r *Registry) Register(s ChannelSender) {
|
||||
t := s.Type()
|
||||
if _, exists := r.senders[t]; exists {
|
||||
panic(fmt.Sprintf("notification: sender already registered for type %q", t))
|
||||
}
|
||||
r.senders[t] = s
|
||||
}
|
||||
|
||||
func (r *Registry) Get(t happydns.NotificationChannelType) (ChannelSender, bool) {
|
||||
s, ok := r.senders[t]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func (r *Registry) Types() []happydns.NotificationChannelType {
|
||||
out := make([]happydns.NotificationChannelType, 0, len(r.senders))
|
||||
for t := range r.senders {
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Registry) DecodeChannelConfig(ch *happydns.NotificationChannel) (ChannelConfig, error) {
|
||||
s, ok := r.Get(ch.Type)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnknownChannelType, ch.Type)
|
||||
}
|
||||
return s.DecodeConfig(ch.Config)
|
||||
}
|
||||
|
||||
// Channels of unknown types are returned unchanged so administrators can still observe legacy data.
|
||||
func (r *Registry) RedactChannel(ch *happydns.NotificationChannel) (*happydns.NotificationChannel, error) {
|
||||
if ch == nil {
|
||||
return nil, nil
|
||||
}
|
||||
s, ok := r.Get(ch.Type)
|
||||
if !ok {
|
||||
copy := *ch
|
||||
return ©, nil
|
||||
}
|
||||
redacted, err := s.RedactConfig(ch.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copy := *ch
|
||||
copy.Config = redacted
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (r *Registry) RedactChannels(chs []*happydns.NotificationChannel) ([]*happydns.NotificationChannel, error) {
|
||||
out := make([]*happydns.NotificationChannel, 0, len(chs))
|
||||
for _, ch := range chs {
|
||||
red, err := r.RedactChannel(ch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, red)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Caller should DecodeConfig the returned raw before persisting.
|
||||
func (r *Registry) MergeChannelForUpdate(existing, incoming *happydns.NotificationChannel) (json.RawMessage, error) {
|
||||
s, ok := r.Get(incoming.Type)
|
||||
if !ok {
|
||||
return incoming.Config, nil
|
||||
}
|
||||
return s.MergeForUpdate(existing.Config, incoming.Config)
|
||||
}
|
||||
|
||||
var ErrUnknownChannelType = errors.New("unknown channel type")
|
||||
67
internal/notification/unifiedpush_sender.go
Normal file
67
internal/notification/unifiedpush_sender.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
const ChannelTypeUnifiedPush happydns.NotificationChannelType = "unifiedpush"
|
||||
|
||||
type UnifiedPushConfig struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
func (c UnifiedPushConfig) Validate() error {
|
||||
if c.Endpoint == "" {
|
||||
return errors.New("UnifiedPush endpoint is required")
|
||||
}
|
||||
if _, err := validateOutboundURL(c.Endpoint); err != nil {
|
||||
return fmt.Errorf("UnifiedPush endpoint: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dashboardURL is captured here — server identity, not per-notification data.
|
||||
type UnifiedPushSender struct {
|
||||
client *http.Client
|
||||
dashboardURL string
|
||||
}
|
||||
|
||||
func NewUnifiedPushSender(dashboardURL string) *UnifiedPushSender {
|
||||
return &UnifiedPushSender{
|
||||
client: newSafeHTTPClient(10 * time.Second),
|
||||
dashboardURL: dashboardURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UnifiedPushSender) Type() happydns.NotificationChannelType { return ChannelTypeUnifiedPush }
|
||||
|
||||
func (s *UnifiedPushSender) Send(ctx context.Context, c UnifiedPushConfig, payload *NotificationPayload) error {
|
||||
return postJSON(ctx, s.client, c.Endpoint, buildHTTPPayload(payload, s.dashboardURL), nil)
|
||||
}
|
||||
135
internal/notification/webhook_sender.go
Normal file
135
internal/notification/webhook_sender.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Reserved by the HTTP client or used to spoof outbound identity (smuggling/host-routing risk).
|
||||
var disallowedWebhookHeaders = map[string]struct{}{
|
||||
"host": {},
|
||||
"content-length": {},
|
||||
"content-encoding": {},
|
||||
"transfer-encoding": {},
|
||||
"connection": {},
|
||||
"upgrade": {},
|
||||
"te": {},
|
||||
"trailer": {},
|
||||
}
|
||||
|
||||
func validateHeader(k, v string) error {
|
||||
if k == "" {
|
||||
return errors.New("empty header name")
|
||||
}
|
||||
if strings.ContainsAny(k, "\r\n") || strings.ContainsAny(v, "\r\n") {
|
||||
return fmt.Errorf("header %q contains CR/LF", k)
|
||||
}
|
||||
if _, blocked := disallowedWebhookHeaders[strings.ToLower(k)]; blocked {
|
||||
return fmt.Errorf("header %q is not allowed", k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const ChannelTypeWebhook happydns.NotificationChannelType = "webhook"
|
||||
|
||||
type WebhookConfig struct {
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
// HMAC-SHA256 signing key.
|
||||
Secret string `json:"secret,omitempty"`
|
||||
// Set only by RedactConfig — never stored or accepted on input.
|
||||
HasSecret bool `json:"hasSecret,omitempty"`
|
||||
}
|
||||
|
||||
func (c WebhookConfig) Validate() error {
|
||||
if c.URL == "" {
|
||||
return errors.New("webhook URL is required")
|
||||
}
|
||||
if _, err := validateOutboundURL(c.URL); err != nil {
|
||||
return fmt.Errorf("webhook URL: %w", err)
|
||||
}
|
||||
for k, v := range c.Headers {
|
||||
if err := validateHeader(k, v); err != nil {
|
||||
return fmt.Errorf("webhook header: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dashboardURL is captured here — server identity, not per-notification data.
|
||||
type WebhookSender struct {
|
||||
client *http.Client
|
||||
dashboardURL string
|
||||
}
|
||||
|
||||
func NewWebhookSender(dashboardURL string) *WebhookSender {
|
||||
return &WebhookSender{
|
||||
client: newSafeHTTPClient(10 * time.Second),
|
||||
dashboardURL: dashboardURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WebhookSender) Type() happydns.NotificationChannelType { return ChannelTypeWebhook }
|
||||
|
||||
func (s *WebhookSender) RedactConfig(cfg WebhookConfig) WebhookConfig {
|
||||
cfg.HasSecret = cfg.Secret != ""
|
||||
cfg.Secret = ""
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Preserve stored secret on empty submit; client never receives it back, so absence means "no change".
|
||||
func (s *WebhookSender) MergeForUpdate(existing, incoming WebhookConfig) WebhookConfig {
|
||||
if incoming.Secret == "" {
|
||||
incoming.Secret = existing.Secret
|
||||
}
|
||||
incoming.HasSecret = false
|
||||
return incoming
|
||||
}
|
||||
|
||||
func (s *WebhookSender) Send(ctx context.Context, c WebhookConfig, payload *NotificationPayload) error {
|
||||
return postJSON(ctx, s.client, c.URL, buildHTTPPayload(payload, s.dashboardURL), func(req *http.Request, body []byte) {
|
||||
req.Header.Set("User-Agent", "happyDomain-Notification/1.0")
|
||||
for k, v := range c.Headers {
|
||||
// Defense in depth: catches stored channels that pre-date Validate().
|
||||
if err := validateHeader(k, v); err != nil {
|
||||
continue
|
||||
}
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if c.Secret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(c.Secret))
|
||||
mac.Write(body)
|
||||
req.Header.Set("X-Happydomain-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import (
|
|||
"git.happydns.org/happyDomain/internal/usecase/domain"
|
||||
"git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||
"git.happydns.org/happyDomain/internal/usecase/insight"
|
||||
"git.happydns.org/happyDomain/internal/usecase/notification"
|
||||
"git.happydns.org/happyDomain/internal/usecase/provider"
|
||||
"git.happydns.org/happyDomain/internal/usecase/session"
|
||||
"git.happydns.org/happyDomain/internal/usecase/user"
|
||||
|
|
@ -53,6 +54,10 @@ type Storage interface {
|
|||
domain.DomainStorage
|
||||
domainlog.DomainLogStorage
|
||||
insight.InsightStorage
|
||||
notification.NotificationChannelStorage
|
||||
notification.NotificationPreferenceStorage
|
||||
notification.NotificationStateStorage
|
||||
notification.NotificationRecordStorage
|
||||
provider.ProviderStorage
|
||||
session.SessionStorage
|
||||
user.UserStorage
|
||||
|
|
|
|||
136
internal/storage/kvtpl/notification_channel.go
Normal file
136
internal/storage/kvtpl/notification_channel.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Layout: notifch|<channelId> -> full record; notifch-user|<userId>|<channelId> -> "" (index only, no double-write).
|
||||
|
||||
const (
|
||||
notifchPrimaryPrefix = "notifch|"
|
||||
notifchUserPrefix = "notifch-user|"
|
||||
)
|
||||
|
||||
func notifchPrimaryKey(id happydns.Identifier) string {
|
||||
return notifchPrimaryPrefix + id.String()
|
||||
}
|
||||
|
||||
func notifchUserKey(userId, channelId happydns.Identifier) string {
|
||||
return fmt.Sprintf("%s%s|%s", notifchUserPrefix, userId.String(), channelId.String())
|
||||
}
|
||||
|
||||
func channelIdFromUserIndexKey(key string) (string, bool) {
|
||||
rest, ok := strings.CutPrefix(key, notifchUserPrefix)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
_, channelId, ok := strings.Cut(rest, "|")
|
||||
if !ok || channelId == "" {
|
||||
return "", false
|
||||
}
|
||||
return channelId, true
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListChannelsByUser(userId happydns.Identifier) ([]*happydns.NotificationChannel, error) {
|
||||
prefix := notifchUserPrefix + userId.String() + "|"
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
var channels []*happydns.NotificationChannel
|
||||
for iter.Next() {
|
||||
idStr, ok := channelIdFromUserIndexKey(iter.Key())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, err := happydns.NewIdentifierFromString(idStr)
|
||||
if err != nil {
|
||||
log.Printf("storage: malformed channel index key %q: %v", iter.Key(), err)
|
||||
continue
|
||||
}
|
||||
ch, err := s.GetChannel(id)
|
||||
if err != nil {
|
||||
// Index drift: skip rather than fail the whole list.
|
||||
log.Printf("storage: channel index points to missing channel %q: %v", idStr, err)
|
||||
continue
|
||||
}
|
||||
channels = append(channels, ch)
|
||||
}
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetChannel(channelId happydns.Identifier) (*happydns.NotificationChannel, error) {
|
||||
ch := &happydns.NotificationChannel{}
|
||||
err := s.db.Get(notifchPrimaryKey(channelId), ch)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return nil, happydns.ErrNotificationChannelNotFound
|
||||
}
|
||||
return ch, err
|
||||
}
|
||||
|
||||
func (s *KVStorage) CreateChannel(ch *happydns.NotificationChannel) error {
|
||||
key, id, err := s.db.FindIdentifierKey(notifchPrimaryPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ch.Id = id
|
||||
|
||||
if err := s.db.Put(key, ch); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.db.Put(notifchUserKey(ch.UserId, ch.Id), ""); err != nil {
|
||||
// Roll back primary so a failed index write doesn't orphan it.
|
||||
if delErr := s.db.Delete(key); delErr != nil {
|
||||
log.Printf("storage: orphan channel %q after index write failed (rollback also failed: %v)", ch.Id.String(), delErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) UpdateChannel(ch *happydns.NotificationChannel) error {
|
||||
// Index has no payload, so only the primary needs writing.
|
||||
return s.db.Put(notifchPrimaryKey(ch.Id), ch)
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteChannel(channelId happydns.Identifier) error {
|
||||
ch, err := s.GetChannel(channelId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete index first so partial failure hides the channel rather than leaving it visible-but-broken.
|
||||
if err := s.db.Delete(notifchUserKey(ch.UserId, channelId)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.db.Delete(notifchPrimaryKey(channelId)); err != nil {
|
||||
log.Printf("storage: channel %q index removed but primary delete failed: %v", channelId.String(), err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
132
internal/storage/kvtpl/notification_preference.go
Normal file
132
internal/storage/kvtpl/notification_preference.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Same primary+per-user-index layout as notification_channel.go.
|
||||
|
||||
const (
|
||||
notifprefPrimaryPrefix = "notifpref|"
|
||||
notifprefUserPrefix = "notifpref-user|"
|
||||
)
|
||||
|
||||
func notifprefPrimaryKey(id happydns.Identifier) string {
|
||||
return notifprefPrimaryPrefix + id.String()
|
||||
}
|
||||
|
||||
func notifprefUserKey(userId, prefId happydns.Identifier) string {
|
||||
return fmt.Sprintf("%s%s|%s", notifprefUserPrefix, userId.String(), prefId.String())
|
||||
}
|
||||
|
||||
func prefIdFromUserIndexKey(key string) (string, bool) {
|
||||
rest, ok := strings.CutPrefix(key, notifprefUserPrefix)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
_, prefId, ok := strings.Cut(rest, "|")
|
||||
if !ok || prefId == "" {
|
||||
return "", false
|
||||
}
|
||||
return prefId, true
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListPreferencesByUser(userId happydns.Identifier) ([]*happydns.NotificationPreference, error) {
|
||||
prefix := notifprefUserPrefix + userId.String() + "|"
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
var prefs []*happydns.NotificationPreference
|
||||
for iter.Next() {
|
||||
idStr, ok := prefIdFromUserIndexKey(iter.Key())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, err := happydns.NewIdentifierFromString(idStr)
|
||||
if err != nil {
|
||||
log.Printf("storage: malformed preference index key %q: %v", iter.Key(), err)
|
||||
continue
|
||||
}
|
||||
pref, err := s.GetPreference(id)
|
||||
if err != nil {
|
||||
log.Printf("storage: preference index points to missing preference %q: %v", idStr, err)
|
||||
continue
|
||||
}
|
||||
prefs = append(prefs, pref)
|
||||
}
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetPreference(prefId happydns.Identifier) (*happydns.NotificationPreference, error) {
|
||||
pref := &happydns.NotificationPreference{}
|
||||
err := s.db.Get(notifprefPrimaryKey(prefId), pref)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return nil, happydns.ErrNotificationPreferenceNotFound
|
||||
}
|
||||
return pref, err
|
||||
}
|
||||
|
||||
func (s *KVStorage) CreatePreference(pref *happydns.NotificationPreference) error {
|
||||
key, id, err := s.db.FindIdentifierKey(notifprefPrimaryPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pref.Id = id
|
||||
|
||||
if err := s.db.Put(key, pref); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.db.Put(notifprefUserKey(pref.UserId, pref.Id), ""); err != nil {
|
||||
if delErr := s.db.Delete(key); delErr != nil {
|
||||
log.Printf("storage: orphan preference %q after index write failed (rollback also failed: %v)", pref.Id.String(), delErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) UpdatePreference(pref *happydns.NotificationPreference) error {
|
||||
return s.db.Put(notifprefPrimaryKey(pref.Id), pref)
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeletePreference(prefId happydns.Identifier) error {
|
||||
pref, err := s.GetPreference(prefId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.db.Delete(notifprefUserKey(pref.UserId, prefId)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.db.Delete(notifprefPrimaryKey(prefId)); err != nil {
|
||||
log.Printf("storage: preference %q index removed but primary delete failed: %v", prefId.String(), err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
97
internal/storage/kvtpl/notification_record.go
Normal file
97
internal/storage/kvtpl/notification_record.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func (s *KVStorage) CreateRecord(rec *happydns.NotificationRecord) error {
|
||||
key, id, err := s.db.FindIdentifierKey("notifrec|")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rec.Id = id
|
||||
|
||||
if err := s.db.Put(key, rec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexKey := fmt.Sprintf("notifrec-user|%s|%s", rec.UserId.String(), rec.Id.String())
|
||||
return s.db.Put(indexKey, rec)
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListRecordsByUser(userId happydns.Identifier, limit int) ([]*happydns.NotificationRecord, error) {
|
||||
prefix := fmt.Sprintf("notifrec-user|%s|", userId.String())
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
var records []*happydns.NotificationRecord
|
||||
for iter.Next() {
|
||||
var rec happydns.NotificationRecord
|
||||
if err := s.db.DecodeData(iter.Value(), &rec); err != nil {
|
||||
// Corrupt entry: log and skip rather than fail the whole listing.
|
||||
log.Printf("storage: malformed notification record at %q: %v", iter.Key(), err)
|
||||
continue
|
||||
}
|
||||
records = append(records, &rec)
|
||||
}
|
||||
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
return records[i].SentAt.After(records[j].SentAt)
|
||||
})
|
||||
|
||||
if limit > 0 && len(records) > limit {
|
||||
records = records[:limit]
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteRecordsOlderThan(before time.Time) error {
|
||||
iter := s.db.Search("notifrec|")
|
||||
defer iter.Release()
|
||||
|
||||
var errs []error
|
||||
for iter.Next() {
|
||||
var rec happydns.NotificationRecord
|
||||
if err := s.db.DecodeData(iter.Value(), &rec); err != nil {
|
||||
log.Printf("storage: malformed notification record at %q: %v", iter.Key(), err)
|
||||
continue
|
||||
}
|
||||
if rec.SentAt.Before(before) {
|
||||
if err := s.db.Delete(iter.Key()); err != nil {
|
||||
errs = append(errs, fmt.Errorf("delete %s: %w", iter.Key(), err))
|
||||
}
|
||||
userIndexKey := fmt.Sprintf("notifrec-user|%s|%s", rec.UserId.String(), rec.Id.String())
|
||||
if err := s.db.Delete(userIndexKey); err != nil {
|
||||
errs = append(errs, fmt.Errorf("delete %s: %w", userIndexKey, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
76
internal/storage/kvtpl/notification_state.go
Normal file
76
internal/storage/kvtpl/notification_state.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Built from explicit fields so SDK changes to CheckTarget can't reshape the key and orphan stored records.
|
||||
func notifStateKey(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) string {
|
||||
return fmt.Sprintf(
|
||||
"notifstate|%s|%s|%s/%s/%s",
|
||||
userId.String(),
|
||||
checkerID,
|
||||
target.UserId,
|
||||
target.DomainId,
|
||||
target.ServiceId,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (*happydns.NotificationState, error) {
|
||||
state := &happydns.NotificationState{}
|
||||
err := s.db.Get(notifStateKey(checkerID, target, userId), state)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return nil, happydns.ErrNotificationStateNotFound
|
||||
}
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (s *KVStorage) PutState(state *happydns.NotificationState) error {
|
||||
return s.db.Put(notifStateKey(state.CheckerID, state.Target, state.UserId), state)
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) error {
|
||||
return s.db.Delete(notifStateKey(checkerID, target, userId))
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) {
|
||||
prefix := fmt.Sprintf("notifstate|%s|", userId.String())
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
var states []*happydns.NotificationState
|
||||
for iter.Next() {
|
||||
var state happydns.NotificationState
|
||||
if err := s.db.DecodeData(iter.Value(), &state); err != nil {
|
||||
log.Printf("storage: malformed notification state at %q: %v", iter.Key(), err)
|
||||
continue
|
||||
}
|
||||
states = append(states, &state)
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
28
internal/storage/kvtpl/updates-from-10.go
Normal file
28
internal/storage/kvtpl/updates-from-10.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package database
|
||||
|
||||
// migrateFrom10 adds the notification system tables.
|
||||
// KV storage uses prefix-based keys, so no structural migration is needed.
|
||||
func migrateFrom10(s *KVStorage) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ var migrations []KVMigrationFunc = []KVMigrationFunc{
|
|||
migrateFrom7,
|
||||
migrateFrom8,
|
||||
migrateFrom9,
|
||||
migrateFrom10,
|
||||
}
|
||||
|
||||
type Version struct {
|
||||
|
|
|
|||
|
|
@ -26,12 +26,24 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
checkerPkg "git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// executionCallback is the signature stored under onComplete. Wrapping it in a
|
||||
// named type lets us hold it via atomic.Pointer without leaking the function
|
||||
// type spelling everywhere.
|
||||
type executionCallback func(*happydns.Execution, *happydns.CheckEvaluation)
|
||||
|
||||
// ExecutionCallbackSetter is implemented by checker engines that support
|
||||
// notification callbacks after execution completion.
|
||||
type ExecutionCallbackSetter interface {
|
||||
SetExecutionCallback(func(*happydns.Execution, *happydns.CheckEvaluation))
|
||||
}
|
||||
|
||||
// checkerEngine implements the happydns.CheckerEngine interface.
|
||||
type checkerEngine struct {
|
||||
optionsUC *CheckerOptionsUsecase
|
||||
|
|
@ -42,6 +54,21 @@ type checkerEngine struct {
|
|||
entryStore DiscoveryEntryStorage
|
||||
obsRefStore DiscoveryObservationStorage
|
||||
relatedLookup checkerPkg.RelatedObservationLookup
|
||||
|
||||
// onComplete is read concurrently by RunExecution from worker goroutines
|
||||
// while SetExecutionCallback writes it during app wiring (and potentially
|
||||
// later, defensively). atomic.Pointer keeps the load/store race-free.
|
||||
onComplete atomic.Pointer[executionCallback]
|
||||
}
|
||||
|
||||
// SetExecutionCallback registers a callback invoked after each successful execution.
|
||||
func (e *checkerEngine) SetExecutionCallback(cb func(*happydns.Execution, *happydns.CheckEvaluation)) {
|
||||
if cb == nil {
|
||||
e.onComplete.Store(nil)
|
||||
return
|
||||
}
|
||||
wrapped := executionCallback(cb)
|
||||
e.onComplete.Store(&wrapped)
|
||||
}
|
||||
|
||||
// NewCheckerEngine creates a new CheckerEngine implementation. Passing nil
|
||||
|
|
@ -145,6 +172,13 @@ func (e *checkerEngine) RunExecution(ctx context.Context, exec *happydns.Executi
|
|||
log.Printf("CheckerEngine: failed to update execution: %v", err)
|
||||
}
|
||||
|
||||
// Fire notification callback. The callback decides synchronously whether
|
||||
// to notify and advances state, but actual sender invocations are
|
||||
// dispatched to a worker pool so a slow channel cannot wedge the engine.
|
||||
if cb := e.onComplete.Load(); cb != nil {
|
||||
(*cb)(exec, eval)
|
||||
}
|
||||
|
||||
return eval, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
93
internal/usecase/notification/acknowledge.go
Normal file
93
internal/usecase/notification/acknowledge.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Depends only on the state store — no senders, no preferences.
|
||||
type AckService struct {
|
||||
stateStore NotificationStateStorage
|
||||
locker *StateLocker
|
||||
// Overridable for tests.
|
||||
nowFn func() time.Time
|
||||
}
|
||||
|
||||
// locker is shared with the Dispatcher to avoid racing on the same state record.
|
||||
func NewAckService(stateStore NotificationStateStorage, locker *StateLocker) *AckService {
|
||||
return &AckService{stateStore: stateStore, locker: locker, nowFn: time.Now}
|
||||
}
|
||||
|
||||
// An existing state record (created by the dispatcher when an execution
|
||||
// completed) is required: acknowledging an issue that the dispatcher has
|
||||
// never observed is rejected with ErrNotificationStateNotFound. This avoids
|
||||
// letting an authenticated client materialize arbitrary state records by
|
||||
// guessing checker IDs or target tuples.
|
||||
func (a *AckService) AcknowledgeIssue(userId happydns.Identifier, checkerID string, target happydns.CheckTarget, acknowledgedBy string, annotation string) error {
|
||||
unlock := a.locker.Lock(checkerID, target, userId)
|
||||
defer unlock()
|
||||
|
||||
state, err := a.stateStore.GetState(checkerID, target, userId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Defense in depth: the storage key already encodes userId, but reject any
|
||||
// state whose stored UserId disagrees rather than silently overwriting.
|
||||
if !state.UserId.Equals(userId) {
|
||||
return happydns.ErrNotificationStateNotFound
|
||||
}
|
||||
|
||||
now := a.nowFn()
|
||||
state.Acknowledged = true
|
||||
state.AcknowledgedAt = &now
|
||||
state.AcknowledgedBy = acknowledgedBy
|
||||
state.Annotation = annotation
|
||||
|
||||
return a.stateStore.PutState(state)
|
||||
}
|
||||
|
||||
func (a *AckService) ClearAcknowledgement(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) error {
|
||||
unlock := a.locker.Lock(checkerID, target, userId)
|
||||
defer unlock()
|
||||
|
||||
state, err := a.stateStore.GetState(checkerID, target, userId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !state.UserId.Equals(userId) {
|
||||
return happydns.ErrNotificationStateNotFound
|
||||
}
|
||||
|
||||
state.ClearAcknowledgement()
|
||||
return a.stateStore.PutState(state)
|
||||
}
|
||||
|
||||
func (a *AckService) GetState(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) (*happydns.NotificationState, error) {
|
||||
return a.stateStore.GetState(checkerID, target, userId)
|
||||
}
|
||||
|
||||
func (a *AckService) ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) {
|
||||
return a.stateStore.ListStatesByUser(userId)
|
||||
}
|
||||
209
internal/usecase/notification/dispatcher.go
Normal file
209
internal/usecase/notification/dispatcher.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
notifPkg "git.happydns.org/happyDomain/internal/notification"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Glue between checker execution and notification system; owns no I/O — all of it lives in collaborators.
|
||||
type Dispatcher struct {
|
||||
stateStore NotificationStateStorage
|
||||
userStore UserGetter
|
||||
domainStore DomainGetter
|
||||
|
||||
resolver *Resolver
|
||||
pool *Pool
|
||||
tester *Tester
|
||||
ack *AckService
|
||||
locker *StateLocker
|
||||
|
||||
// Overridable for tests.
|
||||
nowFn func() time.Time
|
||||
}
|
||||
|
||||
// Caller owns Pool lifecycle.
|
||||
func NewDispatcher(
|
||||
stateStore NotificationStateStorage,
|
||||
userStore UserGetter,
|
||||
domainStore DomainGetter,
|
||||
resolver *Resolver,
|
||||
pool *Pool,
|
||||
tester *Tester,
|
||||
ack *AckService,
|
||||
locker *StateLocker,
|
||||
) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
stateStore: stateStore,
|
||||
userStore: userStore,
|
||||
domainStore: domainStore,
|
||||
resolver: resolver,
|
||||
pool: pool,
|
||||
tester: tester,
|
||||
ack: ack,
|
||||
locker: locker,
|
||||
nowFn: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Start() { d.pool.Start() }
|
||||
func (d *Dispatcher) Stop() { d.pool.Stop() }
|
||||
|
||||
func (d *Dispatcher) OnExecutionComplete(exec *happydns.Execution, eval *happydns.CheckEvaluation) {
|
||||
if exec == nil || exec.Status != happydns.ExecutionDone {
|
||||
return
|
||||
}
|
||||
|
||||
userId := happydns.TargetIdentifier(exec.Target.UserId)
|
||||
if userId == nil {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := d.userStore.GetUser(*userId)
|
||||
if err != nil {
|
||||
log.Printf("notification: failed to load user %q: %v", userId, err)
|
||||
return
|
||||
}
|
||||
|
||||
newStatus := exec.Result.Status
|
||||
|
||||
// Serialize with AckService so concurrent updates can't wipe an ack or fire duplicates.
|
||||
unlock := d.locker.Lock(exec.CheckerID, exec.Target, *userId)
|
||||
defer unlock()
|
||||
|
||||
state, err := d.loadOrInitState(exec, *userId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
oldStatus := state.LastStatus
|
||||
pref := d.resolver.ResolvePreference(user, exec.Target)
|
||||
|
||||
dec := decide(state, pref, newStatus, d.nowFn())
|
||||
|
||||
// Recovery/escalation invalidates ack: incident is over or has worsened.
|
||||
if dec.ClearAck {
|
||||
state.ClearAcknowledgement()
|
||||
}
|
||||
|
||||
switch dec.Action {
|
||||
case actionSkip:
|
||||
return
|
||||
case actionAdvance:
|
||||
d.advanceState(state, newStatus)
|
||||
return
|
||||
}
|
||||
|
||||
payload := d.buildPayload(user, exec, eval, oldStatus, newStatus)
|
||||
|
||||
// Mark before enqueue so a rapid re-run sees oldStatus == newStatus and skips.
|
||||
d.markNotified(state, newStatus)
|
||||
|
||||
for _, ch := range d.resolver.ResolveChannels(user, pref) {
|
||||
d.pool.Enqueue(ch, payload, user)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) loadOrInitState(exec *happydns.Execution, userId happydns.Identifier) (*happydns.NotificationState, error) {
|
||||
state, err := d.stateStore.GetState(exec.CheckerID, exec.Target, userId)
|
||||
if errors.Is(err, happydns.ErrNotificationStateNotFound) {
|
||||
return &happydns.NotificationState{
|
||||
CheckerID: exec.CheckerID,
|
||||
Target: exec.Target,
|
||||
UserId: userId,
|
||||
LastStatus: happydns.StatusUnknown,
|
||||
}, nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("notification: failed to load state for %q/%q: %v", exec.CheckerID, exec.Target.String(), err)
|
||||
return nil, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (d *Dispatcher) buildPayload(user *happydns.User, exec *happydns.Execution, eval *happydns.CheckEvaluation, oldStatus, newStatus happydns.Status) *notifPkg.NotificationPayload {
|
||||
var domainName string
|
||||
if did := happydns.TargetIdentifier(exec.Target.DomainId); did != nil {
|
||||
if domain, err := d.domainStore.GetDomain(*did); err == nil {
|
||||
domainName = domain.DomainName
|
||||
}
|
||||
}
|
||||
if domainName == "" {
|
||||
domainName = "(unknown domain)"
|
||||
}
|
||||
|
||||
var states []happydns.CheckState
|
||||
if eval != nil {
|
||||
states = eval.States
|
||||
}
|
||||
|
||||
return ¬ifPkg.NotificationPayload{
|
||||
Recipient: notifPkg.Recipient{Email: user.Email},
|
||||
CheckerID: exec.CheckerID,
|
||||
Target: exec.Target,
|
||||
DomainName: domainName,
|
||||
OldStatus: oldStatus,
|
||||
NewStatus: newStatus,
|
||||
States: states,
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the observed status without claiming a notification was sent (policy suppressed it).
|
||||
func (d *Dispatcher) advanceState(state *happydns.NotificationState, newStatus happydns.Status) {
|
||||
state.LastStatus = newStatus
|
||||
if err := d.stateStore.PutState(state); err != nil {
|
||||
log.Printf("notification: failed to update state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) markNotified(state *happydns.NotificationState, newStatus happydns.Status) {
|
||||
state.LastStatus = newStatus
|
||||
state.LastNotifiedAt = d.nowFn()
|
||||
if err := d.stateStore.PutState(state); err != nil {
|
||||
log.Printf("notification: failed to update state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) SendTestNotification(ch *happydns.NotificationChannel, user *happydns.User) error {
|
||||
return d.tester.Send(ch, user)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) AcknowledgeIssue(userId happydns.Identifier, checkerID string, target happydns.CheckTarget, acknowledgedBy string, annotation string) error {
|
||||
return d.ack.AcknowledgeIssue(userId, checkerID, target, acknowledgedBy, annotation)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) ClearAcknowledgement(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) error {
|
||||
return d.ack.ClearAcknowledgement(userId, checkerID, target)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) GetState(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) (*happydns.NotificationState, error) {
|
||||
return d.ack.GetState(userId, checkerID, target)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) {
|
||||
return d.ack.ListStatesByUser(userId)
|
||||
}
|
||||
117
internal/usecase/notification/policy.go
Normal file
117
internal/usecase/notification/policy.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
type decisionAction int
|
||||
|
||||
const (
|
||||
actionSkip decisionAction = iota
|
||||
actionAdvance
|
||||
actionNotify
|
||||
)
|
||||
|
||||
// Reason is for logging only — callers must not branch on its text.
|
||||
type decision struct {
|
||||
Action decisionAction
|
||||
Reason string
|
||||
IsRecovery bool
|
||||
IsEscalation bool
|
||||
ClearAck bool
|
||||
}
|
||||
|
||||
// Pure predicate. nil pref means "no preference" — suppress notify, still advance state. now is injected for quiet-hour tests.
|
||||
func decide(state *happydns.NotificationState, pref *happydns.NotificationPreference, newStatus happydns.Status, now time.Time) decision {
|
||||
oldStatus := state.LastStatus
|
||||
|
||||
if oldStatus == newStatus {
|
||||
return decision{Action: actionSkip, Reason: "no transition"}
|
||||
}
|
||||
|
||||
isRecovery := newStatus < happydns.StatusWarn && oldStatus >= happydns.StatusWarn
|
||||
isEscalation := newStatus > oldStatus && newStatus >= happydns.StatusWarn
|
||||
clearAck := isRecovery || isEscalation
|
||||
|
||||
d := decision{IsRecovery: isRecovery, IsEscalation: isEscalation, ClearAck: clearAck}
|
||||
|
||||
if pref == nil {
|
||||
d.Action = actionAdvance
|
||||
d.Reason = "no preference configured"
|
||||
return d
|
||||
}
|
||||
if !pref.Enabled {
|
||||
d.Action = actionAdvance
|
||||
d.Reason = "preference disabled"
|
||||
return d
|
||||
}
|
||||
if !isRecovery && newStatus < pref.MinStatus {
|
||||
d.Action = actionAdvance
|
||||
d.Reason = "below MinStatus threshold"
|
||||
return d
|
||||
}
|
||||
if isRecovery && !pref.NotifyRecovery {
|
||||
d.Action = actionAdvance
|
||||
d.Reason = "recovery suppressed by preference"
|
||||
return d
|
||||
}
|
||||
// Active ack means user already knows; recoveries skip this check.
|
||||
if state.Acknowledged && !clearAck && !isRecovery {
|
||||
d.Action = actionAdvance
|
||||
d.Reason = "acknowledged"
|
||||
return d
|
||||
}
|
||||
if isQuietHour(pref, now) {
|
||||
d.Action = actionAdvance
|
||||
d.Reason = "quiet hours"
|
||||
return d
|
||||
}
|
||||
|
||||
d.Action = actionNotify
|
||||
d.Reason = "notify"
|
||||
return d
|
||||
}
|
||||
|
||||
func isQuietHour(pref *happydns.NotificationPreference, now time.Time) bool {
|
||||
if pref.QuietStart == nil || pref.QuietEnd == nil {
|
||||
return false
|
||||
}
|
||||
loc := time.UTC
|
||||
if pref.Timezone != "" {
|
||||
// Validated at write time; on a stale/invalid value we silently fall back to UTC rather than firing during what the user thinks are quiet hours.
|
||||
if l, err := time.LoadLocation(pref.Timezone); err == nil {
|
||||
loc = l
|
||||
}
|
||||
}
|
||||
hour := now.In(loc).Hour()
|
||||
start := *pref.QuietStart
|
||||
end := *pref.QuietEnd
|
||||
if start <= end {
|
||||
return hour >= start && hour < end
|
||||
}
|
||||
// Wraps midnight, e.g. 22:00 - 06:00.
|
||||
return hour >= start || hour < end
|
||||
}
|
||||
226
internal/usecase/notification/policy_test.go
Normal file
226
internal/usecase/notification/policy_test.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func TestDecide(t *testing.T) {
|
||||
noon := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
|
||||
night := time.Date(2026, 4, 29, 3, 0, 0, 0, time.UTC)
|
||||
|
||||
enabled := func() *happydns.NotificationPreference {
|
||||
return &happydns.NotificationPreference{
|
||||
Enabled: true,
|
||||
MinStatus: happydns.StatusWarn,
|
||||
NotifyRecovery: true,
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
state happydns.NotificationState
|
||||
pref *happydns.NotificationPreference
|
||||
newStatus happydns.Status
|
||||
now time.Time
|
||||
wantAction decisionAction
|
||||
wantClear bool
|
||||
}{
|
||||
{
|
||||
name: "no transition",
|
||||
state: happydns.NotificationState{LastStatus: happydns.StatusOK},
|
||||
pref: enabled(),
|
||||
newStatus: happydns.StatusOK,
|
||||
now: noon,
|
||||
wantAction: actionSkip,
|
||||
},
|
||||
{
|
||||
name: "nil preference advances state without notifying",
|
||||
state: happydns.NotificationState{LastStatus: happydns.StatusOK},
|
||||
pref: nil,
|
||||
newStatus: happydns.StatusCrit,
|
||||
now: noon,
|
||||
wantAction: actionAdvance,
|
||||
wantClear: true,
|
||||
},
|
||||
{
|
||||
name: "preference disabled advances",
|
||||
state: happydns.NotificationState{LastStatus: happydns.StatusOK},
|
||||
pref: &happydns.NotificationPreference{
|
||||
Enabled: false,
|
||||
MinStatus: happydns.StatusWarn,
|
||||
},
|
||||
newStatus: happydns.StatusCrit,
|
||||
now: noon,
|
||||
wantAction: actionAdvance,
|
||||
wantClear: true,
|
||||
},
|
||||
{
|
||||
name: "below MinStatus advances",
|
||||
state: happydns.NotificationState{LastStatus: happydns.StatusUnknown},
|
||||
pref: enabled(),
|
||||
newStatus: happydns.StatusOK,
|
||||
now: noon,
|
||||
wantAction: actionAdvance,
|
||||
},
|
||||
{
|
||||
name: "recovery suppressed when NotifyRecovery is false",
|
||||
state: happydns.NotificationState{LastStatus: happydns.StatusCrit},
|
||||
pref: &happydns.NotificationPreference{
|
||||
Enabled: true,
|
||||
MinStatus: happydns.StatusWarn,
|
||||
NotifyRecovery: false,
|
||||
},
|
||||
newStatus: happydns.StatusOK,
|
||||
now: noon,
|
||||
wantAction: actionAdvance,
|
||||
wantClear: true,
|
||||
},
|
||||
{
|
||||
name: "recovery notifies when NotifyRecovery is true",
|
||||
state: happydns.NotificationState{LastStatus: happydns.StatusCrit},
|
||||
pref: enabled(),
|
||||
newStatus: happydns.StatusOK,
|
||||
now: noon,
|
||||
wantAction: actionNotify,
|
||||
wantClear: true,
|
||||
},
|
||||
{
|
||||
name: "escalation notifies and clears ack",
|
||||
state: happydns.NotificationState{LastStatus: happydns.StatusWarn, Acknowledged: true},
|
||||
pref: enabled(),
|
||||
newStatus: happydns.StatusCrit,
|
||||
now: noon,
|
||||
wantAction: actionNotify,
|
||||
wantClear: true,
|
||||
},
|
||||
{
|
||||
name: "acknowledged non-recovery is suppressed",
|
||||
state: happydns.NotificationState{LastStatus: happydns.StatusCrit, Acknowledged: true},
|
||||
pref: enabled(),
|
||||
newStatus: happydns.StatusWarn,
|
||||
now: noon,
|
||||
wantAction: actionAdvance,
|
||||
wantClear: false,
|
||||
},
|
||||
{
|
||||
name: "quiet hours suppress alert",
|
||||
state: happydns.NotificationState{LastStatus: happydns.StatusOK},
|
||||
pref: &happydns.NotificationPreference{
|
||||
Enabled: true,
|
||||
MinStatus: happydns.StatusWarn,
|
||||
NotifyRecovery: true,
|
||||
QuietStart: ptr(22),
|
||||
QuietEnd: ptr(6),
|
||||
},
|
||||
newStatus: happydns.StatusCrit,
|
||||
now: night,
|
||||
wantAction: actionAdvance,
|
||||
wantClear: true,
|
||||
},
|
||||
{
|
||||
name: "outside quiet hours notifies",
|
||||
state: happydns.NotificationState{LastStatus: happydns.StatusOK},
|
||||
pref: &happydns.NotificationPreference{
|
||||
Enabled: true,
|
||||
MinStatus: happydns.StatusWarn,
|
||||
NotifyRecovery: true,
|
||||
QuietStart: ptr(22),
|
||||
QuietEnd: ptr(6),
|
||||
},
|
||||
newStatus: happydns.StatusCrit,
|
||||
now: noon,
|
||||
wantAction: actionNotify,
|
||||
wantClear: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := decide(&tc.state, tc.pref, tc.newStatus, tc.now)
|
||||
if got.Action != tc.wantAction {
|
||||
t.Errorf("Action: got %v (%s), want %v", got.Action, got.Reason, tc.wantAction)
|
||||
}
|
||||
if got.ClearAck != tc.wantClear {
|
||||
t.Errorf("ClearAck: got %v, want %v", got.ClearAck, tc.wantClear)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsQuietHour(t *testing.T) {
|
||||
at := func(h int) time.Time {
|
||||
return time.Date(2026, 4, 29, h, 30, 0, 0, time.UTC)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
start *int
|
||||
end *int
|
||||
hour int
|
||||
expect bool
|
||||
}{
|
||||
{"no window", nil, nil, 3, false},
|
||||
{"inside non-wrap (9-17)", ptr(9), ptr(17), 12, true},
|
||||
{"outside non-wrap (9-17)", ptr(9), ptr(17), 18, false},
|
||||
{"end-exclusive (9-17 at 17)", ptr(9), ptr(17), 17, false},
|
||||
{"wrap inside before midnight (22-6)", ptr(22), ptr(6), 23, true},
|
||||
{"wrap inside after midnight (22-6)", ptr(22), ptr(6), 3, true},
|
||||
{"wrap outside (22-6)", ptr(22), ptr(6), 12, false},
|
||||
{"wrap end-exclusive (22-6 at 6)", ptr(22), ptr(6), 6, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pref := &happydns.NotificationPreference{QuietStart: tc.start, QuietEnd: tc.end}
|
||||
if got := isQuietHour(pref, at(tc.hour)); got != tc.expect {
|
||||
t.Errorf("got %v, want %v", got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsQuietHourTimezone(t *testing.T) {
|
||||
// 02:30 UTC == 12:30 Asia/Tokyo (UTC+9), so a 9-17 quiet window in Tokyo should fire while UTC says off-hours.
|
||||
now := time.Date(2026, 4, 29, 2, 30, 0, 0, time.UTC)
|
||||
pref := &happydns.NotificationPreference{
|
||||
QuietStart: ptr(9),
|
||||
QuietEnd: ptr(17),
|
||||
Timezone: "Asia/Tokyo",
|
||||
}
|
||||
if !isQuietHour(pref, now) {
|
||||
t.Fatalf("expected quiet hour in Asia/Tokyo at local 11:30, got false")
|
||||
}
|
||||
pref.Timezone = ""
|
||||
if isQuietHour(pref, now) {
|
||||
t.Fatalf("expected non-quiet in UTC at 02:30, got true")
|
||||
}
|
||||
// Invalid TZ falls back to UTC.
|
||||
pref.Timezone = "Not/AReal_Zone"
|
||||
if isQuietHour(pref, now) {
|
||||
t.Fatalf("expected fallback to UTC for invalid timezone, got quiet hour")
|
||||
}
|
||||
}
|
||||
174
internal/usecase/notification/pool.go
Normal file
174
internal/usecase/notification/pool.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
notifPkg "git.happydns.org/happyDomain/internal/notification"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
const (
|
||||
dispatchWorkers = 4
|
||||
// On overflow, an audit record is written so back-pressure losses are visible in history.
|
||||
dispatchQueueSize = 256
|
||||
// Caps a single Send so a wedged endpoint cannot starve workers.
|
||||
sendTimeout = 15 * time.Second
|
||||
// Bounds the persisted error to keep the audit log small.
|
||||
maxRecordErrorLen = 512
|
||||
)
|
||||
|
||||
func truncateError(s string) string {
|
||||
const marker = "…[truncated]"
|
||||
if len(s) <= maxRecordErrorLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxRecordErrorLen-len(marker)] + marker
|
||||
}
|
||||
|
||||
type dispatchJob struct {
|
||||
channel *happydns.NotificationChannel
|
||||
payload *notifPkg.NotificationPayload
|
||||
user *happydns.User
|
||||
}
|
||||
|
||||
// Async send fan-out; no policy — caller decides whether to enqueue.
|
||||
type Pool struct {
|
||||
registry *notifPkg.Registry
|
||||
recordStore NotificationRecordStorage
|
||||
|
||||
jobs chan dispatchJob
|
||||
wg sync.WaitGroup
|
||||
stopped atomic.Bool
|
||||
stopOnce sync.Once
|
||||
|
||||
// Overridable for tests.
|
||||
nowFn func() time.Time
|
||||
}
|
||||
|
||||
func NewPool(registry *notifPkg.Registry, recordStore NotificationRecordStorage) *Pool {
|
||||
return &Pool{
|
||||
registry: registry,
|
||||
recordStore: recordStore,
|
||||
jobs: make(chan dispatchJob, dispatchQueueSize),
|
||||
nowFn: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) Start() {
|
||||
for range dispatchWorkers {
|
||||
p.wg.Add(1)
|
||||
go p.worker()
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotent. Post-Stop, Enqueue is a no-op so a racing caller doesn't panic on send-to-closed-channel.
|
||||
func (p *Pool) Stop() {
|
||||
p.stopOnce.Do(func() {
|
||||
p.stopped.Store(true)
|
||||
close(p.jobs)
|
||||
})
|
||||
p.wg.Wait()
|
||||
}
|
||||
|
||||
func (p *Pool) worker() {
|
||||
defer p.wg.Done()
|
||||
for job := range p.jobs {
|
||||
p.sendAndRecord(job.channel, job.payload, job.user)
|
||||
}
|
||||
}
|
||||
|
||||
// On saturation, persists an audit record so the missed alert surfaces in history.
|
||||
func (p *Pool) Enqueue(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User) bool {
|
||||
if p.stopped.Load() {
|
||||
return false
|
||||
}
|
||||
job := dispatchJob{channel: ch, payload: payload, user: user}
|
||||
select {
|
||||
case p.jobs <- job:
|
||||
return true
|
||||
default:
|
||||
// Saturated: record the miss rather than silently drop.
|
||||
log.Printf("notification: dispatch queue full, recording back-pressure failure for channel %q (%q)", ch.Id, ch.Type)
|
||||
p.recordSaturation(ch, payload, user)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) recordSaturation(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User) {
|
||||
rec := newRecord(ch, payload, user, p.nowFn())
|
||||
rec.Success = false
|
||||
rec.Error = "dispatch queue saturated"
|
||||
if err := p.recordStore.CreateRecord(rec); err != nil {
|
||||
log.Printf("notification: failed to log saturation record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) sendAndRecord(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User) {
|
||||
rec := newRecord(ch, payload, user, p.nowFn())
|
||||
|
||||
if err := p.runSend(ch, payload); err != nil {
|
||||
log.Printf("notification: failed to send via %q channel %q: %v", ch.Type, ch.Id, err)
|
||||
rec.Success = false
|
||||
rec.Error = truncateError(err.Error())
|
||||
} else {
|
||||
rec.Success = true
|
||||
}
|
||||
|
||||
if err := p.recordStore.CreateRecord(rec); err != nil {
|
||||
log.Printf("notification: failed to log record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) runSend(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload) error {
|
||||
sender, ok := p.registry.Get(ch.Type)
|
||||
if !ok {
|
||||
return fmt.Errorf("no sender for channel type %q", ch.Type)
|
||||
}
|
||||
cfg, err := sender.DecodeConfig(ch.Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config for channel %s: %w", ch.Id, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sendTimeout)
|
||||
defer cancel()
|
||||
return sender.Send(ctx, cfg, payload)
|
||||
}
|
||||
|
||||
// Caller fills Success/Error after the send attempt.
|
||||
func newRecord(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User, sentAt time.Time) *happydns.NotificationRecord {
|
||||
return &happydns.NotificationRecord{
|
||||
UserId: user.Id,
|
||||
ChannelType: ch.Type,
|
||||
ChannelId: ch.Id,
|
||||
CheckerID: payload.CheckerID,
|
||||
Target: payload.Target,
|
||||
OldStatus: payload.OldStatus,
|
||||
NewStatus: payload.NewStatus,
|
||||
SentAt: sentAt,
|
||||
}
|
||||
}
|
||||
89
internal/usecase/notification/resolver.go
Normal file
89
internal/usecase/notification/resolver.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Read-only, safe to share between goroutines.
|
||||
type Resolver struct {
|
||||
channelStore NotificationChannelStorage
|
||||
prefStore NotificationPreferenceStorage
|
||||
}
|
||||
|
||||
func NewResolver(channelStore NotificationChannelStorage, prefStore NotificationPreferenceStorage) *Resolver {
|
||||
return &Resolver{channelStore: channelStore, prefStore: prefStore}
|
||||
}
|
||||
|
||||
// Specificity service > domain > global; falls back to DefaultNotificationPreference so opt-in defaults flow through.
|
||||
func (r *Resolver) ResolvePreference(user *happydns.User, target happydns.CheckTarget) *happydns.NotificationPreference {
|
||||
prefs, err := r.prefStore.ListPreferencesByUser(user.Id)
|
||||
if err != nil {
|
||||
log.Printf("notification: failed to load preferences for user %q: %v", user.Id, err)
|
||||
return happydns.DefaultNotificationPreference()
|
||||
}
|
||||
|
||||
var best *happydns.NotificationPreference
|
||||
bestSpecificity := -1
|
||||
for _, p := range prefs {
|
||||
s := p.MatchesTarget(target)
|
||||
if s > bestSpecificity {
|
||||
best = p
|
||||
bestSpecificity = s
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return happydns.DefaultNotificationPreference()
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func (r *Resolver) ResolveChannels(user *happydns.User, pref *happydns.NotificationPreference) []*happydns.NotificationChannel {
|
||||
allChannels, err := r.channelStore.ListChannelsByUser(user.Id)
|
||||
if err != nil {
|
||||
log.Printf("notification: failed to load channels for user %q: %v", user.Id, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var allowed map[string]bool
|
||||
if len(pref.ChannelIds) > 0 {
|
||||
allowed = make(map[string]bool, len(pref.ChannelIds))
|
||||
for _, id := range pref.ChannelIds {
|
||||
allowed[id.String()] = true
|
||||
}
|
||||
}
|
||||
|
||||
var result []*happydns.NotificationChannel
|
||||
for _, ch := range allChannels {
|
||||
if !ch.Enabled {
|
||||
continue
|
||||
}
|
||||
if allowed != nil && !allowed[ch.Id.String()] {
|
||||
continue
|
||||
}
|
||||
result = append(result, ch)
|
||||
}
|
||||
return result
|
||||
}
|
||||
98
internal/usecase/notification/resolver_test.go
Normal file
98
internal/usecase/notification/resolver_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
type fakePrefStore struct {
|
||||
prefs []*happydns.NotificationPreference
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakePrefStore) ListPreferencesByUser(_ happydns.Identifier) ([]*happydns.NotificationPreference, error) {
|
||||
return f.prefs, f.err
|
||||
}
|
||||
func (f *fakePrefStore) GetPreference(_ happydns.Identifier) (*happydns.NotificationPreference, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakePrefStore) CreatePreference(_ *happydns.NotificationPreference) error { return nil }
|
||||
func (f *fakePrefStore) UpdatePreference(_ *happydns.NotificationPreference) error { return nil }
|
||||
func (f *fakePrefStore) DeletePreference(_ happydns.Identifier) error { return nil }
|
||||
|
||||
func TestResolvePreferenceFallsBackToDefault(t *testing.T) {
|
||||
user := &happydns.User{Id: happydns.Identifier{1}}
|
||||
target := happydns.CheckTarget{UserId: user.Id.String(), DomainId: "dom-1"}
|
||||
|
||||
t.Run("no preferences returns opt-in default", func(t *testing.T) {
|
||||
r := NewResolver(nil, &fakePrefStore{})
|
||||
got := r.ResolvePreference(user, target)
|
||||
if got == nil {
|
||||
t.Fatal("expected default preference, got nil")
|
||||
}
|
||||
if !got.Enabled || got.MinStatus != happydns.StatusWarn {
|
||||
t.Errorf("default not opt-in: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("store error returns default", func(t *testing.T) {
|
||||
r := NewResolver(nil, &fakePrefStore{err: errors.New("boom")})
|
||||
if got := r.ResolvePreference(user, target); got == nil || !got.Enabled {
|
||||
t.Errorf("expected enabled default on store error, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("matching preference wins over default", func(t *testing.T) {
|
||||
domId, _ := happydns.NewIdentifierFromString("dom-1")
|
||||
user := &happydns.User{Id: happydns.Identifier{1}}
|
||||
userPref := &happydns.NotificationPreference{
|
||||
DomainId: &domId,
|
||||
Enabled: true,
|
||||
MinStatus: happydns.StatusCrit,
|
||||
}
|
||||
r := NewResolver(nil, &fakePrefStore{prefs: []*happydns.NotificationPreference{userPref}})
|
||||
got := r.ResolvePreference(user, happydns.CheckTarget{DomainId: domId.String()})
|
||||
if got != userPref {
|
||||
t.Errorf("expected user preference, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-matching scoped preference falls back to default", func(t *testing.T) {
|
||||
otherDom, _ := happydns.NewIdentifierFromString("dom-other")
|
||||
userPref := &happydns.NotificationPreference{
|
||||
DomainId: &otherDom,
|
||||
Enabled: true,
|
||||
}
|
||||
r := NewResolver(nil, &fakePrefStore{prefs: []*happydns.NotificationPreference{userPref}})
|
||||
got := r.ResolvePreference(user, happydns.CheckTarget{DomainId: "dom-1"})
|
||||
if got == nil || got == userPref {
|
||||
t.Errorf("expected default fallback, got %+v", got)
|
||||
}
|
||||
if !got.Enabled || got.MinStatus != happydns.StatusWarn {
|
||||
t.Errorf("default not opt-in: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
83
internal/usecase/notification/state_lock.go
Normal file
83
internal/usecase/notification/state_lock.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Per-key serialization for state read-modify-write; without it, ack and dispatcher races wipe acks or fire duplicates. In-process only — single instance.
|
||||
type StateLocker struct {
|
||||
mu sync.Mutex
|
||||
locks map[string]*stateLockEntry
|
||||
}
|
||||
|
||||
type stateLockEntry struct {
|
||||
mu sync.Mutex
|
||||
refCount int
|
||||
}
|
||||
|
||||
func NewStateLocker() *StateLocker {
|
||||
return &StateLocker{locks: make(map[string]*stateLockEntry)}
|
||||
}
|
||||
|
||||
// Always defer the returned unlock — leaking it pins the map entry.
|
||||
func (l *StateLocker) Lock(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) func() {
|
||||
key := stateLockKey(checkerID, target, userId)
|
||||
|
||||
l.mu.Lock()
|
||||
entry, ok := l.locks[key]
|
||||
if !ok {
|
||||
entry = &stateLockEntry{}
|
||||
l.locks[key] = entry
|
||||
}
|
||||
entry.refCount++
|
||||
l.mu.Unlock()
|
||||
|
||||
entry.mu.Lock()
|
||||
|
||||
return func() {
|
||||
entry.mu.Unlock()
|
||||
|
||||
l.mu.Lock()
|
||||
entry.refCount--
|
||||
if entry.refCount == 0 {
|
||||
delete(l.locks, key)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Must match the storage tuple exactly; mismatch silently re-introduces the race.
|
||||
func stateLockKey(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) string {
|
||||
return fmt.Sprintf(
|
||||
"%s|%s|%s/%s/%s",
|
||||
userId.String(),
|
||||
checkerID,
|
||||
target.UserId,
|
||||
target.DomainId,
|
||||
target.ServiceId,
|
||||
)
|
||||
}
|
||||
65
internal/usecase/notification/storage.go
Normal file
65
internal/usecase/notification/storage.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
type NotificationChannelStorage interface {
|
||||
ListChannelsByUser(userId happydns.Identifier) ([]*happydns.NotificationChannel, error)
|
||||
GetChannel(channelId happydns.Identifier) (*happydns.NotificationChannel, error)
|
||||
CreateChannel(ch *happydns.NotificationChannel) error
|
||||
UpdateChannel(ch *happydns.NotificationChannel) error
|
||||
DeleteChannel(channelId happydns.Identifier) error
|
||||
}
|
||||
|
||||
type NotificationPreferenceStorage interface {
|
||||
ListPreferencesByUser(userId happydns.Identifier) ([]*happydns.NotificationPreference, error)
|
||||
GetPreference(prefId happydns.Identifier) (*happydns.NotificationPreference, error)
|
||||
CreatePreference(pref *happydns.NotificationPreference) error
|
||||
UpdatePreference(pref *happydns.NotificationPreference) error
|
||||
DeletePreference(prefId happydns.Identifier) error
|
||||
}
|
||||
|
||||
type NotificationStateStorage interface {
|
||||
GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (*happydns.NotificationState, error)
|
||||
PutState(state *happydns.NotificationState) error
|
||||
DeleteState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) error
|
||||
ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error)
|
||||
}
|
||||
|
||||
type NotificationRecordStorage interface {
|
||||
CreateRecord(rec *happydns.NotificationRecord) error
|
||||
ListRecordsByUser(userId happydns.Identifier, limit int) ([]*happydns.NotificationRecord, error)
|
||||
DeleteRecordsOlderThan(before time.Time) error
|
||||
}
|
||||
|
||||
type UserGetter interface {
|
||||
GetUser(id happydns.Identifier) (*happydns.User, error)
|
||||
}
|
||||
|
||||
type DomainGetter interface {
|
||||
GetDomain(id happydns.Identifier) (*happydns.Domain, error)
|
||||
}
|
||||
52
internal/usecase/notification/tester.go
Normal file
52
internal/usecase/notification/tester.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
notifPkg "git.happydns.org/happyDomain/internal/notification"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Synchronous and bypasses preferences/state/quiet-hours — user explicitly verifying one channel.
|
||||
type Tester struct {
|
||||
registry *notifPkg.Registry
|
||||
}
|
||||
|
||||
func NewTester(registry *notifPkg.Registry) *Tester {
|
||||
return &Tester{registry: registry}
|
||||
}
|
||||
|
||||
func (t *Tester) Send(ch *happydns.NotificationChannel, user *happydns.User) error {
|
||||
sender, ok := t.registry.Get(ch.Type)
|
||||
if !ok {
|
||||
return notifPkg.ErrUnknownChannelType
|
||||
}
|
||||
cfg, err := sender.DecodeConfig(ch.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sendTimeout)
|
||||
defer cancel()
|
||||
return sender.SendTest(ctx, cfg, user)
|
||||
}
|
||||
|
|
@ -27,21 +27,24 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
ErrAuthUserNotFound = errors.New("auth user not found")
|
||||
ErrCheckPlanNotFound = errors.New("check plan not found")
|
||||
ErrCheckEvaluationNotFound = errors.New("check evaluation not found")
|
||||
ErrCheckerNotFound = errors.New("checker not found")
|
||||
ErrDomainDoesNotExist = errors.New("domain name doesn't exist")
|
||||
ErrDomainNotFound = errors.New("domain not found")
|
||||
ErrDomainLogNotFound = errors.New("domain log not found")
|
||||
ErrExecutionNotFound = errors.New("execution not found")
|
||||
ErrProviderNotFound = errors.New("provider not found")
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
ErrSnapshotNotFound = errors.New("snapshot not found")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserAlreadyExist = errors.New("user already exists")
|
||||
ErrZoneNotFound = errors.New("zone not found")
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrAuthUserNotFound = errors.New("auth user not found")
|
||||
ErrCheckPlanNotFound = errors.New("check plan not found")
|
||||
ErrCheckEvaluationNotFound = errors.New("check evaluation not found")
|
||||
ErrCheckerNotFound = errors.New("checker not found")
|
||||
ErrDomainDoesNotExist = errors.New("domain name doesn't exist")
|
||||
ErrDomainNotFound = errors.New("domain not found")
|
||||
ErrDomainLogNotFound = errors.New("domain log not found")
|
||||
ErrExecutionNotFound = errors.New("execution not found")
|
||||
ErrNotificationChannelNotFound = errors.New("notification channel not found")
|
||||
ErrNotificationPreferenceNotFound = errors.New("notification preference not found")
|
||||
ErrNotificationStateNotFound = errors.New("notification state not found")
|
||||
ErrProviderNotFound = errors.New("provider not found")
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
ErrSnapshotNotFound = errors.New("snapshot not found")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserAlreadyExist = errors.New("user already exists")
|
||||
ErrZoneNotFound = errors.New("zone not found")
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."
|
||||
|
|
|
|||
123
model/notification.go
Normal file
123
model/notification.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package happydns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Values are owned by sender implementations; the model does not enumerate them.
|
||||
type NotificationChannelType string
|
||||
|
||||
// Config is opaque to the model: decoded by the sender registered for Type.
|
||||
type NotificationChannel struct {
|
||||
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
|
||||
UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"`
|
||||
Type NotificationChannelType `json:"type" binding:"required"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Config json.RawMessage `json:"config" swaggertype:"object"`
|
||||
}
|
||||
|
||||
// Scope resolution: ServiceId set > DomainId set > global (both nil).
|
||||
type NotificationPreference struct {
|
||||
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
|
||||
UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"`
|
||||
DomainId *Identifier `json:"domainId,omitempty" swaggertype:"string"`
|
||||
ServiceId *Identifier `json:"serviceId,omitempty" swaggertype:"string"`
|
||||
// Empty means all enabled channels.
|
||||
ChannelIds []Identifier `json:"channelIds,omitempty" swaggertype:"array,string"`
|
||||
MinStatus Status `json:"minStatus"`
|
||||
NotifyRecovery bool `json:"notifyRecovery"`
|
||||
// Hours 0-23, interpreted in Timezone (IANA name; empty means UTC).
|
||||
QuietStart *int `json:"quietStart,omitempty"`
|
||||
QuietEnd *int `json:"quietEnd,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Used for deduplication: only state transitions trigger notifications.
|
||||
type NotificationState struct {
|
||||
CheckerID string `json:"checkerId"`
|
||||
Target CheckTarget `json:"target"`
|
||||
UserId Identifier `json:"userId" swaggertype:"string"`
|
||||
LastStatus Status `json:"lastStatus"`
|
||||
LastNotifiedAt time.Time `json:"lastNotifiedAt" format:"date-time"`
|
||||
Acknowledged bool `json:"acknowledged"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty" format:"date-time"`
|
||||
// User email or "api".
|
||||
AcknowledgedBy string `json:"acknowledgedBy,omitempty"`
|
||||
Annotation string `json:"annotation,omitempty"`
|
||||
}
|
||||
|
||||
type NotificationRecord struct {
|
||||
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
|
||||
UserId Identifier `json:"userId" swaggertype:"string"`
|
||||
ChannelType NotificationChannelType `json:"channelType"`
|
||||
ChannelId Identifier `json:"channelId" swaggertype:"string"`
|
||||
CheckerID string `json:"checkerId"`
|
||||
Target CheckTarget `json:"target"`
|
||||
OldStatus Status `json:"oldStatus"`
|
||||
NewStatus Status `json:"newStatus"`
|
||||
SentAt time.Time `json:"sentAt" format:"date-time"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type AcknowledgeRequest struct {
|
||||
Annotation string `json:"annotation,omitempty"`
|
||||
}
|
||||
|
||||
// Called both on explicit user clear and when a transition invalidates the ack.
|
||||
func (s *NotificationState) ClearAcknowledgement() {
|
||||
s.Acknowledged = false
|
||||
s.AcknowledgedAt = nil
|
||||
s.AcknowledgedBy = ""
|
||||
s.Annotation = ""
|
||||
}
|
||||
|
||||
// Implicit fallback when no preference is configured: opt-in at Warn+ on all enabled channels. Returned with zero Id/UserId; not persisted.
|
||||
func DefaultNotificationPreference() *NotificationPreference {
|
||||
return &NotificationPreference{
|
||||
MinStatus: StatusWarn,
|
||||
NotifyRecovery: false,
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Returns 2 service / 1 domain / 0 global / -1 no-match.
|
||||
func (p *NotificationPreference) MatchesTarget(target CheckTarget) int {
|
||||
if p.ServiceId != nil {
|
||||
if p.ServiceId.String() == target.ServiceId {
|
||||
return 2
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if p.DomainId != nil {
|
||||
if p.DomainId.String() == target.DomainId {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
@ -53,6 +53,10 @@ var entityMap = map[string]string{
|
|||
"DomainStorage": "domain",
|
||||
"DomainLogStorage": "domain_log",
|
||||
"InsightStorage": "insight",
|
||||
"NotificationChannelStorage": "notification_channel",
|
||||
"NotificationPreferenceStorage": "notification_preference",
|
||||
"NotificationStateStorage": "notification_state",
|
||||
"NotificationRecordStorage": "notification_record",
|
||||
"ProviderStorage": "provider",
|
||||
"SessionStorage": "session",
|
||||
"UserStorage": "user",
|
||||
|
|
|
|||
147
web/src/lib/api/notifications.ts
Normal file
147
web/src/lib/api/notifications.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2022-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import {
|
||||
getNotificationsChannelTypes,
|
||||
getNotificationsChannels,
|
||||
postNotificationsChannels,
|
||||
getNotificationsChannelsByChannelId,
|
||||
putNotificationsChannelsByChannelId,
|
||||
deleteNotificationsChannelsByChannelId,
|
||||
postNotificationsChannelsByChannelIdTest,
|
||||
getNotificationsPreferences,
|
||||
postNotificationsPreferences,
|
||||
getNotificationsPreferencesByPrefId,
|
||||
putNotificationsPreferencesByPrefId,
|
||||
deleteNotificationsPreferencesByPrefId,
|
||||
getNotificationsHistory,
|
||||
} from "$lib/api-base/sdk.gen";
|
||||
import type {
|
||||
HappydnsNotificationChannel,
|
||||
HappydnsNotificationChannelWritable,
|
||||
HappydnsNotificationPreference,
|
||||
HappydnsNotificationPreferenceWritable,
|
||||
HappydnsNotificationRecord,
|
||||
} from "$lib/api-base/types.gen";
|
||||
import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors";
|
||||
|
||||
// Extend Writable types with Config — OpenAPI models it as free-form so the generated types omit it.
|
||||
export type NotificationChannelInput = HappydnsNotificationChannelWritable & {
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
export type NotificationChannel = HappydnsNotificationChannel & {
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type NotificationPreferenceInput = HappydnsNotificationPreferenceWritable;
|
||||
export type NotificationPreference = HappydnsNotificationPreference;
|
||||
|
||||
export type NotificationRecord = HappydnsNotificationRecord;
|
||||
|
||||
export async function listChannelTypes(): Promise<string[]> {
|
||||
return unwrapSdkResponse(await getNotificationsChannelTypes()) as string[];
|
||||
}
|
||||
|
||||
export async function listChannels(): Promise<NotificationChannel[]> {
|
||||
return unwrapSdkResponse(await getNotificationsChannels()) as NotificationChannel[];
|
||||
}
|
||||
|
||||
export async function getChannel(id: string): Promise<NotificationChannel> {
|
||||
return unwrapSdkResponse(
|
||||
await getNotificationsChannelsByChannelId({ path: { channelId: id } }),
|
||||
) as NotificationChannel;
|
||||
}
|
||||
|
||||
export async function createChannel(
|
||||
channel: NotificationChannelInput,
|
||||
): Promise<NotificationChannel> {
|
||||
return unwrapSdkResponse(
|
||||
await postNotificationsChannels({
|
||||
body: channel as HappydnsNotificationChannelWritable,
|
||||
}),
|
||||
) as NotificationChannel;
|
||||
}
|
||||
|
||||
export async function updateChannel(
|
||||
id: string,
|
||||
channel: NotificationChannelInput,
|
||||
): Promise<NotificationChannel> {
|
||||
return unwrapSdkResponse(
|
||||
await putNotificationsChannelsByChannelId({
|
||||
path: { channelId: id },
|
||||
body: channel as HappydnsNotificationChannelWritable,
|
||||
}),
|
||||
) as NotificationChannel;
|
||||
}
|
||||
|
||||
export async function deleteChannel(id: string): Promise<boolean> {
|
||||
return unwrapEmptyResponse(
|
||||
await deleteNotificationsChannelsByChannelId({ path: { channelId: id } }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function testChannel(id: string): Promise<boolean> {
|
||||
return unwrapEmptyResponse(
|
||||
await postNotificationsChannelsByChannelIdTest({ path: { channelId: id } }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listPreferences(): Promise<NotificationPreference[]> {
|
||||
return unwrapSdkResponse(await getNotificationsPreferences()) as NotificationPreference[];
|
||||
}
|
||||
|
||||
export async function getPreference(id: string): Promise<NotificationPreference> {
|
||||
return unwrapSdkResponse(
|
||||
await getNotificationsPreferencesByPrefId({ path: { prefId: id } }),
|
||||
) as NotificationPreference;
|
||||
}
|
||||
|
||||
export async function createPreference(
|
||||
pref: NotificationPreferenceInput,
|
||||
): Promise<NotificationPreference> {
|
||||
return unwrapSdkResponse(
|
||||
await postNotificationsPreferences({ body: pref }),
|
||||
) as NotificationPreference;
|
||||
}
|
||||
|
||||
export async function updatePreference(
|
||||
id: string,
|
||||
pref: NotificationPreferenceInput,
|
||||
): Promise<NotificationPreference> {
|
||||
return unwrapSdkResponse(
|
||||
await putNotificationsPreferencesByPrefId({
|
||||
path: { prefId: id },
|
||||
body: pref,
|
||||
}),
|
||||
) as NotificationPreference;
|
||||
}
|
||||
|
||||
export async function deletePreference(id: string): Promise<boolean> {
|
||||
return unwrapEmptyResponse(
|
||||
await deleteNotificationsPreferencesByPrefId({ path: { prefId: id } }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listHistory(limit?: number): Promise<NotificationRecord[]> {
|
||||
return unwrapSdkResponse(
|
||||
await getNotificationsHistory({ query: limit ? { limit } : undefined }),
|
||||
) as NotificationRecord[];
|
||||
}
|
||||
|
|
@ -54,6 +54,7 @@
|
|||
"previous": "Previous",
|
||||
"rename": "Rename",
|
||||
"resolver": "Resolver",
|
||||
"save": "Save",
|
||||
"run": "Run the request!",
|
||||
"survey": "A remark? A comment to share? Don't hesitate to write to us!",
|
||||
"update": "Update",
|
||||
|
|
@ -514,6 +515,117 @@
|
|||
"title": "Preferences",
|
||||
"description": "Customize your preferences"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"subtitle": "Configure how and when happyDomain notifies you about issues",
|
||||
"description": "Manage notification channels, preferences and history",
|
||||
"manage": "Manage notifications",
|
||||
"channels": {
|
||||
"title": "Channels",
|
||||
"description": "Destinations where notifications are sent",
|
||||
"add": "Add channel",
|
||||
"edit": "Edit channel",
|
||||
"delete": "Delete channel",
|
||||
"test": "Send test",
|
||||
"type": "Type",
|
||||
"typeImmutable": "Channel type cannot be changed after creation",
|
||||
"name": "Name",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"unnamed": "(unnamed)",
|
||||
"empty": "No channels configured yet. Add one to start receiving notifications.",
|
||||
"rawJson": "Raw JSON configuration",
|
||||
"rawJsonHelp": "This channel type is not known to the UI. Edit the JSON configuration directly.",
|
||||
"created": "Channel created",
|
||||
"updated": "Channel updated",
|
||||
"deleted": "Channel deleted",
|
||||
"testSent": "Test notification sent",
|
||||
"confirmDelete": "Delete this channel? Existing preferences referencing it will fall back to enabled channels.",
|
||||
"loadError": "Failed to load channels",
|
||||
"typesError": "Failed to load channel types",
|
||||
"saveError": "Failed to save channel",
|
||||
"deleteError": "Failed to delete channel",
|
||||
"testError": "Failed to send test notification",
|
||||
"fields": {
|
||||
"emailAddress": "Email address",
|
||||
"emailAddressHelp": "Leave empty to use your account email.",
|
||||
"webhookUrl": "Webhook URL",
|
||||
"webhookHeaders": "Custom headers",
|
||||
"webhookHeadersHelp": "Extra HTTP headers sent with each webhook request.",
|
||||
"webhookSecret": "Signing secret",
|
||||
"webhookSecretHelp": "If set, requests are signed with HMAC-SHA256.",
|
||||
"unifiedPushEndpoint": "UnifiedPush endpoint",
|
||||
"unifiedPushEndpointHelp": "Endpoint URL provided by your UnifiedPush distributor.",
|
||||
"headerName": "Header",
|
||||
"headerValue": "Value",
|
||||
"addHeader": "Add header"
|
||||
}
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Preferences",
|
||||
"description": "Filter what notifications you receive and where they go",
|
||||
"add": "Add preference",
|
||||
"edit": "Edit preference",
|
||||
"delete": "Delete preference",
|
||||
"empty": "No preferences yet. Add one to choose what you get notified about.",
|
||||
"scope": {
|
||||
"label": "Scope",
|
||||
"global": "All domains",
|
||||
"domain": "Specific domain",
|
||||
"service": "Specific service",
|
||||
"serviceLabel": "{{domain}} / service {{service}}"
|
||||
},
|
||||
"domain": "Domain",
|
||||
"selectDomain": "Select a domain…",
|
||||
"serviceId": "Service ID",
|
||||
"serviceIdHelp": "Identifier of the service inside the selected domain.",
|
||||
"channels": "Channels",
|
||||
"channelsHelp": "Leave all unchecked to use all enabled channels.",
|
||||
"noChannels": "Add at least one channel to assign it to a preference.",
|
||||
"allChannels": "All enabled channels",
|
||||
"minStatus": "Minimum severity",
|
||||
"minStatusHelp": "Only statuses at or above this severity trigger a notification.",
|
||||
"notifyRecovery": "Notify on recovery (back to OK)",
|
||||
"recovery": "Recovery",
|
||||
"quietHours": "Quiet hours",
|
||||
"quietHoursHelp": "Hours given in UTC. Notifications are suppressed during the window.",
|
||||
"quietStart": "Start (UTC hour)",
|
||||
"quietEnd": "End (UTC hour)",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"created": "Preference created",
|
||||
"updated": "Preference updated",
|
||||
"deleted": "Preference deleted",
|
||||
"confirmDelete": "Delete this preference?",
|
||||
"loadError": "Failed to load preferences",
|
||||
"saveError": "Failed to save preference",
|
||||
"deleteError": "Failed to delete preference",
|
||||
"defaults": {
|
||||
"title": "Default settings",
|
||||
"description": "Used when no rule matches the target.",
|
||||
"minStatus": "Notifies on Warning and above",
|
||||
"channels": "Sent to every enabled channel",
|
||||
"noRecovery": "Recovery alerts are not sent",
|
||||
"override": "Add a rule below to override these defaults."
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"title": "History",
|
||||
"description": "Recent notifications sent on your behalf",
|
||||
"empty": "No notifications have been sent yet.",
|
||||
"refresh": "Refresh",
|
||||
"loadMore": "Load more",
|
||||
"sentAt": "Sent at",
|
||||
"channel": "Channel",
|
||||
"checker": "Checker",
|
||||
"target": "Target",
|
||||
"transition": "Status change",
|
||||
"result": "Result",
|
||||
"success": "Sent",
|
||||
"failure": "Failed",
|
||||
"loadError": "Failed to load history"
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"title": "Security & Access",
|
||||
"description": "Manage your authentication and security settings",
|
||||
|
|
|
|||
41
web/src/lib/stores/notificationTypes.ts
Normal file
41
web/src/lib/stores/notificationTypes.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2022-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { listChannelTypes } from "$lib/api/notifications";
|
||||
|
||||
export const notificationChannelTypes: Writable<string[] | undefined> = writable(undefined);
|
||||
|
||||
let pending: Promise<string[]> | undefined;
|
||||
|
||||
export async function refreshNotificationChannelTypes(): Promise<string[]> {
|
||||
if (pending) return pending;
|
||||
pending = (async () => {
|
||||
try {
|
||||
const types = await listChannelTypes();
|
||||
notificationChannelTypes.set(types);
|
||||
return types;
|
||||
} finally {
|
||||
pending = undefined;
|
||||
}
|
||||
})();
|
||||
return pending;
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ interface Params {
|
|||
intervalMin?: string;
|
||||
intervalMax?: string;
|
||||
intervalDefault?: string;
|
||||
service?: string;
|
||||
// add more parameters that are used here
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,17 @@
|
|||
|
||||
<UserSettingsForm bind:settings={$userSession.settings} />
|
||||
|
||||
<h2 id="notifications" class="display-7 fw-bold mt-5">
|
||||
<i class="bi bi-bell"></i>
|
||||
{$t("settings.notifications.title")}
|
||||
</h2>
|
||||
<p class="lead">
|
||||
{$t("settings.notifications.description")}
|
||||
</p>
|
||||
<a class="btn btn-outline-primary" href="/me/notifications">
|
||||
{$t("settings.notifications.manage")}
|
||||
</a>
|
||||
|
||||
{#if $userSession.email !== "_no_auth"}
|
||||
<h2 id="security" class="display-7 fw-bold mt-5">
|
||||
<i class="bi bi-shield"></i>
|
||||
|
|
|
|||
62
web/src/routes/me/notifications/+page.svelte
Normal file
62
web/src/routes/me/notifications/+page.svelte
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { Container, TabContent, TabPane } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import PageTitle from "$lib/components/PageTitle.svelte";
|
||||
import { t } from "$lib/translations";
|
||||
|
||||
import ChannelsManager from "./ChannelsManager.svelte";
|
||||
import HistoryList from "./HistoryList.svelte";
|
||||
import PreferencesManager from "./PreferencesManager.svelte";
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t("settings.notifications.title")} - happyDomain</title>
|
||||
</svelte:head>
|
||||
|
||||
<Container class="flex-fill my-4 pb-5">
|
||||
<PageTitle
|
||||
title={$t("settings.notifications.title")}
|
||||
subtitle={$t("settings.notifications.subtitle")}
|
||||
/>
|
||||
|
||||
<TabContent>
|
||||
<TabPane tabId="channels" tab={$t("settings.notifications.channels.title")} active>
|
||||
<div class="pt-3">
|
||||
<ChannelsManager />
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane tabId="preferences" tab={$t("settings.notifications.preferences.title")}>
|
||||
<div class="pt-3">
|
||||
<PreferencesManager />
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane tabId="history" tab={$t("settings.notifications.history.title")}>
|
||||
<div class="pt-3">
|
||||
<HistoryList />
|
||||
</div>
|
||||
</TabPane>
|
||||
</TabContent>
|
||||
</Container>
|
||||
375
web/src/routes/me/notifications/ChannelEditor.svelte
Normal file
375
web/src/routes/me/notifications/ChannelEditor.svelte
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Spinner,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { createChannel, updateChannel, type NotificationChannel } from "$lib/api/notifications";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import { t } from "$lib/translations";
|
||||
|
||||
import {
|
||||
getChannelConfigSchema,
|
||||
emptyConfigForSchema,
|
||||
type ChannelConfigField,
|
||||
} from "./channelConfigs";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
channelTypes: string[];
|
||||
channel: NotificationChannel | null;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open, channelTypes, channel, onSave, onClose }: Props = $props();
|
||||
|
||||
let type: string = $state("");
|
||||
let name: string = $state("");
|
||||
let enabled: boolean = $state(true);
|
||||
let config: Record<string, unknown> = $state({});
|
||||
let rawJson: string = $state("{}");
|
||||
let rawJsonError: string | null = $state(null);
|
||||
let saving: boolean = $state(false);
|
||||
let revealedSecrets: Record<string, boolean> = $state({});
|
||||
|
||||
// Reset only on open transition; other props read via untrack so type loading doesn't retrigger.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
untrack(() => {
|
||||
const c = channel;
|
||||
const types = channelTypes;
|
||||
if (c) {
|
||||
type = c.type ?? "";
|
||||
name = c.name ?? "";
|
||||
enabled = c.enabled ?? true;
|
||||
config = (c.config ?? {}) as Record<string, unknown>;
|
||||
} else {
|
||||
type = types[0] ?? "";
|
||||
name = "";
|
||||
enabled = true;
|
||||
const schema = getChannelConfigSchema(type);
|
||||
config = schema ? emptyConfigForSchema(schema) : {};
|
||||
}
|
||||
rawJson = JSON.stringify(config, null, 2);
|
||||
rawJsonError = null;
|
||||
revealedSecrets = {};
|
||||
});
|
||||
});
|
||||
|
||||
function onTypeChange() {
|
||||
// Reset to schema defaults so we never send fields that don't apply.
|
||||
const schema = getChannelConfigSchema(type);
|
||||
config = schema ? emptyConfigForSchema(schema) : {};
|
||||
rawJson = JSON.stringify(config, null, 2);
|
||||
rawJsonError = null;
|
||||
}
|
||||
|
||||
let schema = $derived(getChannelConfigSchema(type));
|
||||
|
||||
function setHeaderKey(field: ChannelConfigField, oldKey: string, newKey: string) {
|
||||
const cur = (config[field.key] as Record<string, string>) ?? {};
|
||||
const next: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(cur)) {
|
||||
next[k === oldKey ? newKey : k] = v;
|
||||
}
|
||||
config = { ...config, [field.key]: next };
|
||||
}
|
||||
|
||||
function setHeaderValue(field: ChannelConfigField, key: string, value: string) {
|
||||
const cur = (config[field.key] as Record<string, string>) ?? {};
|
||||
config = { ...config, [field.key]: { ...cur, [key]: value } };
|
||||
}
|
||||
|
||||
function addHeader(field: ChannelConfigField) {
|
||||
const cur = (config[field.key] as Record<string, string>) ?? {};
|
||||
if ("" in cur) return;
|
||||
config = { ...config, [field.key]: { ...cur, "": "" } };
|
||||
}
|
||||
|
||||
function removeHeader(field: ChannelConfigField, key: string) {
|
||||
const cur = { ...((config[field.key] as Record<string, string>) ?? {}) };
|
||||
delete cur[key];
|
||||
config = { ...config, [field.key]: cur };
|
||||
}
|
||||
|
||||
async function save() {
|
||||
let payloadConfig: Record<string, unknown>;
|
||||
|
||||
if (schema) {
|
||||
payloadConfig = config;
|
||||
} else {
|
||||
try {
|
||||
payloadConfig = JSON.parse(rawJson);
|
||||
rawJsonError = null;
|
||||
} catch (e) {
|
||||
rawJsonError = (e as Error).message;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
if (channel?.id) {
|
||||
await updateChannel(channel.id, {
|
||||
type,
|
||||
name,
|
||||
enabled,
|
||||
config: payloadConfig,
|
||||
});
|
||||
} else {
|
||||
await createChannel({
|
||||
type,
|
||||
name,
|
||||
enabled,
|
||||
config: payloadConfig,
|
||||
});
|
||||
}
|
||||
toasts.addToast({
|
||||
title: $t(
|
||||
channel?.id
|
||||
? "settings.notifications.channels.updated"
|
||||
: "settings.notifications.channels.created",
|
||||
),
|
||||
timeout: 4000,
|
||||
type: "success",
|
||||
});
|
||||
onSave();
|
||||
} catch (e) {
|
||||
toasts.addErrorToast({
|
||||
title: $t("settings.notifications.channels.saveError"),
|
||||
message: String(e),
|
||||
timeout: 8000,
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal isOpen={open} toggle={onClose} size="lg">
|
||||
<ModalHeader toggle={onClose}>
|
||||
{channel?.id
|
||||
? $t("settings.notifications.channels.edit")
|
||||
: $t("settings.notifications.channels.add")}
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<form id="channel-editor-form" onsubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
<div class="mb-3">
|
||||
<label for="channel-type" class="form-label">
|
||||
{$t("settings.notifications.channels.type")}
|
||||
</label>
|
||||
<Input
|
||||
id="channel-type"
|
||||
type="select"
|
||||
bind:value={type}
|
||||
on:change={onTypeChange}
|
||||
disabled={!!channel?.id}
|
||||
>
|
||||
{#each channelTypes as t (t)}
|
||||
<option value={t}>{t}</option>
|
||||
{/each}
|
||||
</Input>
|
||||
{#if channel?.id}
|
||||
<small class="form-text text-muted">
|
||||
{$t("settings.notifications.channels.typeImmutable")}
|
||||
</small>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="channel-name" class="form-label">
|
||||
{$t("settings.notifications.channels.name")}
|
||||
</label>
|
||||
<Input id="channel-name" type="text" bind:value={name} />
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
id="channel-enabled"
|
||||
bind:checked={enabled}
|
||||
/>
|
||||
<label class="form-check-label" for="channel-enabled">
|
||||
{$t("settings.notifications.channels.enabled")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
{#if schema}
|
||||
{#each schema.fields as field (field.key)}
|
||||
<div class="mb-3">
|
||||
<label for={`field-${field.key}`} class="form-label">
|
||||
{$t(field.i18nLabel)}
|
||||
{#if field.required}<span class="text-danger">*</span>{/if}
|
||||
</label>
|
||||
|
||||
{#if field.kind === "text" || field.kind === "url"}
|
||||
<Input
|
||||
id={`field-${field.key}`}
|
||||
type={field.kind === "url" ? "url" : "text"}
|
||||
value={(config[field.key] as string) ?? ""}
|
||||
on:input={(e) => {
|
||||
config = {
|
||||
...config,
|
||||
[field.key]: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
}}
|
||||
required={field.required}
|
||||
/>
|
||||
{:else if field.kind === "secret"}
|
||||
<div class="input-group">
|
||||
<Input
|
||||
id={`field-${field.key}`}
|
||||
type={revealedSecrets[field.key] ? "text" : "password"}
|
||||
value={(config[field.key] as string) ?? ""}
|
||||
on:input={(e) => {
|
||||
config = {
|
||||
...config,
|
||||
[field.key]: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
outline
|
||||
on:click={() =>
|
||||
(revealedSecrets = {
|
||||
...revealedSecrets,
|
||||
[field.key]: !revealedSecrets[field.key],
|
||||
})}
|
||||
>
|
||||
<i
|
||||
class={`bi bi-eye${revealedSecrets[field.key] ? "-slash" : ""}`}
|
||||
></i>
|
||||
</Button>
|
||||
</div>
|
||||
{:else if field.kind === "headers"}
|
||||
{@const headers = (config[field.key] as Record<string, string>) ?? {}}
|
||||
{#each Object.entries(headers) as [k, v] (k)}
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={$t(
|
||||
"settings.notifications.channels.fields.headerName",
|
||||
)}
|
||||
value={k}
|
||||
on:change={(e) =>
|
||||
setHeaderKey(
|
||||
field,
|
||||
k,
|
||||
(e.target as HTMLInputElement).value,
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={$t(
|
||||
"settings.notifications.channels.fields.headerValue",
|
||||
)}
|
||||
value={v}
|
||||
on:input={(e) =>
|
||||
setHeaderValue(
|
||||
field,
|
||||
k,
|
||||
(e.target as HTMLInputElement).value,
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
color="danger"
|
||||
outline
|
||||
on:click={() => removeHeader(field, k)}
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
outline
|
||||
on:click={() => addHeader(field)}
|
||||
>
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
{$t("settings.notifications.channels.fields.addHeader")}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if field.i18nHelp}
|
||||
<small class="form-text text-muted d-block">
|
||||
{$t(field.i18nHelp)}
|
||||
</small>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else if type}
|
||||
<div class="mb-2">
|
||||
<label for="raw-json" class="form-label">
|
||||
{$t("settings.notifications.channels.rawJson")}
|
||||
</label>
|
||||
<textarea
|
||||
id="raw-json"
|
||||
class="form-control font-monospace"
|
||||
rows="8"
|
||||
bind:value={rawJson}
|
||||
></textarea>
|
||||
{#if rawJsonError}
|
||||
<div class="text-danger small">{rawJsonError}</div>
|
||||
{/if}
|
||||
<small class="form-text text-muted">
|
||||
{$t("settings.notifications.channels.rawJsonHelp")}
|
||||
</small>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button type="button" color="secondary" outline on:click={onClose} disabled={saving}>
|
||||
{$t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="channel-editor-form"
|
||||
color="primary"
|
||||
disabled={saving || !type}
|
||||
>
|
||||
{#if saving}<Spinner size="sm" class="me-2" />{/if}
|
||||
{$t("common.save")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
223
web/src/routes/me/notifications/ChannelsManager.svelte
Normal file
223
web/src/routes/me/notifications/ChannelsManager.svelte
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
import { Badge, Button, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import {
|
||||
deleteChannel,
|
||||
listChannels,
|
||||
testChannel,
|
||||
type NotificationChannel,
|
||||
} from "$lib/api/notifications";
|
||||
import {
|
||||
notificationChannelTypes,
|
||||
refreshNotificationChannelTypes,
|
||||
} from "$lib/stores/notificationTypes";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import { t } from "$lib/translations";
|
||||
|
||||
import ChannelEditor from "./ChannelEditor.svelte";
|
||||
|
||||
let channels: NotificationChannel[] = $state([]);
|
||||
let loading: boolean = $state(true);
|
||||
let editorOpen: boolean = $state(false);
|
||||
let editing: NotificationChannel | null = $state(null);
|
||||
let busy: Record<string, "test" | "delete"> = $state({});
|
||||
|
||||
async function refresh() {
|
||||
loading = true;
|
||||
try {
|
||||
channels = await listChannels();
|
||||
} catch (e) {
|
||||
toasts.addErrorToast({
|
||||
title: $t("settings.notifications.channels.loadError"),
|
||||
message: String(e),
|
||||
timeout: 8000,
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refresh();
|
||||
refreshNotificationChannelTypes().catch((e) =>
|
||||
toasts.addErrorToast({
|
||||
title: $t("settings.notifications.channels.typesError"),
|
||||
message: String(e),
|
||||
timeout: 8000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
editing = null;
|
||||
editorOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(channel: NotificationChannel) {
|
||||
editing = channel;
|
||||
editorOpen = true;
|
||||
}
|
||||
|
||||
async function onTest(channel: NotificationChannel) {
|
||||
if (!channel.id) return;
|
||||
busy = { ...busy, [channel.id]: "test" };
|
||||
try {
|
||||
await testChannel(channel.id);
|
||||
toasts.addToast({
|
||||
title: $t("settings.notifications.channels.testSent"),
|
||||
timeout: 5000,
|
||||
type: "success",
|
||||
});
|
||||
} catch (e) {
|
||||
toasts.addErrorToast({
|
||||
title: $t("settings.notifications.channels.testError"),
|
||||
message: String(e),
|
||||
timeout: 8000,
|
||||
});
|
||||
} finally {
|
||||
const next = { ...busy };
|
||||
delete next[channel.id];
|
||||
busy = next;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(channel: NotificationChannel) {
|
||||
if (!channel.id) return;
|
||||
if (!window.confirm($t("settings.notifications.channels.confirmDelete"))) return;
|
||||
busy = { ...busy, [channel.id]: "delete" };
|
||||
try {
|
||||
await deleteChannel(channel.id);
|
||||
channels = channels.filter((c) => c.id !== channel.id);
|
||||
toasts.addToast({
|
||||
title: $t("settings.notifications.channels.deleted"),
|
||||
timeout: 4000,
|
||||
type: "success",
|
||||
});
|
||||
} catch (e) {
|
||||
toasts.addErrorToast({
|
||||
title: $t("settings.notifications.channels.deleteError"),
|
||||
message: String(e),
|
||||
timeout: 8000,
|
||||
});
|
||||
} finally {
|
||||
const next = { ...busy };
|
||||
delete next[channel.id];
|
||||
busy = next;
|
||||
}
|
||||
}
|
||||
|
||||
function onSaved() {
|
||||
editorOpen = false;
|
||||
refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<p class="mb-0 text-muted">
|
||||
{$t("settings.notifications.channels.description")}
|
||||
</p>
|
||||
<Button color="primary" size="sm" on:click={openCreate} disabled={!$notificationChannelTypes}>
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
{$t("settings.notifications.channels.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="d-flex justify-content-center py-3">
|
||||
<Spinner color="primary" />
|
||||
</div>
|
||||
{:else if channels.length === 0}
|
||||
<div class="alert alert-secondary">
|
||||
{$t("settings.notifications.channels.empty")}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="list-group">
|
||||
{#each channels as channel (channel.id)}
|
||||
<li class="list-group-item d-flex flex-column flex-md-row gap-2 align-items-md-center">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<strong>{channel.name || $t("settings.notifications.channels.unnamed")}</strong>
|
||||
<Badge color="info">{channel.type}</Badge>
|
||||
{#if !channel.enabled}
|
||||
<Badge color="secondary">
|
||||
{$t("settings.notifications.channels.disabled")}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
outline
|
||||
on:click={() => onTest(channel)}
|
||||
disabled={busy[channel.id ?? ""] === "test" || !channel.enabled}
|
||||
title={$t("settings.notifications.channels.test")}
|
||||
>
|
||||
{#if busy[channel.id ?? ""] === "test"}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
<i class="bi bi-send"></i>
|
||||
{/if}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
outline
|
||||
on:click={() => openEdit(channel)}
|
||||
title={$t("settings.notifications.channels.edit")}
|
||||
>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
outline
|
||||
on:click={() => onDelete(channel)}
|
||||
disabled={busy[channel.id ?? ""] === "delete"}
|
||||
title={$t("settings.notifications.channels.delete")}
|
||||
>
|
||||
{#if busy[channel.id ?? ""] === "delete"}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
<i class="bi bi-trash"></i>
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<ChannelEditor
|
||||
open={editorOpen}
|
||||
channelTypes={$notificationChannelTypes ?? []}
|
||||
channel={editing}
|
||||
onSave={onSaved}
|
||||
onClose={() => (editorOpen = false)}
|
||||
/>
|
||||
176
web/src/routes/me/notifications/HistoryList.svelte
Normal file
176
web/src/routes/me/notifications/HistoryList.svelte
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
import { Badge, Button, Spinner, Table } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { listHistory, type NotificationRecord } from "$lib/api/notifications";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import { t } from "$lib/translations";
|
||||
import {
|
||||
getStatusColor,
|
||||
getStatusI18nKey,
|
||||
} from "$lib/utils/checkers";
|
||||
|
||||
let records: NotificationRecord[] = $state([]);
|
||||
let loading: boolean = $state(true);
|
||||
let limit: number = $state(50);
|
||||
let loadingMore: boolean = $state(false);
|
||||
|
||||
async function refresh(newLimit?: number) {
|
||||
const lim = newLimit ?? limit;
|
||||
const isFirst = !newLimit;
|
||||
if (isFirst) loading = true;
|
||||
else loadingMore = true;
|
||||
try {
|
||||
records = await listHistory(lim);
|
||||
limit = lim;
|
||||
} catch (e) {
|
||||
toasts.addErrorToast({
|
||||
title: $t("settings.notifications.history.loadError"),
|
||||
message: String(e),
|
||||
timeout: 8000,
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
function formatTarget(t: NotificationRecord["target"]): string {
|
||||
if (!t) return "—";
|
||||
const parts: string[] = [];
|
||||
if (t.domainId) parts.push(`d:${t.domainId.slice(0, 8)}`);
|
||||
if (t.serviceId) parts.push(`s:${t.serviceId.slice(0, 8)}`);
|
||||
if (t.serviceType) parts.push(t.serviceType);
|
||||
return parts.join(" / ") || "—";
|
||||
}
|
||||
|
||||
function formatDate(d: Date | string | undefined): string {
|
||||
if (!d) return "—";
|
||||
try {
|
||||
return d instanceof Date ? d.toLocaleString() : new Date(d).toLocaleString();
|
||||
} catch {
|
||||
return String(d);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<p class="mb-0 text-muted">
|
||||
{$t("settings.notifications.history.description")}
|
||||
</p>
|
||||
<Button
|
||||
color="secondary"
|
||||
outline
|
||||
size="sm"
|
||||
on:click={() => refresh()}
|
||||
disabled={loading || loadingMore}
|
||||
title={$t("settings.notifications.history.refresh")}
|
||||
>
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="d-flex justify-content-center py-3">
|
||||
<Spinner color="primary" />
|
||||
</div>
|
||||
{:else if records.length === 0}
|
||||
<div class="alert alert-secondary">
|
||||
{$t("settings.notifications.history.empty")}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-responsive">
|
||||
<Table size="sm" hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t("settings.notifications.history.sentAt")}</th>
|
||||
<th>{$t("settings.notifications.history.channel")}</th>
|
||||
<th>{$t("settings.notifications.history.checker")}</th>
|
||||
<th>{$t("settings.notifications.history.target")}</th>
|
||||
<th>{$t("settings.notifications.history.transition")}</th>
|
||||
<th>{$t("settings.notifications.history.result")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each records as r (r.id)}
|
||||
<tr>
|
||||
<td>{formatDate(r.sentAt)}</td>
|
||||
<td>
|
||||
<Badge color="info">{r.channelType}</Badge>
|
||||
</td>
|
||||
<td><code>{r.checkerId}</code></td>
|
||||
<td><small>{formatTarget(r.target)}</small></td>
|
||||
<td>
|
||||
<Badge color={getStatusColor(r.oldStatus)}>
|
||||
{$t(getStatusI18nKey(r.oldStatus))}
|
||||
</Badge>
|
||||
→
|
||||
<Badge color={getStatusColor(r.newStatus)}>
|
||||
{$t(getStatusI18nKey(r.newStatus))}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
{#if r.success}
|
||||
<Badge color="success">
|
||||
<i class="bi bi-check-lg"></i>
|
||||
{$t("settings.notifications.history.success")}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge color="danger" title={r.error}>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
{$t("settings.notifications.history.failure")}
|
||||
</Badge>
|
||||
{#if r.error}
|
||||
<div class="small text-muted">{r.error}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{#if records.length >= limit}
|
||||
<div class="d-flex justify-content-center">
|
||||
<Button
|
||||
color="secondary"
|
||||
outline
|
||||
size="sm"
|
||||
on:click={() => refresh(limit + 50)}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{#if loadingMore}<Spinner size="sm" class="me-2" />{/if}
|
||||
{$t("settings.notifications.history.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
401
web/src/routes/me/notifications/PreferenceEditor.svelte
Normal file
401
web/src/routes/me/notifications/PreferenceEditor.svelte
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Spinner,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import {
|
||||
createPreference,
|
||||
updatePreference,
|
||||
type NotificationChannel,
|
||||
type NotificationPreference,
|
||||
} from "$lib/api/notifications";
|
||||
import type { HappydnsDomainWithCheckStatus, HappydnsStatus } from "$lib/api-base/types.gen";
|
||||
import { domains } from "$lib/stores/domains";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import { t } from "$lib/translations";
|
||||
import {
|
||||
StatusOK,
|
||||
StatusInfo,
|
||||
StatusWarn,
|
||||
StatusCrit,
|
||||
StatusError,
|
||||
} from "$lib/utils/checkers";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
preference: NotificationPreference | null;
|
||||
channels: NotificationChannel[];
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open, preference, channels, onSave, onClose }: Props = $props();
|
||||
|
||||
type Scope = "global" | "domain" | "service";
|
||||
|
||||
let scope: Scope = $state("global");
|
||||
let domainId: string = $state("");
|
||||
let serviceId: string = $state("");
|
||||
let channelIds: string[] = $state([]);
|
||||
let minStatus: HappydnsStatus = $state(StatusWarn);
|
||||
let notifyRecovery: boolean = $state(true);
|
||||
let enabled: boolean = $state(true);
|
||||
let quietHoursEnabled: boolean = $state(false);
|
||||
let quietStart: number = $state(22);
|
||||
let quietEnd: number = $state(7);
|
||||
let saving: boolean = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
untrack(() => {
|
||||
const p = preference;
|
||||
if (p) {
|
||||
if (p.serviceId) {
|
||||
scope = "service";
|
||||
serviceId = p.serviceId;
|
||||
domainId = p.domainId ?? "";
|
||||
} else if (p.domainId) {
|
||||
scope = "domain";
|
||||
domainId = p.domainId;
|
||||
serviceId = "";
|
||||
} else {
|
||||
scope = "global";
|
||||
domainId = "";
|
||||
serviceId = "";
|
||||
}
|
||||
channelIds = p.channelIds ? [...p.channelIds] : [];
|
||||
minStatus = p.minStatus ?? StatusWarn;
|
||||
notifyRecovery = p.notifyRecovery ?? true;
|
||||
enabled = p.enabled ?? true;
|
||||
if (
|
||||
p.quietStart !== undefined &&
|
||||
p.quietEnd !== undefined &&
|
||||
p.quietStart !== null &&
|
||||
p.quietEnd !== null
|
||||
) {
|
||||
quietHoursEnabled = true;
|
||||
quietStart = p.quietStart;
|
||||
quietEnd = p.quietEnd;
|
||||
} else {
|
||||
quietHoursEnabled = false;
|
||||
quietStart = 22;
|
||||
quietEnd = 7;
|
||||
}
|
||||
} else {
|
||||
scope = "global";
|
||||
domainId = "";
|
||||
serviceId = "";
|
||||
channelIds = [];
|
||||
minStatus = StatusWarn;
|
||||
notifyRecovery = true;
|
||||
enabled = true;
|
||||
quietHoursEnabled = false;
|
||||
quietStart = 22;
|
||||
quietEnd = 7;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function toggleChannel(id: string) {
|
||||
channelIds = channelIds.includes(id)
|
||||
? channelIds.filter((c) => c !== id)
|
||||
: [...channelIds, id];
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const payload: Parameters<typeof createPreference>[0] = {
|
||||
channelIds: channelIds.length ? channelIds : undefined,
|
||||
minStatus,
|
||||
notifyRecovery,
|
||||
enabled,
|
||||
};
|
||||
if (scope === "domain") {
|
||||
if (!domainId) return;
|
||||
payload.domainId = domainId;
|
||||
} else if (scope === "service") {
|
||||
if (!serviceId) return;
|
||||
payload.serviceId = serviceId;
|
||||
if (domainId) payload.domainId = domainId;
|
||||
}
|
||||
if (quietHoursEnabled) {
|
||||
payload.quietStart = quietStart;
|
||||
payload.quietEnd = quietEnd;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
if (preference?.id) {
|
||||
await updatePreference(preference.id, payload);
|
||||
} else {
|
||||
await createPreference(payload);
|
||||
}
|
||||
toasts.addToast({
|
||||
title: $t(
|
||||
preference?.id
|
||||
? "settings.notifications.preferences.updated"
|
||||
: "settings.notifications.preferences.created",
|
||||
),
|
||||
timeout: 4000,
|
||||
type: "success",
|
||||
});
|
||||
onSave();
|
||||
} catch (e) {
|
||||
toasts.addErrorToast({
|
||||
title: $t("settings.notifications.preferences.saveError"),
|
||||
message: String(e),
|
||||
timeout: 8000,
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
let domainList: HappydnsDomainWithCheckStatus[] = $derived($domains ?? []);
|
||||
</script>
|
||||
|
||||
<Modal isOpen={open} toggle={onClose} size="lg">
|
||||
<ModalHeader toggle={onClose}>
|
||||
{preference?.id
|
||||
? $t("settings.notifications.preferences.edit")
|
||||
: $t("settings.notifications.preferences.add")}
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<form id="preference-editor-form" onsubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
<fieldset class="mb-3">
|
||||
<legend class="form-label">
|
||||
{$t("settings.notifications.preferences.scope.label")}
|
||||
</legend>
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
id="scope-global"
|
||||
name="scope"
|
||||
value="global"
|
||||
bind:group={scope}
|
||||
/>
|
||||
<label class="form-check-label" for="scope-global">
|
||||
{$t("settings.notifications.preferences.scope.global")}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
id="scope-domain"
|
||||
name="scope"
|
||||
value="domain"
|
||||
bind:group={scope}
|
||||
/>
|
||||
<label class="form-check-label" for="scope-domain">
|
||||
{$t("settings.notifications.preferences.scope.domain")}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
id="scope-service"
|
||||
name="scope"
|
||||
value="service"
|
||||
bind:group={scope}
|
||||
/>
|
||||
<label class="form-check-label" for="scope-service">
|
||||
{$t("settings.notifications.preferences.scope.service")}
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#if scope === "domain" || scope === "service"}
|
||||
<div class="mb-3">
|
||||
<label for="pref-domain" class="form-label">
|
||||
{$t("settings.notifications.preferences.domain")}
|
||||
</label>
|
||||
<Input id="pref-domain" type="select" bind:value={domainId}>
|
||||
<option value="">{$t("settings.notifications.preferences.selectDomain")}</option>
|
||||
{#each domainList as d (d.id)}
|
||||
<option value={d.id}>{d.domain}</option>
|
||||
{/each}
|
||||
</Input>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if scope === "service"}
|
||||
<div class="mb-3">
|
||||
<label for="pref-service" class="form-label">
|
||||
{$t("settings.notifications.preferences.serviceId")}
|
||||
</label>
|
||||
<Input id="pref-service" type="text" bind:value={serviceId} />
|
||||
<small class="form-text text-muted">
|
||||
{$t("settings.notifications.preferences.serviceIdHelp")}
|
||||
</small>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="pref-channels">
|
||||
{$t("settings.notifications.preferences.channels")}
|
||||
</label>
|
||||
{#if channels.length === 0}
|
||||
<div class="alert alert-secondary mb-0 py-2">
|
||||
{$t("settings.notifications.preferences.noChannels")}
|
||||
</div>
|
||||
{:else}
|
||||
<div id="pref-channels">
|
||||
{#each channels as c (c.id)}
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id={`pref-ch-${c.id}`}
|
||||
checked={channelIds.includes(c.id ?? "")}
|
||||
onchange={() => toggleChannel(c.id ?? "")}
|
||||
/>
|
||||
<label class="form-check-label" for={`pref-ch-${c.id}`}>
|
||||
{c.name || c.type}
|
||||
<span class="text-muted">({c.type})</span>
|
||||
</label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<small class="form-text text-muted">
|
||||
{$t("settings.notifications.preferences.channelsHelp")}
|
||||
</small>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="pref-min-status" class="form-label">
|
||||
{$t("settings.notifications.preferences.minStatus")}
|
||||
</label>
|
||||
<Input id="pref-min-status" type="select" bind:value={minStatus}>
|
||||
<option value={StatusOK}>{$t("checkers.status.ok")}</option>
|
||||
<option value={StatusInfo}>{$t("checkers.status.info")}</option>
|
||||
<option value={StatusWarn}>{$t("checkers.status.warning")}</option>
|
||||
<option value={StatusCrit}>{$t("checkers.status.critical")}</option>
|
||||
<option value={StatusError}>{$t("checkers.status.error")}</option>
|
||||
</Input>
|
||||
<small class="form-text text-muted">
|
||||
{$t("settings.notifications.preferences.minStatusHelp")}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
id="pref-recovery"
|
||||
bind:checked={notifyRecovery}
|
||||
/>
|
||||
<label class="form-check-label" for="pref-recovery">
|
||||
{$t("settings.notifications.preferences.notifyRecovery")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
id="pref-quiet"
|
||||
bind:checked={quietHoursEnabled}
|
||||
/>
|
||||
<label class="form-check-label" for="pref-quiet">
|
||||
{$t("settings.notifications.preferences.quietHours")}
|
||||
</label>
|
||||
</div>
|
||||
{#if quietHoursEnabled}
|
||||
<div class="d-flex gap-2 mb-3 align-items-end">
|
||||
<div>
|
||||
<label for="pref-quiet-start" class="form-label">
|
||||
{$t("settings.notifications.preferences.quietStart")}
|
||||
</label>
|
||||
<Input
|
||||
id="pref-quiet-start"
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
bind:value={quietStart}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="pref-quiet-end" class="form-label">
|
||||
{$t("settings.notifications.preferences.quietEnd")}
|
||||
</label>
|
||||
<Input
|
||||
id="pref-quiet-end"
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
bind:value={quietEnd}
|
||||
/>
|
||||
</div>
|
||||
<small class="form-text text-muted">
|
||||
{$t("settings.notifications.preferences.quietHoursHelp")}
|
||||
</small>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
id="pref-enabled"
|
||||
bind:checked={enabled}
|
||||
/>
|
||||
<label class="form-check-label" for="pref-enabled">
|
||||
{$t("settings.notifications.preferences.enabled")}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button type="button" color="secondary" outline on:click={onClose} disabled={saving}>
|
||||
{$t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="preference-editor-form"
|
||||
color="primary"
|
||||
disabled={saving ||
|
||||
(scope === "domain" && !domainId) ||
|
||||
(scope === "service" && !serviceId)}
|
||||
>
|
||||
{#if saving}<Spinner size="sm" class="me-2" />{/if}
|
||||
{$t("common.save")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
258
web/src/routes/me/notifications/PreferencesManager.svelte
Normal file
258
web/src/routes/me/notifications/PreferencesManager.svelte
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
import { Badge, Button, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import {
|
||||
deletePreference,
|
||||
listChannels,
|
||||
listPreferences,
|
||||
type NotificationChannel,
|
||||
type NotificationPreference,
|
||||
} from "$lib/api/notifications";
|
||||
import { domains, refreshDomains } from "$lib/stores/domains";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import { t } from "$lib/translations";
|
||||
import { getStatusColor, getStatusI18nKey } from "$lib/utils/checkers";
|
||||
|
||||
import PreferenceEditor from "./PreferenceEditor.svelte";
|
||||
|
||||
let preferences: NotificationPreference[] = $state([]);
|
||||
let channels: NotificationChannel[] = $state([]);
|
||||
let loading: boolean = $state(true);
|
||||
let editorOpen: boolean = $state(false);
|
||||
let editing: NotificationPreference | null = $state(null);
|
||||
let deleting: Record<string, boolean> = $state({});
|
||||
|
||||
async function refresh() {
|
||||
loading = true;
|
||||
try {
|
||||
const [p, c] = await Promise.all([listPreferences(), listChannels()]);
|
||||
preferences = p;
|
||||
channels = c;
|
||||
} catch (e) {
|
||||
toasts.addErrorToast({
|
||||
title: $t("settings.notifications.preferences.loadError"),
|
||||
message: String(e),
|
||||
timeout: 8000,
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refresh();
|
||||
if (!$domains) refreshDomains().catch(() => {});
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
editing = null;
|
||||
editorOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(pref: NotificationPreference) {
|
||||
editing = pref;
|
||||
editorOpen = true;
|
||||
}
|
||||
|
||||
async function onDelete(pref: NotificationPreference) {
|
||||
if (!pref.id) return;
|
||||
if (!window.confirm($t("settings.notifications.preferences.confirmDelete"))) return;
|
||||
deleting = { ...deleting, [pref.id]: true };
|
||||
try {
|
||||
await deletePreference(pref.id);
|
||||
preferences = preferences.filter((p) => p.id !== pref.id);
|
||||
toasts.addToast({
|
||||
title: $t("settings.notifications.preferences.deleted"),
|
||||
timeout: 4000,
|
||||
type: "success",
|
||||
});
|
||||
} catch (e) {
|
||||
toasts.addErrorToast({
|
||||
title: $t("settings.notifications.preferences.deleteError"),
|
||||
message: String(e),
|
||||
timeout: 8000,
|
||||
});
|
||||
} finally {
|
||||
const next = { ...deleting };
|
||||
delete next[pref.id];
|
||||
deleting = next;
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(p: NotificationPreference): string {
|
||||
if (p.serviceId) {
|
||||
const dom = $domains?.find((d) => d.id === p.domainId);
|
||||
const domLabel = dom ? dom.domain : p.domainId ?? "?";
|
||||
return $t("settings.notifications.preferences.scope.serviceLabel", {
|
||||
domain: domLabel,
|
||||
service: p.serviceId,
|
||||
});
|
||||
}
|
||||
if (p.domainId) {
|
||||
const dom = $domains?.find((d) => d.id === p.domainId);
|
||||
return dom ? dom.domain : p.domainId;
|
||||
}
|
||||
return $t("settings.notifications.preferences.scope.global");
|
||||
}
|
||||
|
||||
function channelsLabel(p: NotificationPreference): string {
|
||||
if (!p.channelIds || p.channelIds.length === 0) {
|
||||
return $t("settings.notifications.preferences.allChannels");
|
||||
}
|
||||
return p.channelIds
|
||||
.map((id) => channels.find((c) => c.id === id)?.name || id)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function onSaved() {
|
||||
editorOpen = false;
|
||||
refresh();
|
||||
}
|
||||
|
||||
let hasGlobalPreference = $derived(
|
||||
preferences.some((p) => !p.domainId && !p.serviceId && p.enabled),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<p class="mb-0 text-muted">
|
||||
{$t("settings.notifications.preferences.description")}
|
||||
</p>
|
||||
<Button color="primary" size="sm" on:click={openCreate}>
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
{$t("settings.notifications.preferences.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="d-flex justify-content-center py-3">
|
||||
<Spinner color="primary" />
|
||||
</div>
|
||||
{:else}
|
||||
{#if !hasGlobalPreference}
|
||||
<div class="card mb-3 border-info">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<i class="bi bi-info-circle text-info"></i>
|
||||
<strong>{$t("settings.notifications.preferences.defaults.title")}</strong>
|
||||
</div>
|
||||
<p class="mb-2 text-muted small">
|
||||
{$t("settings.notifications.preferences.defaults.description")}
|
||||
</p>
|
||||
<ul class="list-unstyled mb-1 small">
|
||||
<li>
|
||||
<i class="bi bi-bell text-warning"></i>
|
||||
{$t("settings.notifications.preferences.defaults.minStatus")}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bi bi-broadcast"></i>
|
||||
{$t("settings.notifications.preferences.defaults.channels")}
|
||||
</li>
|
||||
<li>
|
||||
<i class="bi bi-dash-circle text-secondary"></i>
|
||||
{$t("settings.notifications.preferences.defaults.noRecovery")}
|
||||
</li>
|
||||
</ul>
|
||||
<small class="text-muted fst-italic">
|
||||
{$t("settings.notifications.preferences.defaults.override")}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if preferences.length === 0}
|
||||
<div class="alert alert-secondary">
|
||||
{$t("settings.notifications.preferences.empty")}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="list-group">
|
||||
{#each preferences as pref (pref.id)}
|
||||
<li class="list-group-item d-flex flex-column flex-md-row gap-2 align-items-md-center">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<strong>{scopeLabel(pref)}</strong>
|
||||
{#if !pref.enabled}
|
||||
<Badge color="secondary">
|
||||
{$t("settings.notifications.preferences.disabled")}
|
||||
</Badge>
|
||||
{/if}
|
||||
<Badge color={getStatusColor(pref.minStatus)}>
|
||||
≥ {$t(getStatusI18nKey(pref.minStatus))}
|
||||
</Badge>
|
||||
{#if pref.notifyRecovery}
|
||||
<Badge color="success">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
{$t("settings.notifications.preferences.recovery")}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if pref.quietStart !== undefined && pref.quietEnd !== undefined && pref.quietStart !== null && pref.quietEnd !== null}
|
||||
<Badge color="info">
|
||||
<i class="bi bi-moon"></i>
|
||||
{pref.quietStart}h–{pref.quietEnd}h UTC
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<small class="text-muted">{channelsLabel(pref)}</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
outline
|
||||
on:click={() => openEdit(pref)}
|
||||
title={$t("settings.notifications.preferences.edit")}
|
||||
>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
outline
|
||||
on:click={() => onDelete(pref)}
|
||||
disabled={deleting[pref.id ?? ""]}
|
||||
title={$t("settings.notifications.preferences.delete")}
|
||||
>
|
||||
{#if deleting[pref.id ?? ""]}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
<i class="bi bi-trash"></i>
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<PreferenceEditor
|
||||
open={editorOpen}
|
||||
preference={editing}
|
||||
{channels}
|
||||
onSave={onSaved}
|
||||
onClose={() => (editorOpen = false)}
|
||||
/>
|
||||
96
web/src/routes/me/notifications/channelConfigs.ts
Normal file
96
web/src/routes/me/notifications/channelConfigs.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2022-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Keep the set small — anything exotic falls through to the raw JSON editor.
|
||||
export type FieldKind = "text" | "url" | "secret" | "headers";
|
||||
|
||||
export interface ChannelConfigField {
|
||||
key: string;
|
||||
kind: FieldKind;
|
||||
required?: boolean;
|
||||
i18nLabel: string;
|
||||
i18nHelp?: string;
|
||||
}
|
||||
|
||||
export interface ChannelConfigSchema {
|
||||
fields: ChannelConfigField[];
|
||||
}
|
||||
|
||||
// Field names mirror JSON tags in internal/notification/*_sender.go.
|
||||
export const CHANNEL_CONFIG_SCHEMAS: Record<string, ChannelConfigSchema> = {
|
||||
email: {
|
||||
fields: [
|
||||
{
|
||||
key: "emailAddress",
|
||||
kind: "text",
|
||||
i18nLabel: "settings.notifications.channels.fields.emailAddress",
|
||||
i18nHelp: "settings.notifications.channels.fields.emailAddressHelp",
|
||||
},
|
||||
],
|
||||
},
|
||||
webhook: {
|
||||
fields: [
|
||||
{
|
||||
key: "webhookUrl",
|
||||
kind: "url",
|
||||
required: true,
|
||||
i18nLabel: "settings.notifications.channels.fields.webhookUrl",
|
||||
},
|
||||
{
|
||||
key: "webhookHeaders",
|
||||
kind: "headers",
|
||||
i18nLabel: "settings.notifications.channels.fields.webhookHeaders",
|
||||
i18nHelp: "settings.notifications.channels.fields.webhookHeadersHelp",
|
||||
},
|
||||
{
|
||||
key: "webhookSecret",
|
||||
kind: "secret",
|
||||
i18nLabel: "settings.notifications.channels.fields.webhookSecret",
|
||||
i18nHelp: "settings.notifications.channels.fields.webhookSecretHelp",
|
||||
},
|
||||
],
|
||||
},
|
||||
unifiedpush: {
|
||||
fields: [
|
||||
{
|
||||
key: "unifiedPushEndpoint",
|
||||
kind: "url",
|
||||
required: true,
|
||||
i18nLabel: "settings.notifications.channels.fields.unifiedPushEndpoint",
|
||||
i18nHelp: "settings.notifications.channels.fields.unifiedPushEndpointHelp",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function getChannelConfigSchema(type: string | undefined): ChannelConfigSchema | undefined {
|
||||
if (!type) return undefined;
|
||||
return CHANNEL_CONFIG_SCHEMAS[type];
|
||||
}
|
||||
|
||||
export function emptyConfigForSchema(schema: ChannelConfigSchema): Record<string, unknown> {
|
||||
const cfg: Record<string, unknown> = {};
|
||||
for (const f of schema.fields) {
|
||||
if (f.kind === "headers") cfg[f.key] = {};
|
||||
else cfg[f.key] = "";
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue