package notification import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "log/slog" "sync" "time" "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // Hub fans Payload events out to every enabled channel and persists // the result to notification_log. One Hub per process; thread-safe. type Hub struct { store *store.Store aead *crypto.AEAD baseURL string // e.g. https://restic-manager.example msgIDDomain string // hostname extracted from baseURL for SMTP Message-ID } // NewHub constructs a Hub. baseURL is the public root of the server // (used to build /alerts/ links and the SMTP Message-ID domain). func NewHub(st *store.Store, aead *crypto.AEAD, baseURL string) *Hub { return &Hub{ store: st, aead: aead, baseURL: baseURL, msgIDDomain: extractDomain(baseURL), } } // Dispatch fans out to every enabled channel. Best-effort — failures // are logged to notification_log but do not propagate to the caller. // Each channel runs in its own goroutine; Dispatch returns only when // all goroutines have settled, so the caller can block briefly for // the test-button case. func (h *Hub) Dispatch(ctx context.Context, p Payload) { chans, err := h.store.ListEnabledNotificationChannels(ctx) if err != nil { slog.Error("notification: list channels", "err", err) return } // Stamp the alert link if the caller left it empty. if p.Link == "" { p.Link = h.baseURL + "/alerts/" + p.AlertID } var wg sync.WaitGroup for _, c := range chans { wg.Add(1) go func(c store.NotificationChannel) { defer wg.Done() h.send(ctx, c, p) }(c) } wg.Wait() } // DispatchOne fires a single channel — used by the "Send test // notification" button. Returns the log entry that was persisted so // the handler can render the result inline. func (h *Hub) DispatchOne(ctx context.Context, channelID string, p Payload) (store.NotificationLogEntry, error) { c, err := h.store.GetNotificationChannel(ctx, channelID) if err != nil { return store.NotificationLogEntry{}, err } if p.Link == "" { p.Link = h.baseURL + "/alerts/" + p.AlertID } return h.send(ctx, *c, p), nil } // send builds the channel impl, delivers the payload, and persists a // notification_log row regardless of success or failure. func (h *Hub) send(ctx context.Context, c store.NotificationChannel, p Payload) store.NotificationLogEntry { ch, buildErr := h.buildChannel(c) logEntry := store.NotificationLogEntry{ ID: newID(), ChannelID: c.ID, Event: string(p.Event), FiredAt: time.Now().UTC(), } if p.AlertID != "" { aid := p.AlertID logEntry.AlertID = &aid } if buildErr != nil { errStr := buildErr.Error() logEntry.OK = false logEntry.Error = &errStr _ = h.store.AppendNotificationLog(ctx, logEntry) return logEntry } code, latency, sendErr := ch.Send(ctx, p) statusCode := code latencyMS := int(latency.Milliseconds()) logEntry.StatusCode = &statusCode logEntry.LatencyMS = &latencyMS if sendErr != nil { errStr := sendErr.Error() logEntry.OK = false logEntry.Error = &errStr } else { logEntry.OK = true } if err := h.store.AppendNotificationLog(ctx, logEntry); err != nil { slog.Warn("notification: persist log", "err", err) } return logEntry } // buildChannel decrypts the channel config and returns a concrete // Channel implementation for the channel's kind. func (h *Hub) buildChannel(row store.NotificationChannel) (Channel, error) { plain, err := h.aead.Decrypt(string(row.Config), []byte("notification-channel:"+row.ID)) if err != nil { return nil, err } switch row.Kind { case "webhook": var cfg WebhookConfig if err := json.Unmarshal(plain, &cfg); err != nil { return nil, err } return NewWebhookChannel(cfg), nil case "ntfy": var cfg NtfyConfig if err := json.Unmarshal(plain, &cfg); err != nil { return nil, err } dp := "" if row.DefaultPriority != nil { dp = *row.DefaultPriority } return NewNtfyChannel(cfg, dp), nil case "smtp": var cfg SMTPConfig if err := json.Unmarshal(plain, &cfg); err != nil { return nil, err } return NewSMTPChannel(cfg, h.msgIDDomain), nil } return nil, errUnknownKind(row.Kind) } // newID returns a 32-hex-char random identifier for notification_log rows. func newID() string { var b [16]byte _, _ = rand.Read(b[:]) return hex.EncodeToString(b[:]) } // extractDomain strips the scheme and path from baseURL, leaving only // the host[:port] component. Used as the right-hand side of SMTP // Message-IDs. func extractDomain(baseURL string) string { s := baseURL if i := indexOf(s, "://"); i >= 0 { s = s[i+3:] } if i := indexOf(s, "/"); i >= 0 { s = s[:i] } if s == "" { return "restic-manager.local" } return s } // indexOf returns the index of the first occurrence of sub in s, or -1. func indexOf(s, sub string) int { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return i } } return -1 } type errUnknownKind string func (e errUnknownKind) Error() string { return "notification: unknown kind: " + string(e) }