Files
ARTEX/server/notify_api.go
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

542 lines
16 KiB
Go

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())
}