package server import ( "encoding/json" "errors" "fmt" "net/http" "strings" "time" "github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/notify" ) // This file is the HTTP API for the notification delivery feature. All routes are // mounted behind requireAuth (see Handler()), consistent with the other admin APIs. // notifyChannelDTO is the external representation of a channel. // // Config is the **masked** configuration: credential fields are replaced with // values prefixed by notify.MaskedPrefix. The frontend submitting a masked value // back unchanged means "this field was not modified", and the server keeps the // original value in the database accordingly (see notify.MergeConfig). type notifyChannelDTO struct { ID int64 `json:"id"` Name string `json:"name"` Kind string `json:"kind"` Enabled bool `json:"enabled"` Mode string `json:"mode"` Config map[string]any `json:"config"` Filter notify.Filter `json:"filter"` RatePerMin int `json:"rate_per_min"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // SecretKeys tells the frontend which fields are credentials, so it can render // password inputs and a "leave empty to keep unchanged" hint. Declared by the // channel itself (notify.Channel.SecretKeys); the frontend hardcodes no // channel-specific knowledge. SecretKeys []string `json:"secret_keys"` } // notifyDeliveryDTO is the external representation of a delivery history entry. type notifyDeliveryDTO struct { ID int64 `json:"id"` FindingID int64 `json:"finding_id,string"` EventKind string `json:"event_kind"` ChannelID int64 `json:"channel_id"` ChannelName string `json:"channel_name"` ChannelKind string `json:"channel_kind"` State string `json:"state"` Attempts int `json:"attempts"` LastError string `json:"last_error"` BatchID *int64 `json:"batch_id,omitempty"` CreatedAt time.Time `json:"created_at"` SentAt *time.Time `json:"sent_at,omitempty"` NextAttempt time.Time `json:"next_attempt_at"` // Message title digest, so the history list can show what this delivery is // about without expanding it. Title string `json:"title"` Severity string `json:"severity"` } func toNotifyChannelDTO(ch *db.NotificationChannel) notifyChannelDTO { var cfg map[string]any if len(ch.Config) > 0 { _ = json.Unmarshal(ch.Config, &cfg) } if cfg == nil { cfg = map[string]any{} } secrets := []string{} if c, ok := notify.Get(ch.Kind); ok { secrets = c.SecretKeys() } return notifyChannelDTO{ ID: ch.ID, Name: ch.Name, Kind: ch.Kind, Enabled: ch.IsEnabled(), Mode: ch.Mode, Config: notify.MaskConfig(ch.Kind, cfg), Filter: notify.ParseFilter(ch.Filter), RatePerMin: ch.RatePerMin, CreatedAt: ch.CreatedAt, UpdatedAt: ch.UpdatedAt, SecretKeys: secrets, } } func toNotifyDeliveryDTO(dl *db.NotificationDelivery) notifyDeliveryDTO { snap, _ := parseSnapshot(dl) dto := notifyDeliveryDTO{ ID: dl.ID, FindingID: dl.FindingID, EventKind: dl.EventKind, ChannelID: dl.ChannelID, ChannelName: dl.ChannelName, ChannelKind: dl.ChannelKind, State: dl.State, Attempts: dl.Attempts, LastError: dl.LastError, BatchID: dl.BatchID, CreatedAt: dl.CreatedAt, SentAt: dl.SentAt, NextAttempt: dl.NextAttemptAt, Severity: snap.Severity, } if snap.Name != "" { dto.Title = snap.Name } else { dto.Title = snap.VulnClass } return dto } // notifyMeta returns the static metadata and global settings the notification // page needs, all in one request, so the frontend does not have to fire three // requests just to render a dropdown. func (s *Server) notifyMeta(w http.ResponseWriter, r *http.Request) { pg := s.pg(w) if pg == nil { return } kinds := make([]map[string]any, 0, len(notify.Kinds())) for _, k := range notify.Kinds() { ch, _ := notify.Get(k) kinds = append(kinds, map[string]any{ "kind": k, "default_rate_per_min": ch.DefaultRatePerMin(), "secret_keys": ch.SecretKeys(), }) } baseURL, _, _ := pg.GetSetting(settingNotifyPublicBaseURL) digest, _, _ := pg.GetSetting(settingNotifyDigestMinutes) stats, err := pg.NotificationStatsSnapshot(r.Context()) if err != nil { writeErr(w, 500, err.Error()) return } writeJSON(w, 200, map[string]any{ "kinds": kinds, "enabled": pg.GetBool(settingNotifyEnabled, true), "public_base_url": baseURL, "digest_interval_min": digest, "defaults": map[string]any{ "digest_interval_min": notifyDefaultDigestMinutes, }, "stats": stats, }) } func (s *Server) notifyListChannels(w http.ResponseWriter, r *http.Request) { pg := s.pg(w) if pg == nil { return } channels, err := pg.ListNotificationChannels(r.Context()) if err != nil { writeErr(w, 500, err.Error()) return } out := make([]notifyChannelDTO, 0, len(channels)) for _, ch := range channels { out = append(out, toNotifyChannelDTO(ch)) } writeJSON(w, 200, map[string]any{"channels": out}) } // notifyChannelRequest is the request body for creating/updating a channel. // // All business fields are pointers, so that "not sent" can be told apart from // "sent as zero value": under PATCH semantics, fields that were not sent must // keep their original values in the database. type notifyChannelRequest struct { Name *string `json:"name"` Kind *string `json:"kind"` Enabled *bool `json:"enabled"` Mode *string `json:"mode"` Config map[string]any `json:"config"` Filter *notify.Filter `json:"filter"` RatePerMin *int `json:"rate_per_min"` } func (s *Server) notifyCreateChannel(w http.ResponseWriter, r *http.Request) { pg := s.pg(w) if pg == nil { return } var req notifyChannelRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, 400, "request body is not valid JSON: "+err.Error()) return } if req.Kind == nil || !notify.ValidKind(*req.Kind) { writeErr(w, 400, fmt.Sprintf("invalid channel type, available: %s", strings.Join(notify.Kinds(), " / "))) return } name := "" if req.Name != nil { name = strings.TrimSpace(*req.Name) } if name == "" { writeErr(w, 400, "channel name is required") return } channel, _ := notify.Get(*req.Kind) if err := channel.Validate(req.Config); err != nil { writeErr(w, 400, err.Error()) return } ch := &db.NotificationChannel{ Name: name, Kind: *req.Kind, Enabled: req.Enabled, Mode: db.NotifyModeRealtime, RatePerMin: channel.DefaultRatePerMin(), } if req.Mode != nil { if !db.ValidNotifyMode(*req.Mode) { writeErr(w, 400, "invalid delivery mode, available: realtime / digest") return } ch.Mode = *req.Mode } if req.RatePerMin != nil { // Use the explicitly given value as-is — including 0, which means // "no rate limit" and is a valid configuration. if *req.RatePerMin < 0 { writeErr(w, 400, "rate limit must not be negative") return } ch.RatePerMin = *req.RatePerMin } // Only apply the channel default when the field is absent. The default must be // decided here rather than in the db layer: only the request body can // distinguish "this field was not sent" from "explicitly sent 0", and the two // mean entirely different things (former = use default, latter = no rate // limit). If the db layer also treated 0 as unspecified, the no-rate-limit // configuration would be unreachable. if req.RatePerMin == nil { ch.RatePerMin = channel.DefaultRatePerMin() } if req.Filter != nil { // Validate restricted-value filter fields (e.g. min_severity) on write. See // notify.Filter.Validate for details: a typo in the threshold would // silently disable the filter into delivering everything, so it must be // blocked at the entry point. if err := req.Filter.Validate(); err != nil { writeErr(w, 400, err.Error()) return } raw, _ := json.Marshal(req.Filter) ch.Filter = raw } rawCfg, _ := json.Marshal(req.Config) ch.Config = rawCfg id, err := pg.SaveNotificationChannel(r.Context(), ch) if err != nil { writeErr(w, 500, err.Error()) return } writeJSON(w, 200, map[string]any{"id": id}) } func (s *Server) notifyUpdateChannel(w http.ResponseWriter, r *http.Request) { pg := s.pg(w) if pg == nil { return } id, ok := pathInt(r, "id") if !ok { writeErr(w, 400, "invalid channel id") return } current, err := pg.NotificationChannelByID(r.Context(), id) if err != nil { notifyChannelLookupErr(w, err) return } var req notifyChannelRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, 400, "request body is not valid JSON: "+err.Error()) return } // kind may be changed, but changing the type means the whole set of credential // fields is replaced and cannot be merged with the old config. kind := current.Kind if req.Kind != nil { if !notify.ValidKind(*req.Kind) { writeErr(w, 400, fmt.Sprintf("invalid channel type, available: %s", strings.Join(notify.Kinds(), " / "))) return } kind = *req.Kind } channel, _ := notify.Get(kind) var stored map[string]any if kind == current.Kind { if len(current.Config) > 0 { _ = json.Unmarshal(current.Config, &stored) } } if stored == nil { stored = map[string]any{} } // Use PrepareConfigUpdate rather than bare MergeConfig: when the target address // changes, the operator must re-affirm the credential fields, otherwise // "change the address only, keep the credentials" would send the real // credentials from the database to the new address. merged, err := notify.PrepareConfigUpdate(kind, stored, req.Config) if err != nil { writeErr(w, 400, err.Error()) return } if err := channel.Validate(merged); err != nil { writeErr(w, 400, err.Error()) return } rawCfg, _ := json.Marshal(merged) ch := &db.NotificationChannel{ ID: id, Name: current.Name, Kind: kind, Enabled: current.Enabled, Mode: current.Mode, Config: rawCfg, Filter: current.Filter, RatePerMin: current.RatePerMin, } if req.Name != nil { if ch.Name = strings.TrimSpace(*req.Name); ch.Name == "" { writeErr(w, 400, "channel name must not be empty") return } } if req.Enabled != nil { ch.Enabled = req.Enabled } if req.Mode != nil { if !db.ValidNotifyMode(*req.Mode) { writeErr(w, 400, "invalid delivery mode, available: realtime / digest") return } ch.Mode = *req.Mode } if req.RatePerMin != nil { if *req.RatePerMin < 0 { writeErr(w, 400, "rate limit must not be negative") return } ch.RatePerMin = *req.RatePerMin } if req.Filter != nil { if err := req.Filter.Validate(); err != nil { writeErr(w, 400, err.Error()) return } raw, _ := json.Marshal(req.Filter) ch.Filter = raw } // Going through SetNotificationChannelEnabled instead of the plain // SaveNotificationChannel path ensures that "disabling" also marks pending // deliveries as skipped, so re-enabling does not deliver a batch of stale, // backlogged messages. enabledChanged := ch.Enabled != nil && current.Enabled != nil && *ch.Enabled != *current.Enabled if enabledChanged { // Persist the config update first (with enabled kept at the old value, so // the skip logic is not triggered prematurely), then flip the switch // separately. There is no concurrency window between the two steps: this // endpoint is the only entry point that modifies these two fields. prev := ch.Enabled ch.Enabled = current.Enabled if _, err := pg.SaveNotificationChannel(r.Context(), ch); err != nil { writeErr(w, 500, err.Error()) return } if err := pg.SetNotificationChannelEnabled(r.Context(), id, *prev); err != nil { writeErr(w, 500, err.Error()) return } writeJSON(w, 200, map[string]any{"id": id}) return } if _, err := pg.SaveNotificationChannel(r.Context(), ch); err != nil { writeErr(w, 500, err.Error()) return } writeJSON(w, 200, map[string]any{"id": id}) } func (s *Server) notifyDeleteChannel(w http.ResponseWriter, r *http.Request) { pg := s.pg(w) if pg == nil { return } id, ok := pathInt(r, "id") if !ok { writeErr(w, 400, "invalid channel id") return } if err := pg.DeleteNotificationChannel(r.Context(), id); err != nil { notifyChannelLookupErr(w, err) return } writeJSON(w, 200, map[string]any{"ok": true}) } // notifyTestChannel sends one test message with the currently saved // configuration. // // It calls the channel's Send directly, bypassing the delivery queue: the point // of the test is to tell the user immediately "whether this configuration can // deliver"; going through the queue would hide the result inside the delivery // history, forcing the user to dig it up to know whether it worked. This // endpoint is therefore **synchronous**, with the timeout bounded by the notify // package's HTTP client (15 seconds). func (s *Server) notifyTestChannel(w http.ResponseWriter, r *http.Request) { pg := s.pg(w) if pg == nil { return } id, ok := pathInt(r, "id") if !ok { writeErr(w, 400, "invalid channel id") return } ch, err := pg.NotificationChannelByID(r.Context(), id) if err != nil { notifyChannelLookupErr(w, err) return } channel, ok := notify.Get(ch.Kind) if !ok { writeErr(w, 400, fmt.Sprintf("channel type %q is not registered", ch.Kind)) return } var cfg map[string]any if len(ch.Config) > 0 { _ = json.Unmarshal(ch.Config, &cfg) } if err := channel.Validate(cfg); err != nil { writeErr(w, 400, err.Error()) return } msg := notifyTestMessage(s.notifierBaseURL(pg)) start := time.Now() // The test message is a single entry, so the delivered count is not needed // here (for a single message the channel length limit falls back to // truncation; no segmentation is involved). if _, err := channel.Send(r.Context(), cfg, msg); err != nil { // Return the channel's raw error to the user as-is — it is their only clue // when debugging the configuration. writeErr(w, 502, err.Error()) return } writeJSON(w, 200, map[string]any{ "ok": true, "latency_ms": time.Since(start).Milliseconds(), }) } // notifyTestMessage builds the test message. Its content is deliberately // recognizable as a test at a glance: recipients should not mistake it for a // real vulnerability. func notifyTestMessage(baseURL string) notify.Message { return notify.Message{ Items: []notify.Item{{ FindingID: 0, Name: "Test message · channel configuration OK", VulnClass: "Connectivity test", Severity: "low", Summary: "This is a test message for an ARTEX notification channel; receiving it means the channel is configured correctly.", Assets: []string{"artex.example.com"}, DetailURL: baseURL, }}, HomeURL: baseURL, } } // notifierBaseURL reads the external address used for back-links. func (s *Server) notifierBaseURL(pg *db.DB) string { v, _, _ := pg.GetSetting(settingNotifyPublicBaseURL) return trimTrailingSlash(v) } func (s *Server) notifyListDeliveries(w http.ResponseWriter, r *http.Request) { pg := s.pg(w) if pg == nil { return } f := db.NotificationDeliveryFilter{ State: r.URL.Query().Get("state"), EventKind: r.URL.Query().Get("event_kind"), } if v := r.URL.Query().Get("channel_id"); v != "" { f.ChannelID = int64(atoiDefault(v, 0)) } page := queryInt(r, "page", 1) pageSize := queryInt(r, "page_size", 50) items, total, err := pg.ListNotificationDeliveries(r.Context(), f, page, pageSize) if err != nil { writeErr(w, 500, err.Error()) return } out := make([]notifyDeliveryDTO, 0, len(items)) for _, dl := range items { out = append(out, toNotifyDeliveryDTO(dl)) } writeJSON(w, 200, map[string]any{"deliveries": out, "total": total, "page": page, "page_size": pageSize}) } func (s *Server) notifyRetryDelivery(w http.ResponseWriter, r *http.Request) { pg := s.pg(w) if pg == nil { return } id, ok := pathInt(r, "id") if !ok { writeErr(w, 400, "invalid delivery id") return } if err := pg.RetryNotificationDelivery(r.Context(), id); err != nil { writeErr(w, 400, err.Error()) return } writeJSON(w, 200, map[string]any{"ok": true}) } // notifyChannelLookupErr translates "channel does not exist" into 404, all other // errors into 500. func notifyChannelLookupErr(w http.ResponseWriter, err error) { if errors.Is(err, db.ErrNotificationChannelNotFound) { writeErr(w, 404, "notification channel does not exist") return } writeErr(w, 500, err.Error()) }