// ui_notifications.go — HTML form-driven handlers for the notification // channel CRUD at /settings/notifications and the test-fire endpoint at // POST /api/notifications/{id}/test. // // The settings shell currently has a single sub-tab (Notifications); // the structure is designed to be extended with Users/Auth tabs later. // // Routes (wired in server.go): // // GET /settings → handleUISettings // GET /settings/notifications → handleUINotificationsList // GET /settings/notifications/new → handleUINotificationNewGet // POST /settings/notifications/new → handleUINotificationNewPost // GET /settings/notifications/{id}/edit → handleUINotificationEditGet // POST /settings/notifications/{id}/edit → handleUINotificationEditPost // POST /settings/notifications/{id}/delete → handleUINotificationDelete // POST /api/notifications/{id}/test → handleAPINotificationTest package http import ( "encoding/json" "errors" "fmt" "log/slog" stdhttp "net/http" "net/mail" "net/url" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/notification" "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // ── page models ────────────────────────────────────────────────────────────── // settingsPage is the data fed to the settings shell template. The // sub-tab body is embedded via the Channels slice so a single template // layout works for both the list and the edit form. type settingsPage struct { // ActiveTab is the settings sub-tab currently visible. ActiveTab string // Channels is the full list (list sub-tab). Channels []store.NotificationChannel // Form is populated when the operator is creating or editing a channel. Form *notificationForm // FormError is an inline error message for the channel form. FormError string // DeleteError is an inline error shown on the confirm-delete form. DeleteError string } // notificationForm holds the round-trip values for the channel // create/edit form. Separate per-kind sub-structs mirror the template // field groups; all fields are strings so the template never has to // handle nil. type notificationForm struct { // ID is the channel's ULID; empty for new. ID string Kind string // webhook | ntfy | smtp Name string // Enabled maps to the enabled checkbox. Enabled bool // DefaultPriority applies to ntfy channels. DefaultPriority string // Webhook sub-fields. WebhookURL string WebhookBearerToken string WebhookHeaderName string WebhookHeaderValue string // Ntfy sub-fields. NtfyServerURL string NtfyTopic string NtfyAccessToken string // SMTP sub-fields. SMTPHost string SMTPPort string // string for form round-trip; validated to int on save SMTPEncryption string SMTPUsername string // SMTPPassword is a write-only field: shown as placeholder on edit; // blank on submit means "keep the stored value". SMTPPassword string SMTPFrom string SMTPTo string } // ── internal helpers ────────────────────────────────────────────────────────── // loadSettingsPage fetches the channel list and returns the base page model. func (s *Server) loadSettingsPage(r *stdhttp.Request) (*settingsPage, error) { chans, err := s.deps.Store.ListNotificationChannels(r.Context()) if err != nil { return nil, fmt.Errorf("list channels: %w", err) } return &settingsPage{ ActiveTab: "notifications", Channels: chans, }, nil } // renderSettingsPage renders the settings shell, setting HTTP 422 on // validation failure (pass status=0 for the normal 200). func (s *Server) renderSettingsPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, page *settingsPage, status int) { view := s.baseView(r, u) view.Title = "Settings · restic-manager" view.Active = "settings" view.Page = *page if status != 0 { w.WriteHeader(status) } if err := s.deps.UI.Render(w, "settings", view); err != nil { slog.Error("ui: render settings", "err", err) } } // encryptChannelConfig JSON-encodes cfg and AEAD-seals it with the // channel-specific additional-data binding. func (s *Server) encryptChannelConfig(id string, cfg any) ([]byte, error) { plain, err := json.Marshal(cfg) if err != nil { return nil, fmt.Errorf("marshal config: %w", err) } enc, err := s.deps.AEAD.Encrypt(plain, []byte("notification-channel:"+id)) if err != nil { return nil, fmt.Errorf("encrypt config: %w", err) } return []byte(enc), nil } // decryptChannelConfig decrypts the AEAD blob and unmarshals it into dst. func (s *Server) decryptChannelConfig(ch store.NotificationChannel, dst any) error { plain, err := s.deps.AEAD.Decrypt(string(ch.Config), []byte("notification-channel:"+ch.ID)) if err != nil { return fmt.Errorf("decrypt: %w", err) } return json.Unmarshal(plain, dst) } // firstNonEmpty returns the first non-empty (after TrimSpace) value in // vals, or "". Used for fields like `name` that appear once per per-kind // sub-form: only the visible kind's input is filled in, so PostForm.Get // (which returns the first regardless of emptiness) would lose the // actual value when the user edits the second or third kind. func firstNonEmpty(vals []string) string { for _, v := range vals { if strings.TrimSpace(v) != "" { return v } } return "" } // formHasValue reports whether vals contains want. Used for hidden+checkbox // pairs (e.g. + ) // where r.PostForm.Get returns the first ("0") even when the checkbox is // ticked, so we have to scan the slice instead. func formHasValue(vals []string, want string) bool { for _, v := range vals { if v == want { return true } } return false } // formFromRequest parses the common + per-kind fields from a POST form. // The caller must have already called r.ParseForm(). func formFromRequest(r *stdhttp.Request) *notificationForm { f := ¬ificationForm{ Kind: strings.TrimSpace(r.PostForm.Get("kind")), Name: strings.TrimSpace(firstNonEmpty(r.PostForm["name"])), Enabled: formHasValue(r.PostForm["enabled"], "1"), DefaultPriority: strings.TrimSpace(r.PostForm.Get("default_priority")), WebhookURL: strings.TrimSpace(r.PostForm.Get("webhook_url")), WebhookBearerToken: r.PostForm.Get("webhook_bearer_token"), WebhookHeaderName: strings.TrimSpace(r.PostForm.Get("webhook_header_name")), WebhookHeaderValue: r.PostForm.Get("webhook_header_value"), NtfyServerURL: strings.TrimSpace(r.PostForm.Get("ntfy_server_url")), NtfyTopic: strings.TrimSpace(r.PostForm.Get("ntfy_topic")), NtfyAccessToken: r.PostForm.Get("ntfy_access_token"), SMTPHost: strings.TrimSpace(r.PostForm.Get("smtp_host")), SMTPPort: strings.TrimSpace(r.PostForm.Get("smtp_port")), SMTPEncryption: strings.TrimSpace(r.PostForm.Get("smtp_encryption")), SMTPUsername: strings.TrimSpace(r.PostForm.Get("smtp_username")), SMTPPassword: r.PostForm.Get("smtp_password"), SMTPFrom: strings.TrimSpace(r.PostForm.Get("smtp_from")), SMTPTo: strings.TrimSpace(r.PostForm.Get("smtp_to")), } if f.Kind == "" { f.Kind = "webhook" } return f } // validateForm validates the common + per-kind fields. Returns a // non-empty string on the first validation error found. func validateForm(f *notificationForm) string { if f.Name == "" { return "Name is required." } if len(f.Name) > 100 { return "Name must be 100 characters or fewer." } switch f.Kind { case "webhook": if f.WebhookURL == "" { return "Webhook URL is required." } u, err := url.Parse(f.WebhookURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { return "Webhook URL must be a valid http(s) URL." } case "ntfy": if f.NtfyServerURL != "" { u, err := url.Parse(f.NtfyServerURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { return "Ntfy server URL must be a valid http(s) URL." } } if f.NtfyTopic == "" { return "Ntfy topic is required." } case "smtp": if f.SMTPHost == "" { return "SMTP host is required." } port, err := strconv.Atoi(f.SMTPPort) if err != nil || port < 1 || port > 65535 { return "SMTP port must be a number between 1 and 65535." } switch f.SMTPEncryption { case "starttls", "tls", "none": default: return "SMTP encryption must be starttls, tls, or none." } if f.SMTPFrom == "" { return "SMTP From address is required." } if _, err := mail.ParseAddress(f.SMTPFrom); err != nil { return "SMTP From is not a valid email address." } if f.SMTPTo == "" { return "SMTP To address is required." } if _, err := mail.ParseAddress(f.SMTPTo); err != nil { return "SMTP To is not a valid email address." } default: return "Kind must be webhook, ntfy, or smtp." } return "" } // buildConfig constructs the per-kind notification config struct from f. // For edit (existing != nil), blank password fields fall back to the // stored value so the operator can save other fields without re-typing // the credential. func buildConfig(f *notificationForm, existing any) (any, error) { switch f.Kind { case "webhook": cfg := notification.WebhookConfig{ URL: f.WebhookURL, BearerToken: f.WebhookBearerToken, HeaderName: f.WebhookHeaderName, HeaderValue: f.WebhookHeaderValue, } if existing != nil { ex, ok := existing.(*notification.WebhookConfig) if ok && cfg.BearerToken == "" { cfg.BearerToken = ex.BearerToken } } return cfg, nil case "ntfy": cfg := notification.NtfyConfig{ ServerURL: f.NtfyServerURL, Topic: f.NtfyTopic, AccessToken: f.NtfyAccessToken, } if existing != nil { ex, ok := existing.(*notification.NtfyConfig) if ok && cfg.AccessToken == "" { cfg.AccessToken = ex.AccessToken } } return cfg, nil case "smtp": port, _ := strconv.Atoi(f.SMTPPort) cfg := notification.SMTPConfig{ Host: f.SMTPHost, Port: port, Encryption: f.SMTPEncryption, Username: f.SMTPUsername, Password: f.SMTPPassword, From: f.SMTPFrom, To: f.SMTPTo, } if existing != nil { ex, ok := existing.(*notification.SMTPConfig) if ok && cfg.Password == "" { cfg.Password = ex.Password } } return cfg, nil } return nil, fmt.Errorf("unknown kind %q", f.Kind) } // ── UI handlers ─────────────────────────────────────────────────────────────── // handleUISettings renders the settings shell (defaults to the // Notifications sub-tab in v1). func (s *Server) handleUISettings(w stdhttp.ResponseWriter, r *stdhttp.Request) { s.handleUINotificationsList(w, r) } // handleUINotificationsList renders the channel list under the // Notifications sub-tab. func (s *Server) handleUINotificationsList(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } page, err := s.loadSettingsPage(r) if err != nil { slog.Error("ui settings: load", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } s.renderSettingsPage(w, r, u, page, 0) } // handleUINotificationNewGet renders the kind picker + empty form. // The ?kind= query param pre-selects the visible per-kind fields. func (s *Server) handleUINotificationNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } page, err := s.loadSettingsPage(r) if err != nil { slog.Error("ui settings: load", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } kind := r.URL.Query().Get("kind") if kind == "" { kind = "webhook" } page.Form = ¬ificationForm{Kind: kind} s.renderSettingsPage(w, r, u, page, 0) } // handleUINotificationNewPost validates and creates a new channel, then // redirects to the list. Re-renders the form with an error banner on // validation failure. func (s *Server) handleUINotificationNewPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } if err := r.ParseForm(); err != nil { stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) return } f := formFromRequest(r) if errMsg := validateForm(f); errMsg != "" { page, _ := s.loadSettingsPage(r) if page == nil { page = &settingsPage{ActiveTab: "notifications"} } page.Form = f page.FormError = errMsg s.renderSettingsPage(w, r, u, page, stdhttp.StatusUnprocessableEntity) return } id := ulid.Make().String() cfg, err := buildConfig(f, nil) if err != nil { stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } enc, err := s.encryptChannelConfig(id, cfg) if err != nil { slog.Error("ui notifications: encrypt", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } now := time.Now().UTC() var dp *string if f.DefaultPriority != "" { dp = &f.DefaultPriority } ch := store.NotificationChannel{ ID: id, Kind: f.Kind, Name: f.Name, Enabled: f.Enabled, Config: enc, DefaultPriority: dp, CreatedAt: now, UpdatedAt: now, } if err := s.deps.Store.CreateNotificationChannel(r.Context(), ch); err != nil { slog.Error("ui notifications: create", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", Action: "notification_channel.created", TargetKind: ptr("notification_channel"), TargetID: &id, TS: now, }) stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther) } // handleUINotificationEditGet fetches a channel, decrypts its config, // and renders the edit form with values pre-filled. func (s *Server) handleUINotificationEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } channelID := chi.URLParam(r, "id") ch, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID) if err != nil { if errors.Is(err, store.ErrNotFound) { stdhttp.NotFound(w, r) return } slog.Error("ui notifications: get", "id", channelID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } f := ¬ificationForm{ ID: ch.ID, Kind: ch.Kind, Name: ch.Name, Enabled: ch.Enabled, } if ch.DefaultPriority != nil { f.DefaultPriority = *ch.DefaultPriority } switch ch.Kind { case "webhook": var cfg notification.WebhookConfig if err := s.decryptChannelConfig(*ch, &cfg); err == nil { f.WebhookURL = cfg.URL // BearerToken and custom headers: don't echo plaintext — shown // via placeholder text in the template. f.WebhookHeaderName = cfg.HeaderName // HeaderValue and BearerToken are write-only — left blank // so the placeholder "stored, leave blank to keep" shows. } case "ntfy": var cfg notification.NtfyConfig if err := s.decryptChannelConfig(*ch, &cfg); err == nil { f.NtfyServerURL = cfg.ServerURL f.NtfyTopic = cfg.Topic // AccessToken is write-only. } case "smtp": var cfg notification.SMTPConfig if err := s.decryptChannelConfig(*ch, &cfg); err == nil { f.SMTPHost = cfg.Host f.SMTPPort = strconv.Itoa(cfg.Port) f.SMTPEncryption = cfg.Encryption f.SMTPUsername = cfg.Username // Password is write-only — left blank. f.SMTPFrom = cfg.From f.SMTPTo = cfg.To } } page, err := s.loadSettingsPage(r) if err != nil { slog.Error("ui settings: load", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } page.Form = f s.renderSettingsPage(w, r, u, page, 0) } // handleUINotificationEditPost validates the edit form, merges new // values onto the existing config (preserving blanked-out secrets), // re-encrypts, and updates the channel row. func (s *Server) handleUINotificationEditPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } channelID := chi.URLParam(r, "id") ch, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID) if err != nil { if errors.Is(err, store.ErrNotFound) { stdhttp.NotFound(w, r) return } slog.Error("ui notifications: get for edit", "id", channelID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } if err := r.ParseForm(); err != nil { stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) return } f := formFromRequest(r) f.ID = ch.ID if errMsg := validateForm(f); errMsg != "" { page, _ := s.loadSettingsPage(r) if page == nil { page = &settingsPage{ActiveTab: "notifications"} } page.Form = f page.FormError = errMsg s.renderSettingsPage(w, r, u, page, stdhttp.StatusUnprocessableEntity) return } // Decrypt existing config so blank password fields can fall back // to the stored values. var existingCfg any switch ch.Kind { case "webhook": var cfg notification.WebhookConfig if derr := s.decryptChannelConfig(*ch, &cfg); derr == nil { existingCfg = &cfg } case "ntfy": var cfg notification.NtfyConfig if derr := s.decryptChannelConfig(*ch, &cfg); derr == nil { existingCfg = &cfg } case "smtp": var cfg notification.SMTPConfig if derr := s.decryptChannelConfig(*ch, &cfg); derr == nil { existingCfg = &cfg } } newCfg, err := buildConfig(f, existingCfg) if err != nil { stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } enc, err := s.encryptChannelConfig(ch.ID, newCfg) if err != nil { slog.Error("ui notifications: re-encrypt", "id", ch.ID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } now := time.Now().UTC() var dp *string if f.DefaultPriority != "" { dp = &f.DefaultPriority } updated := store.NotificationChannel{ ID: ch.ID, Kind: f.Kind, Name: f.Name, Enabled: f.Enabled, Config: enc, DefaultPriority: dp, CreatedAt: ch.CreatedAt, UpdatedAt: now, } if err := s.deps.Store.UpdateNotificationChannel(r.Context(), updated); err != nil { slog.Error("ui notifications: update", "id", ch.ID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", Action: "notification_channel.updated", TargetKind: ptr("notification_channel"), TargetID: &ch.ID, TS: now, }) stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther) } // handleUINotificationDelete implements the typed-confirm pattern: // the operator must type the channel name to proceed. On match, // DeleteNotificationChannel + audit row + redirect. On mismatch, // re-render with an error. func (s *Server) handleUINotificationDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } channelID := chi.URLParam(r, "id") ch, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID) if err != nil { if errors.Is(err, store.ErrNotFound) { stdhttp.NotFound(w, r) return } slog.Error("ui notifications: get for delete", "id", channelID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } if err := r.ParseForm(); err != nil { stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) return } confirm := strings.TrimSpace(r.PostForm.Get("confirm_name")) if confirm != ch.Name { page, _ := s.loadSettingsPage(r) if page == nil { page = &settingsPage{ActiveTab: "notifications"} } page.Form = ¬ificationForm{ID: ch.ID, Kind: ch.Kind, Name: ch.Name} page.DeleteError = "Typed name did not match — deletion aborted." s.renderSettingsPage(w, r, u, page, stdhttp.StatusUnprocessableEntity) return } if err := s.deps.Store.DeleteNotificationChannel(r.Context(), ch.ID); err != nil { slog.Error("ui notifications: delete", "id", ch.ID, "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", Action: "notification_channel.deleted", TargetKind: ptr("notification_channel"), TargetID: &ch.ID, TS: time.Now().UTC(), }) stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther) } // ── API handler ─────────────────────────────────────────────────────────────── // testResultFragment is the JSON body returned by handleAPINotificationTest. type testResultFragment struct { OK bool `json:"ok"` LatencyMS int `json:"latency_ms"` StatusCode *int `json:"status_code,omitempty"` Error *string `json:"error,omitempty"` } // handleAPINotificationTest fires a single synthetic test payload // through the named channel via Hub.DispatchOne and returns a JSON // result. The test button in the UI posts here and renders the // green/red pill from the response. func (s *Server) handleAPINotificationTest(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } if s.deps.NotificationHub == nil { writeJSONError(w, stdhttp.StatusServiceUnavailable, "hub_not_ready", "notification hub not initialised") return } channelID := chi.URLParam(r, "id") if _, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID); err != nil { if errors.Is(err, store.ErrNotFound) { writeJSONError(w, stdhttp.StatusNotFound, "not_found", "channel not found") return } slog.Error("api: notification test: get channel", "id", channelID, "err", err) writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") return } // AlertID is intentionally left empty for test notifications: the // notification_log.alert_id column has a FK to alerts.id, and no // real alert exists for a synthetic test fire. The hub leaves the // column NULL when AlertID is empty. payload := notification.Payload{ Event: notification.EventTest, Severity: "info", Kind: "test_notification", HostName: "(test)", Message: "Test from restic-manager — channel is working.", RaisedAt: time.Now().UTC(), } entry, err := s.deps.NotificationHub.DispatchOne(r.Context(), channelID, payload) if err != nil { slog.Error("api: notification test: dispatch", "id", channelID, "err", err) errStr := err.Error() writeJSON(w, stdhttp.StatusOK, testResultFragment{ OK: false, Error: &errStr, }) return } res := testResultFragment{OK: entry.OK, StatusCode: entry.StatusCode} if entry.LatencyMS != nil { res.LatencyMS = *entry.LatencyMS } if entry.Error != nil { res.Error = entry.Error } writeJSON(w, stdhttp.StatusOK, res) }