84e121bb9c
CI / Build (windows/amd64) (pull_request) Successful in 22s
CI / Lint (pull_request) Successful in 38s
CI / Build (linux/amd64) (pull_request) Successful in 21s
CI / Build (linux/arm64) (pull_request) Successful in 22s
CI / Test (linux/amd64) (pull_request) Successful in 2m39s
The channel form has three inputs all named 'name' (one per kind
section: webhook / ntfy / smtp), but only the visible kind's input
is filled in. PostForm.Get returns the first regardless of
emptiness, so editing an ntfy or smtp channel always read '' from
the (hidden, unfilled) webhook section's name input and rejected
with 'name required'.
Add firstNonEmpty helper that scans the slice for the first
non-blank value. Same flavour of bug as the enabled checkbox fix
in 6466f8c — both fall out of having multiple inputs share a name
across the per-kind sub-forms.
732 lines
23 KiB
Go
732 lines
23 KiB
Go
// 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. <input hidden name=x value=0> + <input checkbox name=x value=1>)
|
|
// 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)
|
|
}
|