mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
The IM renderers cut the summary of a single push at 120 characters and listed at most 3 assets, as fixed constants. Both are now global settings in the settings table, read by the delivery engine when it renders: notify_summary_max_runes 0-4000, default 120 notify_assets_max_shown 0-50, default 3 0 means no truncation and an unset key keeps 120 / 3. PUT /api/settings answers 400 for a value outside the bounds. The limits travel on notify.Message, so the notify package still does not touch the database. With no truncation, nothing bounded a single item in a Feishu card, and Feishu rejects an oversized request as a whole. feishuCard now truncates an item's text as the final backstop, the role TruncateBytes plays in markdownBody for DingTalk and WeCom. feishuMaxCardBytes goes from 24000 to 16000: Feishu custom bots cap the request body at 20 KB, which the old value exceeded. Refs #168.
544 lines
16 KiB
Go
544 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))
|
|
limits := notifyLimits(pg)
|
|
msg.Limits = &limits
|
|
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())
|
|
}
|