dsh(f07daac1 之后的 5fab8734)报:客户端一度把这个字段填成**服务器地址** (`AccountInfo.server` = `https://…/api/v1`),而这里只 TrimSpace、不看格式 ⇒ 不 400、不影响收信、不进日志;而**投递路径也不看它**(通知里的 session_id 来自 邮件自己,见 notify/mail.go;dispatch 只用 token 的 Provider/Token)。 两条路都不看 ⇒ 存了假值没有任何机制会报警,可它存在的唯一目的就是 「点通知回到那条会话」。 改动只有一条校验(非空才校验,空串仍合法 —— 契约允许"客户端还没进任何会话"), 400 文案自带药方(写明"要么 UUID、要么留空")。 判据 `TestPushTokenSessionIDMustBeUUID` 四个用例:服务器地址顶替 / 随便一个词 / 空串 / 真 UUID。**变体验证两个方向**: · 去掉校验(连 import 一起去)⇒ 「非法」两条当场红(实际 200); · 把判断写反(`err == nil` 才报错)⇒ 合法的真 UUID 也红 ⇒ 证明它不是"恒 400"。 恢复后 `go test ./...` 全绿、`go vet` 干净。 取舍:老客户端带旧值上报会吃 400,按契约 400 归静默 ⇒ 不打扰用户、只是那次不上报。 `push_tokens` 现为 0 行,**没有历史脏值要清**。
186 lines
6.9 KiB
Go
186 lines
6.9 KiB
Go
package handler
|
||
|
||
import (
|
||
"log"
|
||
"net/http"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"github.com/agentmail/gateway/internal/middleware"
|
||
"github.com/agentmail/gateway/internal/push"
|
||
"github.com/agentmail/gateway/internal/repo"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
/*
|
||
设备推送登记 —— /api/v1/me/devices/push-token
|
||
|
||
# 为什么这三个端点"没配推送也能用"
|
||
|
||
推送是自部署后端的**可选项**(用户 2026-09-15 明确要求)。所以:
|
||
|
||
- 登记/注销**不看服务端有没有配凭证**:照存。管理员之后把 `HMS_APP_ID/SECRET`
|
||
配上就立刻生效,客户端不必再登记一次(它那时可能已经不在前台了)。
|
||
- 响应里回 `enabled`:客户端据此知道「登记成功了,但服务端现在还没开推送」——
|
||
而不是把"没收到通知"当成登记失败去反复重试。
|
||
- 查不到 provider 凭证时**不返回错误状态码**:那是正常配置,不是故障。
|
||
|
||
# 为什么 provider 用字符串而不是枚举
|
||
|
||
加通道(web push、别的厂商)不该动端点形状、不该动 schema。合法性只做**形状**
|
||
校验(字符集与长度),不做白名单 —— 白名单会把「服务端还没实现的那个通道」
|
||
变成客户端的 400,而那恰恰是最不该拦的时候。
|
||
*/
|
||
|
||
var pushProviderRe = regexp.MustCompile(`^[a-z0-9_-]{1,32}$`)
|
||
|
||
// pushTokenRequest 是登记/注销共用的请求体。
|
||
type pushTokenRequest struct {
|
||
Provider string `json:"provider"`
|
||
Token string `json:"token"`
|
||
// DeviceName 仅供人辨认(如"我的手机"),服务端不做判据。
|
||
DeviceName string `json:"device_name"`
|
||
// SessionID 是客户端当前所在的会话:点通知要回到那条会话里的那封信。
|
||
// 允许为空(客户端还没进任何会话),此时通知只带 mail_id。
|
||
SessionID string `json:"session_id"`
|
||
}
|
||
|
||
// pushEnabled 是三个端点共用的响应片段。
|
||
func pushStatus() map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"enabled": push.Enabled(),
|
||
"providers": push.Names(),
|
||
}
|
||
}
|
||
|
||
// RegisterPushToken 登记(或刷新)一台设备的推送地址。
|
||
//
|
||
// POST /api/v1/me/devices/push-token
|
||
func RegisterPushToken(w http.ResponseWriter, r *http.Request) {
|
||
user := middleware.GetUser(r)
|
||
if user == nil {
|
||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||
return
|
||
}
|
||
var req pushTokenRequest
|
||
if !DecodeBody(w, r, &req) {
|
||
return
|
||
}
|
||
req.Provider = strings.TrimSpace(req.Provider)
|
||
req.Token = strings.TrimSpace(req.Token)
|
||
if !pushProviderRe.MatchString(req.Provider) {
|
||
Error(w, http.StatusBadRequest, "provider 非法(只允许小写字母、数字、下划线、连字符,最长 32)")
|
||
return
|
||
}
|
||
if req.Token == "" || len(req.Token) > 512 {
|
||
Error(w, http.StatusBadRequest, "token 非法(不能为空,最长 512 字符)")
|
||
return
|
||
}
|
||
/*
|
||
* session_id 非空时必须是**合法 UUID**(dsh 2026-09-17 报的真 bug)。
|
||
*
|
||
* 为什么这个校验值得加(那个 bug 无声地活了很久):
|
||
* 客户端一度把这个字段填成了**服务器地址**(`https://…/api/v1`),而这里只 TrimSpace、
|
||
* 允许任何字符串 ⇒ 不报错、不影响收信、不进日志。而**投递路径不看这个字段**
|
||
* (发通知用的是邮件自己的 `m.SessionID`,见 notify/mail.go;dispatch 只用 token 的
|
||
* Provider/Token),所以它错了也**没有任何机制会报警**。
|
||
* 可这个字段存在的唯一目的就是“点通知回到那条会话”—— 等哪天真按它路由,
|
||
* 人会被送到别的会话,而根因在一个看起来“存进去了”的字段上。
|
||
*
|
||
* 代价与取舍:老客户端(上报过错值的)下次上报会吃 **400**;按契约 400 归静默,
|
||
* 所以不打扰用户、只是那一次不上报(下次带对值就恢复)。用一次静默重试换掉
|
||
* “静默存错值”,值。
|
||
* 注意:这里**只校验非空值**——空串是契约允许的(“客户端还没进任何会话”)。
|
||
*/
|
||
if sid := strings.TrimSpace(req.SessionID); sid != "" {
|
||
if _, err := uuid.Parse(sid); err != nil {
|
||
Error(w, http.StatusBadRequest, "session_id 非法(必须是会话 UUID,或留空表示“还没有当前会话”)")
|
||
return
|
||
}
|
||
}
|
||
if err := repo.UpsertPushToken(r.Context(), req.Provider, req.Token, user.Username,
|
||
strings.TrimSpace(req.SessionID), strings.TrimSpace(req.DeviceName)); err != nil {
|
||
Error(w, http.StatusInternalServerError, "登记推送地址失败")
|
||
return
|
||
}
|
||
// 顺手清理长期没刷新的登记(90 天)。放在登记路径上是因为它天然低频
|
||
// (每台设备只在启动/token 轮换时来一次),不需要定时任务。
|
||
// 失败只记日志:清理失败不该让一次正常登记变成 500。
|
||
if _, err := repo.PruneStalePushTokens(r.Context()); err != nil {
|
||
log.Printf("[push] 清理过期推送登记失败: %v", err)
|
||
}
|
||
resp := pushStatus()
|
||
resp["ok"] = true
|
||
JSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
// DeletePushToken 注销一台设备的推送地址。
|
||
//
|
||
// DELETE /api/v1/me/devices/push-token
|
||
func DeletePushToken(w http.ResponseWriter, r *http.Request) {
|
||
user := middleware.GetUser(r)
|
||
if user == nil {
|
||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||
return
|
||
}
|
||
var req pushTokenRequest
|
||
if !DecodeBody(w, r, &req) {
|
||
return
|
||
}
|
||
req.Provider = strings.TrimSpace(req.Provider)
|
||
req.Token = strings.TrimSpace(req.Token)
|
||
if !pushProviderRe.MatchString(req.Provider) || req.Token == "" {
|
||
Error(w, http.StatusBadRequest, "provider 或 token 非法")
|
||
return
|
||
}
|
||
deleted, err := repo.DeletePushToken(r.Context(), req.Provider, req.Token, user.Username)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "注销推送地址失败")
|
||
return
|
||
}
|
||
resp := pushStatus()
|
||
resp["ok"] = true
|
||
// deleted=false 不是错误(本来就没登记),但客户端可据此避免重复注销。
|
||
resp["deleted"] = deleted
|
||
JSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
// ListPushTokens 列出自己的推送登记。
|
||
//
|
||
// GET /api/v1/me/devices/push-token
|
||
func ListPushTokens(w http.ResponseWriter, r *http.Request) {
|
||
user := middleware.GetUser(r)
|
||
if user == nil {
|
||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||
return
|
||
}
|
||
tokens, err := repo.ListPushTokensOf(r.Context(), user.Username)
|
||
if err != nil {
|
||
Error(w, http.StatusInternalServerError, "读取推送登记失败")
|
||
return
|
||
}
|
||
items := make([]map[string]interface{}, 0, len(tokens))
|
||
for _, t := range tokens {
|
||
items = append(items, map[string]interface{}{
|
||
"id": t.TokenID,
|
||
"provider": t.Provider,
|
||
// token 只回尾 6 位:客户端不需要全文(它自己刚发过来的),
|
||
// 而一个能读到全文的接口等于多一处泄漏面。
|
||
"token_tail": tailOf(t.Token, 6),
|
||
"device_name": t.DeviceName,
|
||
"session_id": t.SessionID,
|
||
})
|
||
}
|
||
resp := pushStatus()
|
||
resp["tokens"] = items
|
||
JSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
func tailOf(s string, n int) string {
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[len(r)-n:])
|
||
}
|