Files
ARTEX/notify/channel.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

215 lines
6.8 KiB
Go

package notify
import (
"context"
"errors"
"sort"
"strconv"
"strings"
)
// Channel is the adapter for one notification channel. Implementations must be
// **stateless**: the same instance is shared concurrently by multiple channel
// configurations, and credentials are always passed in via the cfg parameter.
type Channel interface {
// Kind returns the channel type identifier; it must match the registry key.
Kind() string
// Validate is called when saving a configuration and checks required fields and
// formats. The returned error is shown directly to the configurator, so the
// message must say "which field is missing" rather than a vague "invalid config".
Validate(cfg map[string]any) error
// Send delivers one message and returns the **number of items actually
// delivered** plus an error.
//
// Why return a count: every platform has a message length limit, and an
// aggregated message that cannot hold the whole batch gets truncated. If the
// caller unconditionally marked the whole batch as delivered, the truncated
// items would vanish — invisible in the message and shown as succeeded in the
// delivery history, with no way to discover the findings were never sent. By
// returning kept, the caller marks only the first kept items and leaves the
// rest for the next batch.
//
// A returned error means delivery failed; *PermanentError means do not retry.
// On failure kept is meaningless and the caller should ignore it.
Send(ctx context.Context, cfg map[string]any, m Message) (int, error)
// DefaultRatePerMin returns the channel's officially recommended per-minute
// delivery cap, used as the default rate limit when a channel instance is
// created. 0 means no known limit.
DefaultRatePerMin() int
// SecretKeys returns the config keys that are credentials. The API masks these
// values on readback, and a masked value on update keeps the stored original.
// Only the implementation itself knows which fields are credentials (for WeCom
// the entire Webhook address is a credential, while for DingTalk only the
// secret inside it is), so this knowledge must come from the channel, not be
// guessed by upper layers.
SecretKeys() []string
// DestinationKeys returns the config keys that decide "where messages go".
//
// Like SecretKeys this is security-relevant: the destination address and the
// credentials are two independent sets of fields. If "change only the address,
// keep the credentials as-is" were allowed, anyone who can edit the channel
// config could send the stored real credentials to a server they control, and
// the config masking would be pointless.
// See PrepareConfigUpdate.
DestinationKeys() []string
}
// registry is the channel registry. Deliberately an explicit literal instead of
// init() self-registration: "which channels exist" is visible in one place, and a
// newly added channel surfaces omissions at compile time rather than via runtime
// side effects.
var registry = map[string]Channel{
KindDingTalk: dingTalkChannel{},
KindFeishu: feishuChannel{},
KindWeCom: weComChannel{},
KindWebhook: webhookChannel{},
KindTelegram: telegramChannel{},
KindEmail: emailChannel{},
}
// Get returns the channel implementation for a type.
func Get(kind string) (Channel, bool) {
c, ok := registry[kind]
return c, ok
}
// ValidKind reports whether kind is a supported channel type.
func ValidKind(kind string) bool {
_, ok := registry[kind]
return ok
}
// Kinds returns all supported channel types in lexicographic order (for a stable UI dropdown).
func Kinds() []string {
out := make([]string, 0, len(registry))
for k := range registry {
out = append(out, k)
}
sort.Strings(out)
return out
}
// PermanentError marks a delivery failure that must not be retried: bad
// credentials, destination refusal, invalid request body, etc. Retries only make
// sense for transient failures (network blips, rate limiting, peer 5xx); backing
// off and retrying a permanent failure never succeeds and buries the real error
// in retry logs.
type PermanentError struct{ Err error }
func (e *PermanentError) Error() string { return e.Err.Error() }
func (e *PermanentError) Unwrap() error { return e.Err }
// Permanent marks err as a permanent failure. A nil err returns nil,
// so it can be written as `return Permanent(someCheck())`.
func Permanent(err error) error {
if err == nil {
return nil
}
return &PermanentError{Err: err}
}
// IsPermanent reports whether err's chain carries the permanent-failure marker.
func IsPermanent(err error) bool {
var pe *PermanentError
return errors.As(err, &pe)
}
// ---- config-reading helpers ----
//
// Channel configs come from a JSONB database column; after encoding/json
// deserialization they are map[string]any, with all numbers as float64 and arrays
// as []any. The helpers below unify that conversion and tolerate type deviations
// from users leaving fields empty in the UI (e.g. entering the port as a string).
// cfgString reads a string config item, always trimming surrounding whitespace —
// easy to pick up when copy-pasting from a web form.
func cfgString(cfg map[string]any, key string) string {
v, ok := cfg[key]
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return strings.TrimSpace(s)
}
// cfgInt reads an integer config item, accepting both float64 (the JSON default)
// and string sources.
func cfgInt(cfg map[string]any, key string) int {
switch v := cfg[key].(type) {
case float64:
return int(v)
case int:
return v
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0
}
return n
default:
return 0
}
}
// cfgBool reads a boolean config item, also accepting the strings "true"/"1".
func cfgBool(cfg map[string]any, key string) bool {
switch v := cfg[key].(type) {
case bool:
return v
case string:
s := strings.ToLower(strings.TrimSpace(v))
return s == "true" || s == "1" || s == "yes"
default:
return false
}
}
// cfgStrings reads a string-array config item, trimming whitespace and dropping
// empty strings.
func cfgStrings(cfg map[string]any, key string) []string {
raw, ok := cfg[key].([]any)
if !ok {
// Also accept a single string, to simplify form submissions with one value.
if s := cfgString(cfg, key); s != "" {
return []string{s}
}
return nil
}
out := make([]string, 0, len(raw))
for _, v := range raw {
s, ok := v.(string)
if !ok {
continue
}
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
return out
}
// cfgMap reads a string-map config item (e.g. custom HTTP headers), trimming keys
// and dropping empty keys.
func cfgMap(cfg map[string]any, key string) map[string]string {
raw, ok := cfg[key].(map[string]any)
if !ok {
return nil
}
out := make(map[string]string, len(raw))
for k, v := range raw {
k = strings.TrimSpace(k)
if k == "" {
continue
}
s, ok := v.(string)
if !ok {
continue
}
out[k] = s
}
return out
}