mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
对推送功能做了一轮安全审计,每条都先复现、再修、再补回归测试。 以下按严重程度排列。 【严重】掩码可被「改目标地址、保留凭据」绕过 目标地址与凭据是两套独立字段,而配置合并对「未提及的键」保留原值。于是 只改地址、对凭据避而不谈,服务端就会把库里的真凭据发到任意地址。已实测 复现四个渠道:通用 Webhook 的 Authorization 头、Telegram 的 Bot Token (进请求路径)、邮件的 SMTP 密码(STARTTLS 后交给对端)、钉钉/飞书的加签 密钥(该项不可利用,一并列出仅为说明面)。完全静默、不依赖重定向,所以 上一轮加的「拒绝跨主机跳转」挡不住它。 修法:新增 Channel.DestinationKeys() 声明「消息发往哪」,PrepareConfigUpdate 在地址变化时要求对**每一个**凭据字段显式表态(给新值,或显式留空表示不再 需要)——原样回传掩码值等于「沿用旧凭据」,正是攻击形状,一并拒绝。 刻意不做「自动丢弃凭据」:对可选字段(如 headers)那会变成「鉴权静默没了 但接口返回成功」,比报错更难排查。 【中】markdown 系三渠道对不可信内容零转义 钉钉/企微/飞书此前完全不转义(Telegram 与邮件都做了),而标题与摘要来自 模型输出、资产 url 来自被测目标。一条标题里的 `[文字](http://evil)` 会在 工程师的钉钉里变成可点击外链;`` 会被客户端拉取, 泄露阅读者 IP 并暴露「这条已被看过」。现统一单行化 + markdown 元字符转义。 修的过程中还纠了自己一个设计错误:转义一度被放进 markdownTitle,而它被 markdown 正文、Telegram 的 HTML、飞书卡片、Webhook 的 JSON 与邮件主题四种 语境共用,markdown 的反斜杠泄漏到了 Telegram 消息里(用户会看到 `\(1\)`)。 转义必须发生在各自的渲染出口——测试直接抓到了这个回归。 【中】投递地址的 SSRF 面 此前只校验 scheme 与 host,云元数据 169.254.169.254、环回、内网一律可投递; 而失败响应体前 200 字节会进 last_error 并被投递历史接口回显,构成半盲读 原语。现在在**拨号阶段**设防(最终生效点,同时覆盖 DNS 重绑定与同主机 重定向)。环回/链路本地需显式 ARTEX_NOTIFY_ALLOW_LOCAL=1 才放行(本机 SMTP 中继是合法配置);RFC1918 私网**刻意放行**——内网自建 Mattermost / SMTP 很常见,做到那个程度会把正常部署一起废掉,这条取舍写进了测试断言。 【中】汇总消息被截断后整批标记已送达 = 静默丢失 渠道都有长度上限(企微 4096 字节最紧)。此前渲染完整篇再截断、然后整批 标记成功:被截掉的那些既不在消息里、也不在失败列表里,投递历史还显示 成功。现改为按**整条**打包,只标记真正装下的,其余回队续发,消息头部 如实写明还有多少条在下一条。被推迟的条目**不消耗重试次数**(领取时乐观 +1 的那次减回去),否则 500 条的积压会在第三段就把尾部判成失败。 【中】其余修复 - min_severity 打错字让过滤器静默失效(未知级别序数为 0 → rank>=0 恒真, 用户以为「仅高危」实际全推)。改为写入时校验取值。 - rate_per_min=0(不限流)不可达:文档/UI/令牌桶都按 0=不限流解释,唯独 写库那层 `if <=0 取默认值` 把它改成 20 或 100。默认值改到接口层填。 - digest 渠道完全绕过令牌桶(allow 被扣却没人用)。 - 失败处置按整批 max(attempts) 判断,让老投递连坐同批的新投递。 - 汇总批次里快照无法解析的投递被静默标记成功。 - url.Parse 失败时错误文本仍带完整地址(上一轮只修了 http.Client.Do 那侧; 且当时的用例全走 scheme 分支、没覆盖解析失败路径,是假保证)。 - 单渠道每轮投递条数可能超出租约时长(多实例下重复发送)。加了防漂移 断言,写的时候立刻发现原取值 6 恰好用满租约、零余量,已改 5。 - Telegram 截断可能切断 HTML 实体,导致整条被拒收。 - 邮件把 SMTP 4xx(灰名单等临时失败)判成永久失败,最该重试的场景从不重试。 - 提交嵌套结构时会把掩码字面量真的写进库,导致鉴权静默失效。 测试:notify 包从 82% 提到 86%,新增 SSRF 守卫、掩码绕过(用攻击形状的 输入而非「防御逻辑的正确输入」)、分段投递收敛、markdown 转义、SMTP 4xx/5xx 分类等回归用例;端到端补了一条「60 条长标题跨多轮发完且不卡死」的收敛测试。 Co-Authored-By: Claude Code <[email protected]>
516 lines
15 KiB
Go
516 lines
15 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"
|
||
)
|
||
|
||
// 本文件是推送功能的 HTTP 接口。全部路由挂在 requireAuth 之后(见 Handler()),
|
||
// 与其它管理接口一致。
|
||
|
||
// notifyChannelDTO 是渠道的对外表述。
|
||
//
|
||
// Config 是**掩码后**的配置:凭据字段被替换成 notify.MaskedPrefix 开头的值。
|
||
// 前端把掩码值原样提交回来即表示「这个字段没改」,服务端据此保留库中原值
|
||
// (见 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 告知前端哪些字段是凭据,据此渲染密码框与「留空即不改」的提示。
|
||
// 由渠道自己声明(notify.Channel.SecretKeys),前端不硬编码渠道知识。
|
||
SecretKeys []string `json:"secret_keys"`
|
||
}
|
||
|
||
// notifyDeliveryDTO 是投递历史的对外表述。
|
||
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"`
|
||
// 消息标题摘要,让历史列表不必展开就能看出这条推的是什么。
|
||
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 返回通知页需要的静态元数据与全局设置,一次请求拿全,
|
||
// 避免前端为了渲染一个下拉框发三次请求。
|
||
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 是新建/更新渠道的请求体。
|
||
//
|
||
// 全部业务字段用指针,以便区分「没传」与「传了零值」:PATCH 语义下,
|
||
// 没传的字段必须保留库中原值。
|
||
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, "请求体不是合法 JSON: "+err.Error())
|
||
return
|
||
}
|
||
if req.Kind == nil || !notify.ValidKind(*req.Kind) {
|
||
writeErr(w, 400, fmt.Sprintf("渠道类型无效,可选:%s", strings.Join(notify.Kinds(), " / ")))
|
||
return
|
||
}
|
||
name := ""
|
||
if req.Name != nil {
|
||
name = strings.TrimSpace(*req.Name)
|
||
}
|
||
if name == "" {
|
||
writeErr(w, 400, "缺少渠道名称")
|
||
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, "推送模式无效,可选:realtime / digest")
|
||
return
|
||
}
|
||
ch.Mode = *req.Mode
|
||
}
|
||
if req.RatePerMin != nil {
|
||
// 显式给值就照用——包括 0,它表示「不限流」,是合法配置。
|
||
if *req.RatePerMin < 0 {
|
||
writeErr(w, 400, "限流值不能为负")
|
||
return
|
||
}
|
||
ch.RatePerMin = *req.RatePerMin
|
||
}
|
||
// 只有「字段缺省」才套用渠道默认值。默认值必须在这里决定而不是在 db 层:
|
||
// 只有请求体能区分「没传这个字段」与「显式传了 0」,而两者的含义完全不同
|
||
// (前者=用默认,后者=不限流)。db 层把 0 也当未指定,会让不限流配置不可达。
|
||
if req.RatePerMin == nil {
|
||
ch.RatePerMin = channel.DefaultRatePerMin()
|
||
}
|
||
if req.Filter != nil {
|
||
// 写入时校验取值受限的过滤字段(如 min_severity)。详见 notify.Filter.Validate:
|
||
// 门槛打错字会让过滤器静默失效变成全推,必须在入口拦掉。
|
||
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, "渠道 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, "请求体不是合法 JSON: "+err.Error())
|
||
return
|
||
}
|
||
|
||
// kind 允许修改,但改类型意味着凭据字段整套替换,不能与旧配置合并。
|
||
kind := current.Kind
|
||
if req.Kind != nil {
|
||
if !notify.ValidKind(*req.Kind) {
|
||
writeErr(w, 400, fmt.Sprintf("渠道类型无效,可选:%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{}
|
||
}
|
||
// 用 PrepareConfigUpdate 而不是裸的 MergeConfig:目标地址变更时必须让操作者
|
||
// 对凭据字段重新表态,否则「只改地址、凭据沿用」会把库里的真凭据发到新地址。
|
||
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, "渠道名称不能为空")
|
||
return
|
||
}
|
||
}
|
||
if req.Enabled != nil {
|
||
ch.Enabled = req.Enabled
|
||
}
|
||
if req.Mode != nil {
|
||
if !db.ValidNotifyMode(*req.Mode) {
|
||
writeErr(w, 400, "推送模式无效,可选:realtime / digest")
|
||
return
|
||
}
|
||
ch.Mode = *req.Mode
|
||
}
|
||
if req.RatePerMin != nil {
|
||
if *req.RatePerMin < 0 {
|
||
writeErr(w, 400, "限流值不能为负")
|
||
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
|
||
}
|
||
|
||
// 走 SetNotificationChannelEnabled 而非 SaveNotificationChannel 的路径,
|
||
// 是为了让「停用」同时把存量待发投递标记为 skipped,避免重新启用时收到
|
||
// 一批已过时的积压消息。
|
||
enabledChanged := ch.Enabled != nil && current.Enabled != nil && *ch.Enabled != *current.Enabled
|
||
if enabledChanged {
|
||
// 先把配置更新落库(此时 enabled 用旧值,避免提前触发跳过逻辑),
|
||
// 再单独切开关。两步之间没有并发窗口:本接口是唯一改这两个字段的入口。
|
||
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, "渠道 id 无效")
|
||
return
|
||
}
|
||
if err := pg.DeleteNotificationChannel(r.Context(), id); err != nil {
|
||
notifyChannelLookupErr(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"ok": true})
|
||
}
|
||
|
||
// notifyTestChannel 用当前保存的配置发一条测试消息。
|
||
//
|
||
// 直接调用渠道 Send 而不经投递队列:测试的目的是立刻告诉用户「这套配置能不能
|
||
// 发出去」,走队列会把结果藏进投递历史,用户得再去翻一遍才知道成没成。
|
||
// 因此本接口是**同步**的,超时上限由 notify 包的 HTTP 客户端决定(15 秒)。
|
||
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, "渠道 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("渠道类型 %q 未注册", 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()
|
||
// 测试消息只有一条,送达条数这里不需要(渠道长度上限对单条消息而言
|
||
// 由截断兜底,不涉及分段)。
|
||
if _, err := channel.Send(r.Context(), cfg, msg); err != nil {
|
||
// 把渠道返回的原始错误如实回给用户——这是他们调试配置的唯一线索。
|
||
writeErr(w, 502, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{
|
||
"ok": true,
|
||
"latency_ms": time.Since(start).Milliseconds(),
|
||
})
|
||
}
|
||
|
||
// notifyTestMessage 构造测试消息。刻意用一眼能看出是测试的内容:
|
||
// 收到的人不应该把它误判成真实漏洞。
|
||
func notifyTestMessage(baseURL string) notify.Message {
|
||
return notify.Message{
|
||
Items: []notify.Item{{
|
||
FindingID: 0,
|
||
Name: "测试消息 · 渠道配置正常",
|
||
VulnClass: "连通性测试",
|
||
Severity: "low",
|
||
Summary: "这是 ARTEX 推送渠道的测试消息,收到即表示该渠道配置可用。",
|
||
Assets: []string{"artex.example.com"},
|
||
DetailURL: baseURL,
|
||
}},
|
||
HomeURL: baseURL,
|
||
}
|
||
}
|
||
|
||
// notifierBaseURL 读回链用的外部地址。
|
||
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, "投递 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 把「渠道不存在」翻译成 404,其余错误 500。
|
||
func notifyChannelLookupErr(w http.ResponseWriter, err error) {
|
||
if errors.Is(err, db.ErrNotificationChannelNotFound) {
|
||
writeErr(w, 404, "通知渠道不存在")
|
||
return
|
||
}
|
||
writeErr(w, 500, err.Error())
|
||
}
|