Files
warmbly/internal/api/handler/admin_notifications.go
T

139 lines
4.3 KiB
Go

package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/app/instancesettings"
"github.com/warmbly/warmbly/internal/app/opsnotify"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
)
// Operator notification channels. The channels themselves live in the instance
// settings document and are written through PUT /admin/instance/settings; this
// file adds the two things that document cannot carry: the catalog of
// subscribable events, and a way to prove a channel actually works.
// AdminNotificationEvents returns the subscribable event catalog.
//
// A self-hosted deployment gets the subset that means something without
// billing, so the panel never offers to alert on a commercial event that can
// never fire there.
func (h *Handler) AdminNotificationEvents(c *gin.Context) {
selfHosted := config.SelfHosted()
out := make([]opsnotify.EventDef, 0, len(opsnotify.Catalog))
for _, def := range opsnotify.Catalog {
if selfHosted && !def.SelfHostRelevant {
continue
}
out = append(out, def)
}
c.JSON(http.StatusOK, gin.H{"events": out, "self_hosted": selfHosted})
}
// AdminTestNotificationChannel delivers a test alert.
//
// It accepts either a saved channel id or a full channel body, so an operator
// can prove a webhook URL before committing it to the document. Unlike every
// other delivery this one is synchronous and reports the transport error, and
// it is the only place a channel is exercised on demand.
func (h *Handler) AdminTestNotificationChannel(c *gin.Context) {
adminID := middleware.GetAdminUserID(c)
if adminID == nil {
errx.JSON(c, errx.ErrUnauthorized)
return
}
if h.OpsNotifier == nil {
errx.JSON(c, errx.New(errx.BadRequest, "Operator notifications are not available on this deployment."))
return
}
var req struct {
// ID selects a saved channel; when set the body's other fields are ignored.
ID string `json:"id"`
// The unsaved-channel form.
Type string `json:"type"`
Name string `json:"name"`
Target string `json:"target"`
Secret string `json:"secret"`
}
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
return
}
var ch instancesettings.NotifyChannel
if req.ID != "" {
if h.InstanceSettings == nil {
errx.JSON(c, errx.New(errx.BadRequest, "Instance settings are not available on this deployment."))
return
}
saved, ok := h.InstanceSettings.Get(c.Request.Context()).Notifications.Find(req.ID)
if !ok {
errx.JSON(c, errx.New(errx.NotFound, "no such notification channel"))
return
}
ch = saved
} else {
ch = instancesettings.NotifyChannel{
ID: "test",
Name: req.Name,
Type: req.Type,
Target: req.Target,
Secret: req.Secret,
Enabled: true,
}
// A masked target means the client sent back a redacted read without
// retyping it, which cannot be delivered to. Ask for the id instead.
if ch.Target == instancesettings.Masked {
errx.JSON(c, errx.New(errx.BadRequest, "Save the channel first, then send a test to it by id."))
return
}
if ch.IsWebhookTransport() {
if err := instancesettings.ValidateChannelURL(ch.Target); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, err.Error()))
return
}
}
}
// A test is always delivered, whatever the channel is subscribed to:
// Enabled and Events gate real alerts, not a deliberate probe.
ch.Enabled = true
ch.Events = nil
event := opsnotify.NewEvent(
opsnotify.EventTest,
"Test alert from Warmbly",
"If you can read this, this channel is wired up correctly.",
opsnotify.Field{Label: "Channel", Value: firstNonBlank(ch.Name, ch.Type)},
)
if err := h.OpsNotifier.Deliver(c.Request.Context(), ch, event); err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "Delivery failed: "+err.Error()))
return
}
if h.AdminService != nil {
h.AdminService.LogAdminAction(
c.Request.Context(), *adminID,
"test_notification_channel", "instance", nil,
map[string]any{"channel_type": ch.Type, "channel_name": ch.Name},
c.ClientIP(), c.Request.UserAgent(),
)
}
c.JSON(http.StatusOK, gin.H{"delivered": true})
}
func firstNonBlank(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return "channel"
}