notification: add API endpoints for channels, preferences, history, and acknowledgement

This commit is contained in:
nemunaire 2026-04-11 08:55:00 +07:00
commit d997cb5ed3
10 changed files with 841 additions and 15 deletions

View file

@ -0,0 +1,514 @@
// 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"
"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"
)
// maxHistoryLimit caps the ?limit= query parameter on ListHistory. The store
// loads all matching records into memory before slicing, so an unbounded limit
// is a trivial DoS vector.
const maxHistoryLimit = 500
// maxAnnotationLength caps the user-supplied annotation persisted with an
// acknowledgement. The string is stored in NotificationState and rendered in
// the UI; an unbounded value bloats state and is a DoS vector.
const maxAnnotationLength = 1024
// internalError logs the underlying error and returns a generic 500 to the
// client. Storage errors can contain keys and other implementation details;
// echoing them back leaks information.
func internalError(c *gin.Context, err error) {
log.Printf("notification controller: %v", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{
Message: "internal server error",
})
}
// NotificationController handles notification-related API endpoints.
type NotificationController struct {
dispatcher *notifUC.Dispatcher
registry *notifPkg.Registry
channelStore notifUC.NotificationChannelStorage
prefStore notifUC.NotificationPreferenceStorage
recordStore notifUC.NotificationRecordStorage
}
// NewNotificationController creates a new NotificationController.
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,
}
}
// ListChannelTypes returns the channel types registered by the server, so the
// UI knows which transports are available without hardcoding the list.
//
// @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())
}
// --- Channel CRUD ---
// ListChannels returns all notification channels for the authenticated user.
//
// @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)
}
// CreateChannel creates a new notification channel.
//
// @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)
}
// GetChannel returns a specific notification channel.
//
// @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)
}
// UpdateChannel updates a notification channel. Fields absent from the request
// body are preserved from the stored channel, so a PATCH-style partial update
// does not silently zero out unrelated fields (e.g. omitting "enabled" must
// not disable the channel).
//
// @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 into a copy of the stored channel so json.Unmarshal only overwrites
// fields present in the body. Identity fields are then forced back to the
// stored values, regardless of what the client sent.
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 that the redacted GET response could not
// expose to the client, so a round-trip GET → PUT does not silently wipe
// them. Sender-specific behaviour lives in ConfigMerger.
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)
}
// DeleteChannel deletes a notification channel.
//
// @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)
}
// TestChannel sends a test notification through a channel.
//
// @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"})
}
// --- Preference CRUD ---
// validateQuietHours rejects out-of-range quiet-hour values. The fields are
// optional pointers; nil is allowed. A 023 hour is required when set.
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")
}
return nil
}
// ListPreferences returns all notification preferences for the authenticated user.
//
// @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)
}
// CreatePreference creates a new notification preference.
//
// @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)
}
// GetPreference returns a specific notification preference.
//
// @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))
}
// UpdatePreference updates a notification preference. Fields absent from the
// request body are preserved from the stored preference (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)
}
// DeletePreference deletes a notification preference.
//
// @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)
}
// --- History ---
// ListHistory returns recent notification records for the authenticated user.
//
// @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)
}
// --- Acknowledgement ---
// AcknowledgeIssue marks a checker issue as acknowledged.
//
// @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)
}
// ClearAcknowledgement removes an acknowledgement from a checker issue.
//
// @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)
}

View file

@ -0,0 +1,124 @@
// 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"
)
// NotificationChannelHandler resolves :channelId, ensures it exists and is
// owned by the authenticated user, and exposes it via MyNotificationChannel.
// Centralizing the ownership check here removes a latent bug class: any new
// per-channel endpoint cannot forget to enforce 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()
}
}
// MyNotificationChannel returns the channel resolved by NotificationChannelHandler.
// Panics if the middleware was not installed on the route — this is a wiring
// bug, not a runtime condition.
func MyNotificationChannel(c *gin.Context) *happydns.NotificationChannel {
return c.MustGet(ctxKeyNotificationChannel).(*happydns.NotificationChannel)
}
// NotificationPreferenceHandler resolves :prefId with the same contract as
// NotificationChannelHandler.
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()
}
}
// MyNotificationPreference returns the preference resolved by NotificationPreferenceHandler.
func MyNotificationPreference(c *gin.Context) *happydns.NotificationPreference {
return c.MustGet(ctxKeyNotificationPreference).(*happydns.NotificationPreference)
}

View file

@ -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)
}
}

View file

@ -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,
)
}

View file

@ -0,0 +1,103 @@
// 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"
)
// DeclareNotificationRoutes registers notification routes under /api/notifications.
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)
// TestChannel triggers an outbound request — webhook/email/UnifiedPush —
// per call. Without throttling, an authenticated user could spam any
// configured endpoint at line rate. Rate-limit per user (the channel is
// owned by the user; spreading it by IP would be looser than necessary).
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
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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)
@ -415,6 +453,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)
@ -437,6 +479,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 {

View file

@ -22,7 +22,6 @@
package notification
import (
"errors"
"time"
"git.happydns.org/happyDomain/model"
@ -45,24 +44,25 @@ func NewAckService(stateStore NotificationStateStorage, locker *StateLocker) *Ac
return &AckService{stateStore: stateStore, locker: locker, nowFn: time.Now}
}
// AcknowledgeIssue marks an issue as acknowledged, suppressing repeat
// notifications until the next state change.
//
// 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 errors.Is(err, happydns.ErrNotificationStateNotFound) {
// Create a new state if one doesn't exist yet.
state = &happydns.NotificationState{
CheckerID: checkerID,
Target: target,
UserId: userId,
LastStatus: happydns.StatusUnknown,
}
} else if err != nil {
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
@ -82,6 +82,9 @@ func (a *AckService) ClearAcknowledgement(userId happydns.Identifier, checkerID
if err != nil {
return err
}
if !state.UserId.Equals(userId) {
return happydns.ErrNotificationStateNotFound
}
state.ClearAcknowledgement()
return a.stateStore.PutState(state)