310 lines
10 KiB
Go
310 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// 通知渠道配置键,前缀 notify_ch_<type>_;标注「密文」的键 AES-GCM 加密落库。
|
|
const (
|
|
settingChWebhookEnabled = "notify_ch_webhook_enabled" // "1"/"0"
|
|
settingChWebhookURL = "notify_ch_webhook_url"
|
|
settingChWebhookBodyTpl = "notify_ch_webhook_body_tpl"
|
|
settingChNtfyEnabled = "notify_ch_ntfy_enabled"
|
|
settingChNtfyServer = "notify_ch_ntfy_server"
|
|
settingChNtfyTopic = "notify_ch_ntfy_topic"
|
|
settingChNtfyToken = "notify_ch_ntfy_token" // 密文
|
|
settingChBarkEnabled = "notify_ch_bark_enabled"
|
|
settingChBarkServer = "notify_ch_bark_server"
|
|
settingChBarkDeviceKey = "notify_ch_bark_device_key" // 密文
|
|
settingChSMTPEnabled = "notify_ch_smtp_enabled"
|
|
settingChSMTPHost = "notify_ch_smtp_host"
|
|
settingChSMTPPort = "notify_ch_smtp_port"
|
|
settingChSMTPUsername = "notify_ch_smtp_username"
|
|
settingChSMTPPassword = "notify_ch_smtp_password" // 密文
|
|
settingChSMTPFrom = "notify_ch_smtp_from"
|
|
settingChSMTPTo = "notify_ch_smtp_to"
|
|
)
|
|
|
|
// ErrUnknownNotifyChannel 标记未知渠道类型,api 层映射 400。
|
|
var ErrUnknownNotifyChannel = fmt.Errorf("未知通知渠道类型")
|
|
|
|
// NotifyChannelView 是单渠道的脱敏视图:密文字段只回是否已配置与尾 4 位。
|
|
type NotifyChannelView struct {
|
|
Type string `json:"type"`
|
|
Enabled bool `json:"enabled"`
|
|
// webhook
|
|
URL string `json:"url,omitempty"`
|
|
BodyTemplate string `json:"bodyTemplate,omitempty"`
|
|
// ntfy / bark 共用 Server
|
|
Server string `json:"server,omitempty"`
|
|
Topic string `json:"topic,omitempty"`
|
|
// smtp
|
|
Host string `json:"host,omitempty"`
|
|
Port int `json:"port,omitempty"`
|
|
Username string `json:"username,omitempty"`
|
|
From string `json:"from,omitempty"`
|
|
To string `json:"to,omitempty"`
|
|
// 密文字段状态(ntfy token / bark device key / smtp password)
|
|
SecretSet bool `json:"secretSet"`
|
|
SecretTail string `json:"secretTail,omitempty"`
|
|
}
|
|
|
|
// UpdateNotifyChannelInput 是单渠道更新入参;Secret 为 nil 沿用已存密文,空串清除。
|
|
type UpdateNotifyChannelInput struct {
|
|
Enabled bool
|
|
URL string
|
|
BodyTemplate string
|
|
Server string
|
|
Topic string
|
|
Host string
|
|
Port int
|
|
Username string
|
|
From string
|
|
To string
|
|
Secret *string
|
|
}
|
|
|
|
// notifyChannelKeys 返回渠道的(启用键, 明文键列表, 密文键);未知类型返回启用键空串。
|
|
func notifyChannelKeys(chType string) (enabled string, plain []string, secret string) {
|
|
switch chType {
|
|
case ChannelWebhook:
|
|
return settingChWebhookEnabled, []string{settingChWebhookURL, settingChWebhookBodyTpl}, ""
|
|
case ChannelNtfy:
|
|
return settingChNtfyEnabled, []string{settingChNtfyServer, settingChNtfyTopic}, settingChNtfyToken
|
|
case ChannelBark:
|
|
return settingChBarkEnabled, []string{settingChBarkServer}, settingChBarkDeviceKey
|
|
case ChannelSMTP:
|
|
return settingChSMTPEnabled, []string{settingChSMTPHost, settingChSMTPPort,
|
|
settingChSMTPUsername, settingChSMTPFrom, settingChSMTPTo}, settingChSMTPPassword
|
|
}
|
|
return "", nil, ""
|
|
}
|
|
|
|
// NotifyChannels 返回全部新增渠道的脱敏视图(不含 telegram,其沿用独立接口)。
|
|
func (s *SettingService) NotifyChannels(ctx context.Context) ([]NotifyChannelView, error) {
|
|
out := make([]NotifyChannelView, 0, len(NotifyChannelTypes))
|
|
for _, t := range NotifyChannelTypes {
|
|
v, err := s.notifyChannelView(ctx, t)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// notifyChannelView 组装单渠道脱敏视图。
|
|
func (s *SettingService) notifyChannelView(ctx context.Context, chType string) (NotifyChannelView, error) {
|
|
enabledKey, plain, secretKey := notifyChannelKeys(chType)
|
|
vals, err := s.getMany(ctx, append(plain, enabledKey)...)
|
|
if err != nil {
|
|
return NotifyChannelView{}, err
|
|
}
|
|
view := NotifyChannelView{Type: chType, Enabled: vals[enabledKey] == "1"}
|
|
fillChannelView(&view, chType, vals)
|
|
if secretKey == "" {
|
|
return view, nil
|
|
}
|
|
secret, err := s.decryptSetting(ctx, secretKey)
|
|
if err != nil {
|
|
return NotifyChannelView{}, err
|
|
}
|
|
view.SecretSet = secret != ""
|
|
view.SecretTail = tokenTail(secret)
|
|
return view, nil
|
|
}
|
|
|
|
// fillChannelView 把明文键值填入视图对应字段。
|
|
func fillChannelView(v *NotifyChannelView, chType string, vals map[string]string) {
|
|
switch chType {
|
|
case ChannelWebhook:
|
|
v.URL, v.BodyTemplate = vals[settingChWebhookURL], vals[settingChWebhookBodyTpl]
|
|
case ChannelNtfy:
|
|
v.Server, v.Topic = vals[settingChNtfyServer], vals[settingChNtfyTopic]
|
|
case ChannelBark:
|
|
v.Server = vals[settingChBarkServer]
|
|
case ChannelSMTP:
|
|
v.Host, v.Username = vals[settingChSMTPHost], vals[settingChSMTPUsername]
|
|
v.From, v.To = vals[settingChSMTPFrom], vals[settingChSMTPTo]
|
|
v.Port, _ = strconv.Atoi(vals[settingChSMTPPort])
|
|
}
|
|
}
|
|
|
|
// UpdateNotifyChannel 保存单渠道配置;字段先经 validateChannelInput 校验。
|
|
func (s *SettingService) UpdateNotifyChannel(ctx context.Context, chType string, in UpdateNotifyChannelInput) error {
|
|
enabledKey, _, secretKey := notifyChannelKeys(chType)
|
|
if enabledKey == "" {
|
|
return ErrUnknownNotifyChannel
|
|
}
|
|
if err := validateChannelInput(chType, in); err != nil {
|
|
return err
|
|
}
|
|
pairs := channelPlainPairs(chType, in)
|
|
pairs[enabledKey] = boolSetting(in.Enabled)
|
|
for k, v := range pairs {
|
|
if err := s.set(ctx, k, v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if secretKey == "" || in.Secret == nil {
|
|
return nil
|
|
}
|
|
return s.encryptSetting(ctx, secretKey, *in.Secret)
|
|
}
|
|
|
|
// channelPlainPairs 返回渠道明文键与入参值的对应关系。
|
|
func channelPlainPairs(chType string, in UpdateNotifyChannelInput) map[string]string {
|
|
switch chType {
|
|
case ChannelWebhook:
|
|
return map[string]string{settingChWebhookURL: in.URL, settingChWebhookBodyTpl: in.BodyTemplate}
|
|
case ChannelNtfy:
|
|
return map[string]string{settingChNtfyServer: in.Server, settingChNtfyTopic: in.Topic}
|
|
case ChannelBark:
|
|
return map[string]string{settingChBarkServer: in.Server}
|
|
case ChannelSMTP:
|
|
return map[string]string{settingChSMTPHost: in.Host, settingChSMTPPort: strconv.Itoa(in.Port),
|
|
settingChSMTPUsername: in.Username, settingChSMTPFrom: in.From, settingChSMTPTo: in.To}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateChannelInput 按渠道校验入参;仅在启用时强校验必填,便于「先存草稿后启用」。
|
|
func validateChannelInput(chType string, in UpdateNotifyChannelInput) error {
|
|
if !in.Enabled {
|
|
return validateChannelURLs(chType, in)
|
|
}
|
|
switch chType {
|
|
case ChannelWebhook:
|
|
if strings.TrimSpace(in.URL) == "" {
|
|
return fmt.Errorf("启用 Webhook 须填写 URL")
|
|
}
|
|
case ChannelNtfy:
|
|
if strings.TrimSpace(in.Topic) == "" {
|
|
return fmt.Errorf("启用 ntfy 须填写 topic")
|
|
}
|
|
case ChannelSMTP:
|
|
if in.Host == "" || in.Port <= 0 || in.Port > 65535 || in.From == "" || in.To == "" {
|
|
return fmt.Errorf("启用 SMTP 须填写主机、端口(1-65535)、发件人与收件人")
|
|
}
|
|
}
|
|
return validateChannelURLs(chType, in)
|
|
}
|
|
|
|
// validateChannelURLs 校验渠道内的 URL 形字段(非空才校验)。
|
|
func validateChannelURLs(chType string, in UpdateNotifyChannelInput) error {
|
|
if chType == ChannelWebhook && in.URL != "" {
|
|
return validNotifyURL(in.URL)
|
|
}
|
|
if (chType == ChannelNtfy || chType == ChannelBark) && in.Server != "" {
|
|
return validNotifyURL(in.Server)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// boolSetting 把布尔序列化为设置值 "1"/"0"。
|
|
func boolSetting(b bool) string {
|
|
if b {
|
|
return "1"
|
|
}
|
|
return "0"
|
|
}
|
|
|
|
// encryptSetting 加密保存设置值;空串直接落空值表示清除。
|
|
func (s *SettingService) encryptSetting(ctx context.Context, key, value string) error {
|
|
if value == "" {
|
|
return s.set(ctx, key, "")
|
|
}
|
|
enc, err := s.cipher.EncryptString(value)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt setting %s: %w", key, err)
|
|
}
|
|
return s.set(ctx, key, enc)
|
|
}
|
|
|
|
// decryptSetting 读取并解密设置值;未配置返回空串。
|
|
func (s *SettingService) decryptSetting(ctx context.Context, key string) (string, error) {
|
|
enc, err := s.get(ctx, key)
|
|
if err != nil || enc == "" {
|
|
return "", err
|
|
}
|
|
val, err := s.cipher.DecryptString(enc)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decrypt setting %s: %w", key, err)
|
|
}
|
|
return val, nil
|
|
}
|
|
|
|
// webhookChannel 读取解密后的 Webhook 配置,供发送端消费。
|
|
func (s *SettingService) webhookChannel(ctx context.Context) (WebhookChannel, error) {
|
|
vals, err := s.getMany(ctx, settingChWebhookEnabled, settingChWebhookURL, settingChWebhookBodyTpl)
|
|
if err != nil {
|
|
return WebhookChannel{}, err
|
|
}
|
|
return WebhookChannel{
|
|
Enabled: vals[settingChWebhookEnabled] == "1",
|
|
URL: vals[settingChWebhookURL],
|
|
BodyTemplate: vals[settingChWebhookBodyTpl],
|
|
}, nil
|
|
}
|
|
|
|
// ntfyChannel 读取解密后的 ntfy 配置。
|
|
func (s *SettingService) ntfyChannel(ctx context.Context) (NtfyChannel, error) {
|
|
vals, err := s.getMany(ctx, settingChNtfyEnabled, settingChNtfyServer, settingChNtfyTopic)
|
|
if err != nil {
|
|
return NtfyChannel{}, err
|
|
}
|
|
token, err := s.decryptSetting(ctx, settingChNtfyToken)
|
|
if err != nil {
|
|
return NtfyChannel{}, err
|
|
}
|
|
return NtfyChannel{
|
|
Enabled: vals[settingChNtfyEnabled] == "1",
|
|
Server: vals[settingChNtfyServer],
|
|
Topic: vals[settingChNtfyTopic],
|
|
Token: token,
|
|
}, nil
|
|
}
|
|
|
|
// barkChannel 读取解密后的 Bark 配置。
|
|
func (s *SettingService) barkChannel(ctx context.Context) (BarkChannel, error) {
|
|
vals, err := s.getMany(ctx, settingChBarkEnabled, settingChBarkServer)
|
|
if err != nil {
|
|
return BarkChannel{}, err
|
|
}
|
|
key, err := s.decryptSetting(ctx, settingChBarkDeviceKey)
|
|
if err != nil {
|
|
return BarkChannel{}, err
|
|
}
|
|
return BarkChannel{
|
|
Enabled: vals[settingChBarkEnabled] == "1",
|
|
Server: vals[settingChBarkServer],
|
|
DeviceKey: key,
|
|
}, nil
|
|
}
|
|
|
|
// smtpChannel 读取解密后的 SMTP 配置。
|
|
func (s *SettingService) smtpChannel(ctx context.Context) (SMTPChannel, error) {
|
|
vals, err := s.getMany(ctx, settingChSMTPEnabled, settingChSMTPHost, settingChSMTPPort,
|
|
settingChSMTPUsername, settingChSMTPFrom, settingChSMTPTo)
|
|
if err != nil {
|
|
return SMTPChannel{}, err
|
|
}
|
|
password, err := s.decryptSetting(ctx, settingChSMTPPassword)
|
|
if err != nil {
|
|
return SMTPChannel{}, err
|
|
}
|
|
port, _ := strconv.Atoi(vals[settingChSMTPPort])
|
|
return SMTPChannel{
|
|
Enabled: vals[settingChSMTPEnabled] == "1",
|
|
Host: vals[settingChSMTPHost],
|
|
Port: port,
|
|
Username: vals[settingChSMTPUsername],
|
|
Password: password,
|
|
From: vals[settingChSMTPFrom],
|
|
To: vals[settingChSMTPTo],
|
|
}, nil
|
|
}
|