From d997cb5ed3d66c5affe0c7d1e12f441fbd66757f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 11 Apr 2026 08:55:00 +0700 Subject: [PATCH] notification: add API endpoints for channels, preferences, history, and acknowledgement --- internal/api/controller/notification.go | 514 +++++++++++++++++++ internal/api/middleware/notification.go | 124 +++++ internal/api/route/checker.go | 9 +- internal/api/route/domain.go | 4 +- internal/api/route/notification.go | 103 ++++ internal/api/route/route.go | 22 + internal/api/route/service.go | 3 +- internal/api/route/zone.go | 2 + internal/app/app.go | 48 ++ internal/usecase/notification/acknowledge.go | 27 +- 10 files changed, 841 insertions(+), 15 deletions(-) create mode 100644 internal/api/controller/notification.go create mode 100644 internal/api/middleware/notification.go create mode 100644 internal/api/route/notification.go diff --git a/internal/api/controller/notification.go b/internal/api/controller/notification.go new file mode 100644 index 00000000..76cde9e7 --- /dev/null +++ b/internal/api/controller/notification.go @@ -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 . +// +// 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 . + +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 0–23 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) +} diff --git a/internal/api/middleware/notification.go b/internal/api/middleware/notification.go new file mode 100644 index 00000000..dda4bfb3 --- /dev/null +++ b/internal/api/middleware/notification.go @@ -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 . +// +// 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 . + +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) +} diff --git a/internal/api/route/checker.go b/internal/api/route/checker.go index 19cfa4a5..c08e85c5 100644 --- a/internal/api/route/checker.go +++ b/internal/api/route/checker.go @@ -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) + } } diff --git a/internal/api/route/domain.go b/internal/api/route/domain.go index c5586f27..523c2c4a 100644 --- a/internal/api/route/domain.go +++ b/internal/api/route/domain.go @@ -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, ) } diff --git a/internal/api/route/notification.go b/internal/api/route/notification.go new file mode 100644 index 00000000..7e01f9e7 --- /dev/null +++ b/internal/api/route/notification.go @@ -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 . +// +// 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 . + +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 +} + diff --git a/internal/api/route/route.go b/internal/api/route/route.go index 8f17d3d8..4741171d 100644 --- a/internal/api/route/route.go +++ b/internal/api/route/route.go @@ -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) diff --git a/internal/api/route/service.go b/internal/api/route/service.go index ded089a8..0690099f 100644 --- a/internal/api/route/service.go +++ b/internal/api/route/service.go @@ -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) } } diff --git a/internal/api/route/zone.go b/internal/api/route/zone.go index bb6da44c..c6c90710 100644 --- a/internal/api/route/zone.go +++ b/internal/api/route/zone.go @@ -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) diff --git a/internal/app/app.go b/internal/app/app.go index 555f29dc..b179ca60 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 { diff --git a/internal/usecase/notification/acknowledge.go b/internal/usecase/notification/acknowledge.go index b9cdf936..1d601a20 100644 --- a/internal/usecase/notification/acknowledge.go +++ b/internal/usecase/notification/acknowledge.go @@ -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)