Files
pulse/internal/api/notifications.go
T
rcourtman 6c78d78fc7 fix(webhooks): test button in edit form sends redacted headers (#1273)
When editing an existing webhook, header values are masked as
***REDACTED*** for security. The "Test" button in the edit form
sent these redacted values to the webhook endpoint, causing auth
failures (HTTP 403) on services like ntfy.sh that require tokens.

The test button outside the edit form worked because it used the
server-side saved config with real header values.

Fix: frontend now includes the webhook ID in test payloads for
existing webhooks, and the backend TestWebhook handler merges
redacted values with the saved originals before sending the test
(same logic already used by UpdateWebhook).
2026-02-20 13:31:52 +00:00

936 lines
30 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog/log"
)
// NotificationManager defines the interface for notification management operations.
type NotificationManager interface {
GetEmailConfig() notifications.EmailConfig
SetEmailConfig(notifications.EmailConfig)
GetAppriseConfig() notifications.AppriseConfig
SetAppriseConfig(notifications.AppriseConfig)
GetWebhooks() []notifications.WebhookConfig
ValidateWebhookURL(string) error
AddWebhook(notifications.WebhookConfig)
UpdateWebhook(string, notifications.WebhookConfig) error
DeleteWebhook(string) error
SendTestWebhook(notifications.WebhookConfig) error
SendTestNotificationWithConfig(string, *notifications.EmailConfig, *notifications.TestNodeInfo) error
SendTestAppriseWithConfig(notifications.AppriseConfig) error
SendTestNotification(string) error
GetWebhookHistory() []notifications.WebhookDelivery
TestEnhancedWebhook(notifications.EnhancedWebhookConfig) (int, string, error)
GetQueueStats() (map[string]int, error)
}
// NotificationConfigPersistence defines the interface for saving notification configuration.
type NotificationConfigPersistence interface {
SaveEmailConfig(notifications.EmailConfig) error
SaveAppriseConfig(notifications.AppriseConfig) error
SaveWebhooks([]notifications.WebhookConfig) error
IsEncryptionEnabled() bool
}
// NotificationMonitor defines the interface for monitoring operations used by notification handlers.
type NotificationMonitor interface {
GetNotificationManager() NotificationManager
GetConfigPersistence() NotificationConfigPersistence
GetState() models.StateSnapshot
}
// NotificationHandlers handles notification-related HTTP endpoints
type NotificationHandlers struct {
mtMonitor *monitoring.MultiTenantMonitor
legacyMonitor NotificationMonitor
}
// NewNotificationHandlers creates new notification handlers
func NewNotificationHandlers(mtm *monitoring.MultiTenantMonitor, monitor NotificationMonitor) *NotificationHandlers {
// If mtm is provided, try to populate legacyMonitor from "default" org if not provided
if monitor == nil && mtm != nil {
if m, err := mtm.GetMonitor("default"); err == nil {
monitor = NewNotificationMonitorWrapper(m)
}
}
return &NotificationHandlers{
mtMonitor: mtm,
legacyMonitor: monitor,
}
}
// SetMonitor updates the monitor reference for notification handlers.
func (h *NotificationHandlers) SetMonitor(m NotificationMonitor) {
h.legacyMonitor = m
}
// SetMultiTenantMonitor updates the multi-tenant monitor reference
func (h *NotificationHandlers) SetMultiTenantMonitor(mtm *monitoring.MultiTenantMonitor) {
h.mtMonitor = mtm
if mtm != nil {
if m, err := mtm.GetMonitor("default"); err == nil {
h.legacyMonitor = NewNotificationMonitorWrapper(m)
}
}
}
func (h *NotificationHandlers) getMonitor(ctx context.Context) NotificationMonitor {
orgID := GetOrgID(ctx)
if h.mtMonitor != nil {
if m, err := h.mtMonitor.GetMonitor(orgID); err == nil && m != nil {
return NewNotificationMonitorWrapper(m)
}
}
return h.legacyMonitor
}
// GetEmailConfig returns the current email configuration
func (h *NotificationHandlers) GetEmailConfig(w http.ResponseWriter, r *http.Request) {
config := h.getMonitor(r.Context()).GetNotificationManager().GetEmailConfig()
// For security, don't return the password
config.Password = ""
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(config)
}
// UpdateEmailConfig updates the email configuration
func (h *NotificationHandlers) UpdateEmailConfig(w http.ResponseWriter, r *http.Request) {
// Limit request body to 32KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
// Read raw body for debugging
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// NEVER log the body as it contains passwords
log.Info().
Msg("Received email config update")
var config notifications.EmailConfig
if err := json.Unmarshal(body, &config); err != nil {
log.Error().Err(err).Msg("Failed to parse email config") // Don't log body with passwords
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If password is empty, preserve the existing password
if config.Password == "" {
existingConfig := h.getMonitor(r.Context()).GetNotificationManager().GetEmailConfig()
config.Password = existingConfig.Password
}
log.Info().
Bool("enabled", config.Enabled).
Str("smtp", config.SMTPHost).
Str("from", config.From).
Int("toCount", len(config.To)).
Bool("hasPassword", config.Password != "").
Msg("Parsed email config")
h.getMonitor(r.Context()).GetNotificationManager().SetEmailConfig(config)
// Save to persistent storage
if err := h.getMonitor(r.Context()).GetConfigPersistence().SaveEmailConfig(config); err != nil {
// Log error but don't fail the request
log.Error().Err(err).Msg("Failed to save email configuration")
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
// GetAppriseConfig returns the current Apprise configuration.
func (h *NotificationHandlers) GetAppriseConfig(w http.ResponseWriter, r *http.Request) {
config := h.getMonitor(r.Context()).GetNotificationManager().GetAppriseConfig()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(config); err != nil {
log.Error().Err(err).Msg("Failed to encode Apprise configuration response")
}
}
// UpdateAppriseConfig updates the Apprise configuration.
func (h *NotificationHandlers) UpdateAppriseConfig(w http.ResponseWriter, r *http.Request) {
// Limit request body to 64KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var config notifications.AppriseConfig
if err := json.Unmarshal(body, &config); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Info().
Bool("enabled", config.Enabled).
Str("mode", string(config.Mode)).
Int("targetCount", len(config.Targets)).
Str("cliPath", config.CLIPath).
Str("serverUrl", config.ServerURL).
Str("configKey", config.ConfigKey).
Bool("hasApiKey", config.APIKey != "").
Str("apiKeyHeader", config.APIKeyHeader).
Bool("skipTlsVerify", config.SkipTLSVerify).
Int("timeoutSeconds", config.TimeoutSeconds).
Msg("Parsed Apprise configuration update")
h.getMonitor(r.Context()).GetNotificationManager().SetAppriseConfig(config)
if err := h.getMonitor(r.Context()).GetConfigPersistence().SaveAppriseConfig(config); err != nil {
log.Error().Err(err).Msg("Failed to save Apprise configuration")
}
normalized := h.getMonitor(r.Context()).GetNotificationManager().GetAppriseConfig()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(normalized); err != nil {
log.Error().Err(err).Msg("Failed to encode Apprise configuration response")
}
}
// GetWebhooks returns all webhook configurations with secrets masked
func (h *NotificationHandlers) GetWebhooks(w http.ResponseWriter, r *http.Request) {
webhooks := h.getMonitor(r.Context()).GetNotificationManager().GetWebhooks()
// Mask sensitive fields in headers and customFields
maskedWebhooks := make([]map[string]interface{}, len(webhooks))
for i, webhook := range webhooks {
whMap := map[string]interface{}{
"id": webhook.ID,
"name": webhook.Name,
"url": webhook.URL,
"method": webhook.Method,
"enabled": webhook.Enabled,
"service": webhook.Service,
}
// Mask headers - only show keys, not values
if len(webhook.Headers) > 0 {
maskedHeaders := make(map[string]string)
for key := range webhook.Headers {
maskedHeaders[key] = "***REDACTED***"
}
whMap["headers"] = maskedHeaders
}
// Mask custom fields - only show keys, not values
if len(webhook.CustomFields) > 0 {
maskedFields := make(map[string]string)
for key := range webhook.CustomFields {
maskedFields[key] = "***REDACTED***"
}
whMap["customFields"] = maskedFields
}
// Include template if present
if webhook.Template != "" {
whMap["template"] = webhook.Template
}
maskedWebhooks[i] = whMap
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(maskedWebhooks)
}
// CreateWebhook creates a new webhook
func (h *NotificationHandlers) CreateWebhook(w http.ResponseWriter, r *http.Request) {
// Limit request body to 64KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
// Read the raw body to preserve all fields
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var webhook notifications.WebhookConfig
if err := json.Unmarshal(bodyBytes, &webhook); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Validate webhook URL
if err := h.getMonitor(r.Context()).GetNotificationManager().ValidateWebhookURL(webhook.URL); err != nil {
http.Error(w, fmt.Sprintf("Invalid webhook URL: %v", err), http.StatusBadRequest)
return
}
// Generate ID if not provided
if webhook.ID == "" {
webhook.ID = utils.GenerateID("webhook")
}
h.getMonitor(r.Context()).GetNotificationManager().AddWebhook(webhook)
// Save webhooks to persistent storage with all fields
webhooks := h.getMonitor(r.Context()).GetNotificationManager().GetWebhooks()
if err := h.getMonitor(r.Context()).GetConfigPersistence().SaveWebhooks(webhooks); err != nil {
log.Error().Err(err).Msg("Failed to save webhooks")
}
// Return the full webhook data including any extra fields like 'service'
var responseData map[string]interface{}
if err := json.Unmarshal(bodyBytes, &responseData); err != nil {
log.Warn().Err(err).Msg("Failed to unmarshal webhook response data")
responseData = make(map[string]interface{})
}
responseData["id"] = webhook.ID
if err := utils.WriteJSONResponse(w, responseData); err != nil {
log.Error().Err(err).Msg("Failed to write webhook creation response")
}
}
// UpdateWebhook updates an existing webhook
func (h *NotificationHandlers) UpdateWebhook(w http.ResponseWriter, r *http.Request) {
// Extract webhook ID from URL path
// Path is like /api/notifications/webhooks/{id} after routing
path := strings.TrimPrefix(r.URL.Path, "/api/notifications/webhooks/")
webhookID := path
if webhookID == "" {
http.Error(w, "Invalid URL - missing webhook ID", http.StatusBadRequest)
return
}
// Limit request body to 64KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
// Read the raw body to preserve all fields
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var webhook notifications.WebhookConfig
if err := json.Unmarshal(bodyBytes, &webhook); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Preserve original headers/customFields if the incoming values are redacted
// This happens when the frontend sends back masked values from GetWebhooks
existingWebhooks := h.getMonitor(r.Context()).GetNotificationManager().GetWebhooks()
for _, existing := range existingWebhooks {
if existing.ID == webhookID {
// Preserve headers if incoming contains redacted values
if len(webhook.Headers) > 0 && len(existing.Headers) > 0 {
hasRedacted := false
for _, v := range webhook.Headers {
if v == "***REDACTED***" {
hasRedacted = true
break
}
}
if hasRedacted {
webhook.Headers = existing.Headers
}
}
// Preserve customFields if incoming contains redacted values
if len(webhook.CustomFields) > 0 && len(existing.CustomFields) > 0 {
hasRedacted := false
for _, v := range webhook.CustomFields {
if v == "***REDACTED***" {
hasRedacted = true
break
}
}
if hasRedacted {
webhook.CustomFields = existing.CustomFields
}
}
break
}
}
// Validate webhook URL
if err := h.getMonitor(r.Context()).GetNotificationManager().ValidateWebhookURL(webhook.URL); err != nil {
http.Error(w, fmt.Sprintf("Invalid webhook URL: %v", err), http.StatusBadRequest)
return
}
webhook.ID = webhookID
if err := h.getMonitor(r.Context()).GetNotificationManager().UpdateWebhook(webhookID, webhook); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Save webhooks to persistent storage
webhooks := h.getMonitor(r.Context()).GetNotificationManager().GetWebhooks()
if err := h.getMonitor(r.Context()).GetConfigPersistence().SaveWebhooks(webhooks); err != nil {
log.Error().Err(err).Msg("Failed to save webhooks")
}
// Return the full webhook data including any extra fields like 'service'
var responseData map[string]interface{}
if err := json.Unmarshal(bodyBytes, &responseData); err != nil {
log.Warn().Err(err).Msg("Failed to unmarshal webhook response data")
responseData = make(map[string]interface{})
}
responseData["id"] = webhookID
if err := utils.WriteJSONResponse(w, responseData); err != nil {
log.Error().Err(err).Str("webhookID", webhookID).Msg("Failed to write webhook update response")
}
}
// DeleteWebhook deletes a webhook
func (h *NotificationHandlers) DeleteWebhook(w http.ResponseWriter, r *http.Request) {
// Extract webhook ID from URL path
// Path is like /api/notifications/webhooks/{id} after routing
path := strings.TrimPrefix(r.URL.Path, "/api/notifications/webhooks/")
webhookID := path
if webhookID == "" {
http.Error(w, "Invalid URL - missing webhook ID", http.StatusBadRequest)
return
}
if err := h.getMonitor(r.Context()).GetNotificationManager().DeleteWebhook(webhookID); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Save webhooks to persistent storage
webhooks := h.getMonitor(r.Context()).GetNotificationManager().GetWebhooks()
if err := h.getMonitor(r.Context()).GetConfigPersistence().SaveWebhooks(webhooks); err != nil {
log.Error().Err(err).Msg("Failed to save webhooks")
}
if err := utils.WriteJSONResponse(w, map[string]string{"status": "success"}); err != nil {
log.Error().Err(err).Str("webhookID", webhookID).Msg("Failed to write webhook deletion response")
}
}
// TestNotification sends a test notification
func (h *NotificationHandlers) TestNotification(w http.ResponseWriter, r *http.Request) {
// Read body for debugging
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// NEVER log the body as it contains passwords
log.Info().
Msg("Test notification request received")
var req struct {
Method string `json:"method"` // "email", "webhook", or "apprise"
Type string `json:"type"` // Alternative field name used by frontend
Config json.RawMessage `json:"config,omitempty"` // Optional config for testing (email or apprise)
WebhookID string `json:"webhookId,omitempty"` // For webhook testing
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Support both "method" and "type" field names
if req.Method == "" && req.Type != "" {
req.Method = req.Type
}
// Get actual node info from monitor state
state := h.getMonitor(r.Context()).GetState()
var nodeInfo *notifications.TestNodeInfo
// Use first available node and instance
if len(state.Nodes) > 0 {
for _, node := range state.Nodes {
nodeInfo = &notifications.TestNodeInfo{
NodeName: node.Name,
InstanceURL: node.Instance,
}
break
}
}
// Handle webhook testing
if req.Method == "webhook" && req.WebhookID != "" {
log.Info().
Str("webhookId", req.WebhookID).
Msg("Testing specific webhook")
// Get the webhook by ID and test it
webhooks := h.getMonitor(r.Context()).GetNotificationManager().GetWebhooks()
var foundWebhook *notifications.WebhookConfig
for _, wh := range webhooks {
if wh.ID == req.WebhookID {
foundWebhook = &wh
break
}
}
if foundWebhook == nil {
http.Error(w, "Webhook not found", http.StatusNotFound)
return
}
// Send test webhook
if err := h.getMonitor(r.Context()).GetNotificationManager().SendTestWebhook(*foundWebhook); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
} else if req.Method == "email" && len(req.Config) > 0 {
var emailConfig notifications.EmailConfig
if err := json.Unmarshal(req.Config, &emailConfig); err != nil {
http.Error(w, fmt.Sprintf("Invalid email config: %v", err), http.StatusBadRequest)
return
}
// If password is empty, use the saved password
if emailConfig.Password == "" {
savedConfig := h.getMonitor(r.Context()).GetNotificationManager().GetEmailConfig()
emailConfig.Password = savedConfig.Password
}
log.Info().
Bool("enabled", emailConfig.Enabled).
Str("smtp", emailConfig.SMTPHost).
Str("from", emailConfig.From).
Int("toCount", len(emailConfig.To)).
Strs("to", emailConfig.To).
Bool("hasPassword", emailConfig.Password != "").
Msg("Testing email with provided config")
if err := h.getMonitor(r.Context()).GetNotificationManager().SendTestNotificationWithConfig(req.Method, &emailConfig, nodeInfo); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
} else if req.Method == "apprise" && len(req.Config) > 0 {
var appriseConfig notifications.AppriseConfig
if err := json.Unmarshal(req.Config, &appriseConfig); err != nil {
http.Error(w, fmt.Sprintf("Invalid Apprise config: %v", err), http.StatusBadRequest)
return
}
if err := h.getMonitor(r.Context()).GetNotificationManager().SendTestAppriseWithConfig(appriseConfig); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
} else {
// Use saved config
if err := h.getMonitor(r.Context()).GetNotificationManager().SendTestNotification(req.Method); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success", "message": "Test notification sent"})
}
// GetWebhookTemplates returns available webhook templates
func (h *NotificationHandlers) GetWebhookTemplates(w http.ResponseWriter, r *http.Request) {
templates := notifications.GetWebhookTemplates()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(templates)
}
// GetWebhookHistory returns recent webhook delivery history with URLs redacted
func (h *NotificationHandlers) GetWebhookHistory(w http.ResponseWriter, r *http.Request) {
history := h.getMonitor(r.Context()).GetNotificationManager().GetWebhookHistory()
// Redact secrets from URLs in history
for i := range history {
history[i].WebhookURL = redactSecretsFromURL(history[i].WebhookURL)
// Note: ResponseBody is not stored in WebhookDelivery struct
// Error messages are already limited in length
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(history)
}
// redactSecretsFromURL masks tokens and credentials in URLs
func redactSecretsFromURL(urlStr string) string {
// Redact common patterns like:
// - /bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/sendMessage → /botXXX:REDACTED/sendMessage
// - ?token=abc123 → ?token=REDACTED
// - ?apikey=abc123 → ?apikey=REDACTED
// Redact Telegram bot tokens
if idx := strings.Index(urlStr, "/bot"); idx != -1 {
// Search for next "/" after "/bot" (starting at idx+4)
if endIdx := strings.Index(urlStr[idx+4:], "/"); endIdx != -1 {
urlStr = urlStr[:idx+4] + "REDACTED" + urlStr[idx+4+endIdx:]
} else {
// No trailing slash - token extends to end of URL or query string
if qIdx := strings.Index(urlStr[idx+4:], "?"); qIdx != -1 {
urlStr = urlStr[:idx+4] + "REDACTED" + urlStr[idx+4+qIdx:]
} else {
urlStr = urlStr[:idx+4] + "REDACTED"
}
}
}
// Redact query parameters with sensitive names
if qIdx := strings.Index(urlStr, "?"); qIdx != -1 {
sensitiveParams := []string{"token", "apikey", "api_key", "key", "secret", "password"}
for _, param := range sensitiveParams {
pattern := param + "="
// Search for the pattern after the query string starts
searchStart := qIdx
for {
paramIdx := strings.Index(urlStr[searchStart:], pattern)
if paramIdx == -1 {
break
}
paramIdx += searchStart // Convert to absolute index
// Check that we're at a parameter boundary (after ? or &)
if paramIdx > 0 {
prevChar := urlStr[paramIdx-1]
if prevChar != '?' && prevChar != '&' {
// Not at a boundary - this is part of another param name
// Move past this match and continue searching
searchStart = paramIdx + len(pattern)
continue
}
}
// Valid match - redact the value
start := paramIdx + len(pattern)
end := start
for end < len(urlStr) && urlStr[end] != '&' && urlStr[end] != '#' {
end++
}
urlStr = urlStr[:start] + "REDACTED" + urlStr[end:]
// After modification, continue from after the inserted REDACTED
searchStart = start + len("REDACTED")
}
}
}
return urlStr
}
// GetEmailProviders returns available email providers
func (h *NotificationHandlers) GetEmailProviders(w http.ResponseWriter, r *http.Request) {
providers := notifications.GetEmailProviders()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(providers)
}
// TestWebhook tests a webhook configuration
func (h *NotificationHandlers) TestWebhook(w http.ResponseWriter, r *http.Request) {
// First try to decode as basic webhook config
var basicWebhook notifications.WebhookConfig
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := json.Unmarshal(bodyBytes, &basicWebhook); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// If testing an existing webhook, merge redacted header/custom field values
// with the saved originals (the frontend masks them with ***REDACTED*** on load)
if basicWebhook.ID != "" {
existingWebhooks := h.getMonitor(r.Context()).GetNotificationManager().GetWebhooks()
for _, existing := range existingWebhooks {
if existing.ID == basicWebhook.ID {
if len(basicWebhook.Headers) > 0 && len(existing.Headers) > 0 {
for _, v := range basicWebhook.Headers {
if v == "***REDACTED***" {
basicWebhook.Headers = existing.Headers
break
}
}
}
if len(basicWebhook.CustomFields) > 0 && len(existing.CustomFields) > 0 {
for _, v := range basicWebhook.CustomFields {
if v == "***REDACTED***" {
basicWebhook.CustomFields = existing.CustomFields
break
}
}
}
break
}
}
}
// Convert to enhanced webhook for testing
webhook := notifications.EnhancedWebhookConfig{
WebhookConfig: basicWebhook,
Service: "generic", // Default to generic if not specified
RetryEnabled: false, // Don't retry during testing
}
if len(basicWebhook.CustomFields) > 0 {
customFields := make(map[string]interface{}, len(basicWebhook.CustomFields))
for key, value := range basicWebhook.CustomFields {
customFields[key] = value
}
webhook.CustomFields = customFields
}
// If the webhook has a custom template, use it
if basicWebhook.Template != "" {
webhook.PayloadTemplate = basicWebhook.Template
}
// Try to extract service from body if present
var serviceCheck struct {
Service string `json:"service"`
}
if err := json.Unmarshal(bodyBytes, &serviceCheck); err == nil && serviceCheck.Service != "" {
webhook.Service = serviceCheck.Service
// Also set it in the basic webhook for consistency
basicWebhook.Service = serviceCheck.Service
webhook.WebhookConfig.Service = serviceCheck.Service
}
log.Info().
Str("service", webhook.Service).
Str("url", webhook.URL).
Str("name", webhook.Name).
Msg("Testing webhook")
// Get template for the service (if not using custom template)
if webhook.PayloadTemplate == "" {
templates := notifications.GetWebhookTemplates()
for _, tmpl := range templates {
if tmpl.Service == webhook.Service {
webhook.PayloadTemplate = tmpl.PayloadTemplate
if webhook.Headers == nil {
webhook.Headers = make(map[string]string)
}
// Only copy headers that don't contain template syntax
// This prevents issues with headers that have Go template expressions
for k, v := range tmpl.Headers {
if !strings.Contains(v, "{{") {
webhook.Headers[k] = v
}
}
log.Info().Str("service", webhook.Service).Msg("Found template for service")
break
}
}
}
// If still no template found, use a simple generic template
if webhook.PayloadTemplate == "" {
webhook.PayloadTemplate = `{
"alert": {
"id": "{{.ID}}",
"type": "{{.Type}}",
"level": "{{.Level}}",
"resourceName": "{{.ResourceName}}",
"node": "{{.Node}}",
"message": "{{.Message}}",
"value": {{.Value}},
"threshold": {{.Threshold}}
},
"source": "pulse-monitoring",
"timestamp": {{.Timestamp}}
}`
}
// Test the webhook
status, response, err := h.getMonitor(r.Context()).GetNotificationManager().TestEnhancedWebhook(webhook)
result := map[string]interface{}{
"status": status,
"response": response,
}
if err != nil {
result["error"] = err.Error()
w.WriteHeader(http.StatusBadRequest)
} else if status < 200 || status >= 300 {
// HTTP error from webhook endpoint
result["error"] = fmt.Sprintf("Webhook returned HTTP %d: %s", status, response)
result["success"] = false
w.WriteHeader(http.StatusBadRequest)
} else {
result["success"] = true
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// GetNotificationHealth returns health status of notification system
func (h *NotificationHandlers) GetNotificationHealth(w http.ResponseWriter, r *http.Request) {
// Get queue stats
queueStats := make(map[string]interface{})
stats, err := h.getMonitor(r.Context()).GetNotificationManager().GetQueueStats()
if err != nil {
log.Warn().Err(err).Msg("Failed to get queue stats for health check")
queueStats["error"] = err.Error()
queueStats["healthy"] = false
} else {
queueStats = map[string]interface{}{
"pending": stats["pending"],
"sending": stats["sending"],
"sent": stats["sent"],
"failed": stats["failed"],
"dlq": stats["dlq"],
"healthy": true,
}
}
// Get config status
nm := h.getMonitor(r.Context()).GetNotificationManager()
emailCfg := nm.GetEmailConfig()
webhooks := nm.GetWebhooks()
health := map[string]interface{}{
"queue": queueStats,
"email": map[string]interface{}{
"enabled": emailCfg.Enabled,
"configured": emailCfg.SMTPHost != "",
},
"webhooks": map[string]interface{}{
"total": len(webhooks),
"enabled": countEnabledWebhooks(webhooks),
},
"encryption": map[string]interface{}{
"enabled": h.getMonitor(r.Context()).GetConfigPersistence().IsEncryptionEnabled(),
},
"overall_healthy": queueStats["healthy"] == true,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(health)
}
func countEnabledWebhooks(webhooks []notifications.WebhookConfig) int {
count := 0
for _, wh := range webhooks {
if wh.Enabled {
count++
}
}
return count
}
// HandleNotifications routes notification requests to appropriate handlers
func (h *NotificationHandlers) HandleNotifications(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/notifications")
requireAnyScope := func(required string, scopes ...string) bool {
record := getAPITokenRecordFromRequest(r)
if record == nil {
return true
}
for _, scope := range scopes {
if scope != "" && record.HasScope(scope) {
return true
}
}
respondMissingScope(w, required)
return false
}
switch {
case path == "/email" && r.Method == http.MethodGet:
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
return
}
h.GetEmailConfig(w, r)
case path == "/email" && r.Method == http.MethodPut:
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
return
}
h.UpdateEmailConfig(w, r)
case path == "/apprise" && r.Method == http.MethodGet:
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
return
}
h.GetAppriseConfig(w, r)
case path == "/apprise" && r.Method == http.MethodPut:
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
return
}
h.UpdateAppriseConfig(w, r)
case path == "/webhooks" && r.Method == http.MethodGet:
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
return
}
h.GetWebhooks(w, r)
case path == "/webhooks" && r.Method == http.MethodPost:
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
return
}
h.CreateWebhook(w, r)
case path == "/webhooks/test" && r.Method == http.MethodPost:
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
return
}
h.TestWebhook(w, r)
case strings.HasPrefix(path, "/webhooks/") && r.Method == http.MethodPut:
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
return
}
h.UpdateWebhook(w, r)
case strings.HasPrefix(path, "/webhooks/") && r.Method == http.MethodDelete:
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
return
}
h.DeleteWebhook(w, r)
case path == "/webhook-templates" && r.Method == http.MethodGet:
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
return
}
h.GetWebhookTemplates(w, r)
case path == "/webhook-history" && r.Method == http.MethodGet:
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
return
}
h.GetWebhookHistory(w, r)
case path == "/email-providers" && r.Method == http.MethodGet:
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
return
}
h.GetEmailProviders(w, r)
case path == "/test" && r.Method == http.MethodPost:
if !requireAnyScope(config.ScopeSettingsWrite, config.ScopeSettingsWrite) {
return
}
h.TestNotification(w, r)
case path == "/health" && r.Method == http.MethodGet:
if !requireAnyScope(config.ScopeSettingsRead, config.ScopeSettingsRead, config.ScopeSettingsWrite) {
return
}
h.GetNotificationHealth(w, r)
default:
http.Error(w, "Not found", http.StatusNotFound)
}
}