Files
restic-manager/internal/server/http/ui_notifications.go
T
steve feaeff217d
CI / Build (windows/amd64) (pull_request) Successful in 22s
CI / Build (linux/amd64) (pull_request) Successful in 22s
CI / Build (linux/arm64) (pull_request) Successful in 21s
CI / Lint (pull_request) Successful in 1m12s
CI / Test (linux/amd64) (pull_request) Successful in 1m18s
feat(ntfy): support HTTP Basic auth alongside access tokens
Self-hosted ntfy that doesn't expose a token-mint endpoint can still
authenticate over HTTP Basic. Add Username + Password fields to
NtfyConfig; the channel sends 'Authorization: Basic …' when token is
empty and username is set. Token wins when both are configured.

Form-side: two new optional fields next to the access token, with
the same write-only placeholder treatment as smtp_password (blank
on edit means 'keep stored value'). Username is round-tripped on
edit; password is masked.
2026-05-04 22:25:42 +01:00

794 lines
26 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
NtfyUsername string
NtfyPassword 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 := &notificationForm{
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"),
NtfyUsername: strings.TrimSpace(r.PostForm.Get("ntfy_username")),
NtfyPassword: r.PostForm.Get("ntfy_password"),
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,
Username: f.NtfyUsername,
Password: f.NtfyPassword,
}
if existing != nil {
if ex, ok := existing.(*notification.NtfyConfig); ok {
// Blank password on edit means "keep stored value"
// — same write-only treatment as smtp_password.
if cfg.AccessToken == "" {
cfg.AccessToken = ex.AccessToken
}
if cfg.Password == "" {
cfg.Password = ex.Password
}
}
}
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 = &notificationForm{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 := &notificationForm{
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
f.NtfyUsername = cfg.Username
// AccessToken and Password are 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 = &notificationForm{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)
}
// handleUINotificationToggle flips the enabled flag for one channel
// and re-renders the row. Wired to the inline toggle in the channel
// list so operators don't need to enter the edit form just to flip a
// channel on or off. HTMX-aware: returns just the toggle fragment when
// the request carries HX-Request, otherwise redirects back to the list.
func (s *Server) handleUINotificationToggle(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 toggle", "id", channelID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
now := time.Now().UTC()
want := !ch.Enabled
if err := s.deps.Store.SetNotificationChannelEnabled(r.Context(), ch.ID, want, now); err != nil {
slog.Error("ui notifications: set enabled", "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.toggled",
TargetKind: ptr("notification_channel"),
TargetID: &ch.ID,
TS: now,
})
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if want {
_, _ = w.Write([]byte(`<span class="toggle on" hx-post="/settings/notifications/` + ch.ID + `/toggle" hx-target="this" hx-swap="outerHTML" onclick="event.stopPropagation()" style="cursor:pointer"></span>`))
} else {
_, _ = w.Write([]byte(`<span class="toggle" hx-post="/settings/notifications/` + ch.ID + `/toggle" hx-target="this" hx-swap="outerHTML" onclick="event.stopPropagation()" style="cursor:pointer"></span>`))
}
return
}
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)
}