notification: add API endpoints for channels, preferences, history, and acknowledgement
This commit is contained in:
parent
6f1d3432ff
commit
d997cb5ed3
10 changed files with 841 additions and 15 deletions
514
internal/api/controller/notification.go
Normal file
514
internal/api/controller/notification.go
Normal 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 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)
|
||||
}
|
||||
124
internal/api/middleware/notification.go
Normal file
124
internal/api/middleware/notification.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
103
internal/api/route/notification.go
Normal file
103
internal/api/route/notification.go
Normal 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
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue