保存微信个人绑定后端、按实际收件账户推送和回归源码;前端未接入,回归用例未执行,尚未部署验收。
This commit is contained in:
parent
2a532b2224
commit
afc177132a
213
backend/controllers/plugin_wechat.go
Normal file
213
backend/controllers/plugin_wechat.go
Normal file
@ -0,0 +1,213 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
"git.pcux.cn/xuans/Smail/dto/response"
|
||||
"git.pcux.cn/xuans/Smail/hooks"
|
||||
"git.pcux.cn/xuans/Smail/hooks/framework"
|
||||
"git.pcux.cn/xuans/Smail/services/access"
|
||||
pluginservice "git.pcux.cn/xuans/Smail/services/plugin"
|
||||
"git.pcux.cn/xuans/Smail/session"
|
||||
appcontext "git.pcux.cn/xuans/Smail/utils/context"
|
||||
)
|
||||
|
||||
func weChatHook() framework.EmailHook {
|
||||
for _, name := range []string{"wechat_push", "WeChatPushHook", "WechatPush"} {
|
||||
if hook, found := hooks.GetHook(name); found {
|
||||
return hook
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func notificationBindingActor(ctx *appcontext.Context) (pluginservice.NotificationBindingActor, error) {
|
||||
if !authorizedPluginUser(ctx) || session.Instance == nil || ctx.Context == nil {
|
||||
return pluginservice.NotificationBindingActor{}, pluginservice.ErrWeChatBinding
|
||||
}
|
||||
token := session.Instance.Token(ctx)
|
||||
if token == "" {
|
||||
return pluginservice.NotificationBindingActor{}, pluginservice.ErrWeChatBinding
|
||||
}
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return pluginservice.NotificationBindingActor{UserID: ctx.UserID, SessionVersion: ctx.SessionVersion,
|
||||
SecurityVersion: ctx.SecurityVersion, SessionHash: hex.EncodeToString(hash[:])}, nil
|
||||
}
|
||||
|
||||
func weChatRequestService(ctx *appcontext.Context, w http.ResponseWriter, recent bool) *pluginservice.ConfigService {
|
||||
if !authorizedPluginUser(ctx) {
|
||||
writeConfigAccessError(w, &access.Denial{Code: access.CodeAccessDenied})
|
||||
return nil
|
||||
}
|
||||
if recent && !ctx.RecentAuth {
|
||||
writeConfigAccessError(w, &access.Denial{Code: access.CodeRecentAuthRequired})
|
||||
return nil
|
||||
}
|
||||
service := hooks.PluginConfigService()
|
||||
if service == nil {
|
||||
response.NewErrorResponse(response.ServerError, "通知配置服务不可用", "").FPrint(w)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func weChatPublicSettings(status pluginservice.WeChatUserSettingsStatus) map[string]any {
|
||||
return map[string]any{"version": status.Version, "bound": status.Settings.OpenID != "", "enabled": status.Settings.Enabled,
|
||||
"showContent": status.Settings.ShowContent, "displayName": status.Settings.DisplayName}
|
||||
}
|
||||
|
||||
func writeWeChatSettings(w http.ResponseWriter, userID int, status pluginservice.WeChatUserSettingsStatus) {
|
||||
data := weChatPublicSettings(status)
|
||||
data["synced"] = hooks.SyncWeChatUserSettings(userID, status.Settings) == nil
|
||||
response.NewSuccessResponse(data).FPrint(w)
|
||||
}
|
||||
|
||||
func WeChatUserSettings(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
|
||||
service := weChatRequestService(ctx, w, req.Method != http.MethodGet)
|
||||
if service == nil {
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodGet {
|
||||
status, err := service.GetOwnWeChatSettings(ctx.UserID)
|
||||
if err != nil {
|
||||
writeWeChatError(w, err)
|
||||
return
|
||||
}
|
||||
available, message := service.WeChatBindingAvailability()
|
||||
running := weChatHook() != nil
|
||||
if available && !running {
|
||||
message = "微信推送插件未运行,请联系超级管理员"
|
||||
}
|
||||
data := weChatPublicSettings(status)
|
||||
data["available"], data["configured"], data["running"], data["message"] = available && running, available, running, message
|
||||
response.NewSuccessResponse(data).FPrint(w)
|
||||
return
|
||||
}
|
||||
if req.Method != http.MethodPut && req.Method != http.MethodDelete {
|
||||
w.Header().Set("Allow", "GET, PUT, DELETE")
|
||||
http.Error(w, "请求方法不支持", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ShowContent bool `json:"showContent"`
|
||||
Version uint64 `json:"version"`
|
||||
}
|
||||
if !decodePluginJSONBody(w, req, &input) {
|
||||
return
|
||||
}
|
||||
status, err := service.UpdateOwnWeChatPreferences(ctx.UserID, input.Enabled, input.ShowContent, req.Method == http.MethodDelete, input.Version)
|
||||
if err != nil {
|
||||
writeWeChatError(w, err)
|
||||
return
|
||||
}
|
||||
writeWeChatSettings(w, ctx.UserID, status)
|
||||
}
|
||||
|
||||
func WeChatBinding(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
|
||||
var input struct {
|
||||
Action string `json:"action"`
|
||||
Ticket string `json:"ticket"`
|
||||
}
|
||||
if !decodePluginJSONBody(w, req, &input) {
|
||||
return
|
||||
}
|
||||
service := weChatRequestService(ctx, w, input.Action == "begin" || input.Action == "confirm")
|
||||
if service == nil {
|
||||
return
|
||||
}
|
||||
actor, err := notificationBindingActor(ctx)
|
||||
if err != nil {
|
||||
writeWeChatError(w, err)
|
||||
return
|
||||
}
|
||||
var result any
|
||||
switch input.Action {
|
||||
case "begin":
|
||||
if weChatHook() == nil {
|
||||
writeWeChatError(w, pluginservice.WeChatNotice("微信推送插件未运行,请联系超级管理员"))
|
||||
return
|
||||
}
|
||||
result, err = service.BeginWeChatBinding(actor)
|
||||
case "status":
|
||||
result, err = service.GetWeChatBinding(actor, input.Ticket)
|
||||
case "cancel":
|
||||
err = service.CancelWeChatBinding(actor, input.Ticket)
|
||||
case "confirm":
|
||||
var status pluginservice.WeChatUserSettingsStatus
|
||||
status, err = service.ConfirmWeChatBinding(actor, input.Ticket)
|
||||
if err == nil {
|
||||
writeWeChatSettings(w, ctx.UserID, status)
|
||||
return
|
||||
}
|
||||
default:
|
||||
err = pluginservice.WeChatNotice("绑定操作无效")
|
||||
}
|
||||
if err != nil {
|
||||
writeWeChatError(w, err)
|
||||
return
|
||||
}
|
||||
response.NewSuccessResponse(result).FPrint(w)
|
||||
}
|
||||
|
||||
// WeChatBindingCallback 不依赖手机浏览器的登录 Cookie,也不自动完成绑定。
|
||||
func WeChatBindingCallback(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
|
||||
service := hooks.PluginConfigService()
|
||||
if !config.IsSetupReady() || service == nil || service.ClaimWeChatBinding(req.Context(), req.URL.Query().Get("state"), req.URL.Query().Get("code")) != nil {
|
||||
http.Error(w, "授权失败或二维码已失效,请返回 Smail 页面重新扫码。", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("微信授权已完成。请返回发起扫码的 Smail 页面,核对微信昵称后点击确认绑定。"))
|
||||
}
|
||||
|
||||
func TestWeChatMessage(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
|
||||
service := weChatRequestService(ctx, w, true)
|
||||
if service == nil {
|
||||
return
|
||||
}
|
||||
status, err := service.GetOwnWeChatSettings(ctx.UserID)
|
||||
if err != nil || status.Settings.OpenID == "" {
|
||||
writeWeChatError(w, pluginservice.WeChatNotice("请先绑定本人的微信"))
|
||||
return
|
||||
}
|
||||
provider, ok := weChatHook().(framework.TestMessageProvider)
|
||||
if !ok {
|
||||
writeWeChatError(w, pluginservice.WeChatNotice("微信推送插件未运行,请联系超级管理员"))
|
||||
return
|
||||
}
|
||||
if err := service.ReserveWeChatTest(ctx.UserID); err != nil {
|
||||
writeWeChatError(w, err)
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(status.Settings)
|
||||
if err != nil || provider.TestMessage(payload) != nil {
|
||||
writeWeChatError(w, pluginservice.WeChatNotice("测试消息发送失败,请确认已关注公众号及模板配置正确"))
|
||||
return
|
||||
}
|
||||
response.NewSuccessResponse(nil).FPrint(w)
|
||||
}
|
||||
|
||||
func writeWeChatError(w http.ResponseWriter, err error) {
|
||||
message := "微信通知操作失败,请稍后重试"
|
||||
if errors.Is(err, pluginservice.ErrPluginSettingConflict) {
|
||||
message = "设置已被其他操作更新,请刷新后重试"
|
||||
} else if errors.Is(err, pluginservice.ErrWeChatBinding) {
|
||||
message = pluginservice.ErrWeChatBinding.Error()
|
||||
} else {
|
||||
// 只公开固定业务提示,避免数据库错误携带内部路径、查询或连接信息。
|
||||
var notice pluginservice.WeChatNotice
|
||||
if errors.As(err, ¬ice) {
|
||||
message = notice.Error()
|
||||
}
|
||||
}
|
||||
response.NewErrorResponse(response.ParamsError, message, "").FPrint(w)
|
||||
}
|
||||
@ -118,6 +118,31 @@ func (h *HookSender) callHook(ctx *context.Context, endpoint string, dto framewo
|
||||
}
|
||||
|
||||
func (h *HookSender) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail) {
|
||||
if h.name == "wechat_push" {
|
||||
if pluginConfigService == nil {
|
||||
return
|
||||
}
|
||||
allowed := make([]*models.UserEmail, 0, len(ue))
|
||||
for _, recipient := range ue {
|
||||
if recipient == nil || recipient.UserID <= 0 {
|
||||
continue
|
||||
}
|
||||
// 发信前重新读取,停用、解绑或禁用账户后不能继续使用插件缓存中的旧接收人。
|
||||
status, err := pluginConfigService.GetOwnWeChatSettings(recipient.UserID)
|
||||
if err != nil || !status.Settings.Enabled || status.Settings.OpenID == "" {
|
||||
continue
|
||||
}
|
||||
payload, err := json.Marshal(status.Settings)
|
||||
if err != nil || h.ConfigureUserSettings(recipient.UserID, payload) != nil {
|
||||
continue
|
||||
}
|
||||
allowed = append(allowed, recipient)
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return
|
||||
}
|
||||
ue = allowed
|
||||
}
|
||||
_, _ = h.callHook(ctx, "ReceiveSaveAfter", framework.HookDTO{Ctx: ctx, Email: email, UserEmail: ue})
|
||||
}
|
||||
|
||||
@ -385,6 +410,11 @@ func Init(serverVersion string) {
|
||||
_ = process.Kill()
|
||||
continue
|
||||
}
|
||||
if syncErr := syncWeChatUserSettings(logicalID, hook); syncErr != nil {
|
||||
log.Warnf("[%s] 微信个人设置恢复失败,插件未启用", logicalID)
|
||||
_ = process.Kill()
|
||||
continue
|
||||
}
|
||||
if healthErr := hook.Health(); healthErr != nil {
|
||||
log.Warnf("[%s] Plugin health check failed", logicalID)
|
||||
_ = process.Kill()
|
||||
@ -401,6 +431,48 @@ func Init(serverVersion string) {
|
||||
}
|
||||
}
|
||||
|
||||
func SyncWeChatUserSettings(userID int, settings pluginservice.WeChatUserSettings) error {
|
||||
var target framework.EmailHook
|
||||
for _, name := range []string{"wechat_push", "WeChatPushHook", "WechatPush"} {
|
||||
if hook, found := GetHook(name); found {
|
||||
target = hook
|
||||
break
|
||||
}
|
||||
}
|
||||
configurer, ok := target.(framework.UserSettingsConfigurer)
|
||||
if !ok || userID <= 0 {
|
||||
return errors.New("微信插件不可用")
|
||||
}
|
||||
payload, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return configurer.ConfigureUserSettings(userID, payload)
|
||||
}
|
||||
|
||||
func syncWeChatUserSettings(logicalID string, hook *HookSender) error {
|
||||
if logicalID != "wechat_push" {
|
||||
return nil
|
||||
}
|
||||
if hook == nil || pluginConfigService == nil {
|
||||
return pluginservice.ErrPluginStoreUnavailable
|
||||
}
|
||||
entries, err := pluginConfigService.ListWeChatUserSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
payload, err := json.Marshal(entry.Settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hook.ConfigureUserSettings(entry.UserID, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SyncTelegramUserSettings(userID int, settings pluginservice.TelegramUserSettings) error {
|
||||
if userID <= 0 {
|
||||
return errors.New("invalid user ID")
|
||||
|
||||
@ -43,6 +43,48 @@ type WeChatPushHook struct {
|
||||
pushUser string
|
||||
webDomain string
|
||||
httpClient *http.Client
|
||||
userSettings map[int]weChatUserSettings
|
||||
}
|
||||
|
||||
type weChatUserSettings struct {
|
||||
AppID string `json:"appId"`
|
||||
OpenID string `json:"openId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ShowContent bool `json:"showContent"`
|
||||
}
|
||||
|
||||
func (w *WeChatPushHook) ConfigureUserSettings(userID int, data json.RawMessage) error {
|
||||
if userID <= 0 || len(data) > 4096 || !json.Valid(data) {
|
||||
return fmt.Errorf("微信个人配置无效")
|
||||
}
|
||||
var settings weChatUserSettings
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&settings); err != nil || settings.Enabled && (settings.OpenID == "" || settings.AppID == "") {
|
||||
return fmt.Errorf("微信个人配置无效")
|
||||
}
|
||||
w.tokenMu.Lock()
|
||||
defer w.tokenMu.Unlock()
|
||||
if w.userSettings == nil {
|
||||
w.userSettings = make(map[int]weChatUserSettings)
|
||||
}
|
||||
w.userSettings[userID] = settings
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WeChatPushHook) TestMessage(data json.RawMessage) error {
|
||||
var settings weChatUserSettings
|
||||
if len(data) > 4096 || json.Unmarshal(data, &settings) != nil || settings.OpenID == "" {
|
||||
return fmt.Errorf("请先绑定本人的微信")
|
||||
}
|
||||
w.tokenMu.Lock()
|
||||
appID := w.appId
|
||||
w.tokenMu.Unlock()
|
||||
if appID == "" || settings.AppID != appID {
|
||||
return fmt.Errorf("系统公众号已变更,请重新绑定")
|
||||
}
|
||||
return w.sendUserMsg(&context.Context{}, settings.OpenID, "这是一条 Smail 微信通知测试消息。")
|
||||
}
|
||||
|
||||
func (w *WeChatPushHook) GetName(ctx *context.Context) string {
|
||||
@ -106,7 +148,7 @@ func (w *WeChatPushHook) Health() error {
|
||||
return fmt.Errorf("微信插件不可用")
|
||||
}
|
||||
w.tokenMu.Lock()
|
||||
configured := w.appId != "" && w.secret != "" && w.templateId != "" && w.pushUser != "" && w.webDomain != "" && w.httpClient != nil
|
||||
configured := w.appId != "" && w.secret != "" && w.templateId != "" && w.webDomain != "" && w.httpClient != nil
|
||||
w.tokenMu.Unlock()
|
||||
if !configured {
|
||||
return fmt.Errorf("微信插件尚未完成运行时配置")
|
||||
@ -127,19 +169,29 @@ func (w *WeChatPushHook) ReceiveSaveAfter(ctx *context.Context, email *parsemail
|
||||
w.tokenMu.Lock()
|
||||
appID := w.appId
|
||||
secret := w.secret
|
||||
pushUser := w.pushUser
|
||||
w.tokenMu.Unlock()
|
||||
if email == nil || appID == "" || secret == "" || pushUser == "" {
|
||||
if email == nil || appID == "" || secret == "" || email.MessageId <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
seen := make(map[int]bool)
|
||||
for _, u := range ue {
|
||||
if u == nil {
|
||||
if u == nil || u.UserID <= 0 || u.IsRead != 0 || u.Status != 0 || seen[u.UserID] {
|
||||
continue
|
||||
}
|
||||
// 管理员(Uid=1)收到邮件且非已读、非已删除 触发通知
|
||||
if u.UserID == 1 && u.IsRead == 0 && u.Status == 0 && email.MessageId > 0 {
|
||||
w.sendUserMsg(ctx, pushUser, buildWeChatMessage(email))
|
||||
seen[u.UserID] = true
|
||||
w.tokenMu.Lock()
|
||||
settings := w.userSettings[u.UserID]
|
||||
w.tokenMu.Unlock()
|
||||
if !settings.Enabled || settings.OpenID == "" || settings.AppID != appID {
|
||||
continue
|
||||
}
|
||||
content := "您收到一封新邮件,请登录邮箱查看。"
|
||||
if settings.ShowContent {
|
||||
content = buildWeChatMessage(email)
|
||||
}
|
||||
if err := w.sendUserMsg(ctx, settings.OpenID, content); err != nil {
|
||||
log.WithContext(ctx).Warn("微信个人通知发送失败")
|
||||
}
|
||||
}
|
||||
|
||||
@ -226,7 +278,7 @@ type DataItem struct {
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
func (w *WeChatPushHook) sendUserMsg(ctx *context.Context, userId string, content string) {
|
||||
func (w *WeChatPushHook) sendUserMsg(ctx *context.Context, userId string, content string) error {
|
||||
content = truncateWeChatText(content, maxWeChatMessageBytes)
|
||||
w.tokenMu.Lock()
|
||||
webDomain := w.webDomain
|
||||
@ -246,12 +298,12 @@ func (w *WeChatPushHook) sendUserMsg(ctx *context.Context, userId string, conten
|
||||
})
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Error("wechat push request build failed")
|
||||
return
|
||||
return fmt.Errorf("微信推送请求构造失败")
|
||||
}
|
||||
token := w.getWxAccessToken()
|
||||
if token == "" {
|
||||
log.WithContext(ctx).Error("wechat push authentication failed")
|
||||
return
|
||||
return fmt.Errorf("微信公众号认证失败")
|
||||
}
|
||||
endpoint, _ := urlpkg.Parse("https://api.weixin.qq.com/cgi-bin/message/template/send")
|
||||
query := endpoint.Query()
|
||||
@ -260,18 +312,18 @@ func (w *WeChatPushHook) sendUserMsg(ctx *context.Context, userId string, conten
|
||||
request, err := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(sendMsgReq))
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Error("wechat push request build failed")
|
||||
return
|
||||
return fmt.Errorf("微信推送请求构造失败")
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(request)
|
||||
if err != nil || resp == nil {
|
||||
log.WithContext(ctx).Error("wechat push request failed")
|
||||
return
|
||||
return fmt.Errorf("微信推送请求失败")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
log.WithContext(ctx).Error("wechat push request was rejected")
|
||||
return
|
||||
return fmt.Errorf("微信推送请求被拒绝")
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxWeChatResponseBytes+1))
|
||||
var result struct {
|
||||
@ -279,7 +331,7 @@ func (w *WeChatPushHook) sendUserMsg(ctx *context.Context, userId string, conten
|
||||
}
|
||||
if err != nil || len(body) > maxWeChatResponseBytes || json.Unmarshal(body, &result) != nil || result.ErrorCode == nil {
|
||||
log.WithContext(ctx).Error("微信推送响应无效或超过大小限制")
|
||||
return
|
||||
return fmt.Errorf("微信推送响应无效")
|
||||
}
|
||||
if *result.ErrorCode != 0 {
|
||||
// 只记录业务码,不能输出可能含凭据或收件人信息的响应正文。
|
||||
@ -292,7 +344,9 @@ func (w *WeChatPushHook) sendUserMsg(ctx *context.Context, userId string, conten
|
||||
}
|
||||
w.tokenMu.Unlock()
|
||||
}
|
||||
return fmt.Errorf("微信推送失败,业务码:%d", *result.ErrorCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildWeChatMessage(email *parsemail.Email) string {
|
||||
@ -362,10 +416,6 @@ func parseWeChatRuntimeConfig(data json.RawMessage) (weChatRuntimeConfig, weChat
|
||||
if err != nil {
|
||||
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信模板 ID 无效")
|
||||
}
|
||||
userID, err := credential.ValidateSecret(settings.UserID, 256)
|
||||
if err != nil {
|
||||
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信用户 ID 无效")
|
||||
}
|
||||
webDomain, err := normalizeWeChatWebDomain(settings.WebDomain)
|
||||
if err != nil {
|
||||
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信回调域名无效")
|
||||
@ -384,7 +434,6 @@ func parseWeChatRuntimeConfig(data json.RawMessage) (weChatRuntimeConfig, weChat
|
||||
}
|
||||
settings.AppID = appID
|
||||
settings.TemplateID = templateID
|
||||
settings.UserID = userID
|
||||
settings.WebDomain = webDomain
|
||||
return payload, settings, secret, nil
|
||||
}
|
||||
|
||||
@ -13,6 +13,8 @@ import (
|
||||
|
||||
"git.pcux.cn/xuans/Smail/dto/parsemail"
|
||||
"git.pcux.cn/xuans/Smail/hooks/framework"
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
appcontext "git.pcux.cn/xuans/Smail/utils/context"
|
||||
)
|
||||
|
||||
type weChatRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
@ -53,6 +55,50 @@ func TestWeChatRuntimeConfigAcceptsAuthorizedPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeChatRoutesOnlyToBoundActualRecipients(t *testing.T) {
|
||||
hook := NewWechatPushHook()
|
||||
if err := hook.Configure(validWeChatRuntimePayload()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hook.token, hook.tokenExpires = "test-access-token", time.Now().Add(time.Hour).Unix()
|
||||
var recipients []string
|
||||
hook.httpClient = &http.Client{Transport: weChatRoundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
var payload sendMsgRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recipients = append(recipients, payload.Touser)
|
||||
if payload.Data.Content.Value != "您收到一封新邮件,请登录邮箱查看。" {
|
||||
t.Fatal("默认通知不应泄露正文或验证码")
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"errcode":0}`))}, nil
|
||||
})}
|
||||
ctx := &appcontext.Context{}
|
||||
email := &parsemail.Email{MessageId: 12, Subject: "private subject"}
|
||||
hook.ReceiveSaveAfter(ctx, email, []*models.UserEmail{{UserID: 1}})
|
||||
if len(recipients) != 0 {
|
||||
t.Fatal("未绑定的用户一不应触发旧版全局收件人通知")
|
||||
}
|
||||
for id, recipient := range map[int]string{17: "openid-first", 29: "openid-second"} {
|
||||
payload, err := json.Marshal(weChatUserSettings{AppID: "app-id", OpenID: recipient, Enabled: true})
|
||||
if err != nil || hook.ConfigureUserSettings(id, payload) != nil {
|
||||
t.Fatal("个人绑定配置失败")
|
||||
}
|
||||
}
|
||||
hook.ReceiveSaveAfter(ctx, email, []*models.UserEmail{{UserID: 17}, {UserID: 17}, {UserID: 29, IsRead: 1}, {UserID: 1}})
|
||||
if len(recipients) != 1 || recipients[0] != "openid-first" {
|
||||
t.Fatalf("实际通知收件人错误:%v", recipients)
|
||||
}
|
||||
payload, _ := json.Marshal(weChatUserSettings{AppID: "app-id", OpenID: "openid-first", Enabled: false})
|
||||
if err := hook.ConfigureUserSettings(17, payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hook.ReceiveSaveAfter(ctx, email, []*models.UserEmail{{UserID: 17}, {UserID: 29}})
|
||||
if len(recipients) != 2 || recipients[1] != "openid-second" {
|
||||
t.Fatal("停用本人推送不应影响其他收件账户")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeChatRuntimeConfigRejectsZeroGeneration(t *testing.T) {
|
||||
var payload weChatRuntimeConfig
|
||||
if err := json.Unmarshal(validWeChatRuntimePayload(), &payload); err != nil {
|
||||
|
||||
@ -164,6 +164,10 @@ func registerV1TOTPRoute(mux *http.ServeMux) {
|
||||
}
|
||||
|
||||
func registerV1PluginRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/plugin/settings/wechat_push/user", limitBody(4<<10, contextIterceptor(controllers.WeChatUserSettings)))
|
||||
mux.HandleFunc("/api/v1/plugin/settings/wechat_push/binding", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.WeChatBinding))))
|
||||
mux.HandleFunc("/api/v1/plugin/settings/wechat_push/test", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.TestWeChatMessage))))
|
||||
mux.HandleFunc("/api/v1/plugin/binding/wechat/callback", onlyMethod(http.MethodGet, controllers.WeChatBindingCallback))
|
||||
for _, id := range []string{"telegram_push", "wechat_push"} {
|
||||
mux.HandleFunc("/api/v1/plugin/secrets/"+id, limitBody(16<<10, contextIterceptor(controllers.PluginSecret)))
|
||||
}
|
||||
|
||||
@ -102,11 +102,15 @@ type bulkTarget struct {
|
||||
// ConfigService 是插件全局 Secret 和用户设置的唯一写服务。
|
||||
// HTTP 路由在 CG-025 完成能力接入前不得直接暴露本服务的写方法。
|
||||
type ConfigService struct {
|
||||
engine *xorm.Engine
|
||||
vault *credential.Vault
|
||||
now func() time.Time
|
||||
ticketsMu sync.Mutex
|
||||
tickets map[string]pluginBulkTicket
|
||||
engine *xorm.Engine
|
||||
vault *credential.Vault
|
||||
now func() time.Time
|
||||
ticketsMu sync.Mutex
|
||||
tickets map[string]pluginBulkTicket
|
||||
bindingMu sync.Mutex
|
||||
wechatBindings map[string]weChatBinding
|
||||
weChatTestMu sync.Mutex
|
||||
weChatTestAttempts map[int]time.Time
|
||||
}
|
||||
|
||||
func NewConfigService(engine *xorm.Engine, vault *credential.Vault) (*ConfigService, error) {
|
||||
|
||||
@ -91,6 +91,95 @@ func TestConfigServiceAllowsEmptyTelegramChatIDToDisablePush(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryRoleKeepsIndependentOwnWeChatSettings(t *testing.T) {
|
||||
service, engine := newPluginConfigService(t)
|
||||
for _, role := range []access.Role{access.RoleSuperAdmin, access.RoleAdmin, access.RoleUser} {
|
||||
t.Run(string(role), func(t *testing.T) {
|
||||
user := insertPluginTestUser(t, engine, "wechat-"+string(role), role)
|
||||
status, err := service.GetOwnWeChatSettings(user.ID)
|
||||
if err != nil || status.Settings.OpenID != "" || status.Version != 0 {
|
||||
t.Fatalf("角色=%s,不应自动继承系统接收人:%v", role, err)
|
||||
}
|
||||
if _, err := service.UpdateOwnWeChatPreferences(user.ID, true, false, false, 0); !errors.Is(err, ErrWeChatBinding) {
|
||||
t.Fatal("未绑定身份时不应直接启用微信推送")
|
||||
}
|
||||
settings := WeChatUserSettings{AppID: "wx-test-application", OpenID: "openid-" + string(role), Enabled: true}
|
||||
status, err = service.saveWeChatSettings(user.ID, settings, 0)
|
||||
if err != nil || status.Settings != settings || status.Version != 1 {
|
||||
t.Fatalf("角色=%s,保存独立接收人失败:%v", role, err)
|
||||
}
|
||||
record, found, err := service.findUserSetting(weChatPluginID, user.ID)
|
||||
if err != nil || !found || record.SettingsCiphertext == "" || record.SettingsCiphertext == settings.OpenID {
|
||||
t.Fatal("微信个人身份必须加密保存")
|
||||
}
|
||||
if _, err := service.UpdateOwnWeChatPreferences(user.ID, false, false, false, 0); !errors.Is(err, ErrPluginSettingConflict) {
|
||||
t.Fatal("旧版本不应覆盖绑定设置")
|
||||
}
|
||||
status, err = service.UpdateOwnWeChatPreferences(user.ID, false, false, false, 1)
|
||||
if err != nil || status.Settings.OpenID != settings.OpenID || status.Settings.Enabled {
|
||||
t.Fatal("停用推送应保留本人绑定")
|
||||
}
|
||||
status, err = service.UpdateOwnWeChatPreferences(user.ID, false, false, true, 2)
|
||||
if err != nil || status.Settings.OpenID != "" || status.Settings.Enabled || status.Version != 3 {
|
||||
t.Fatal("解绑应清除本人接收身份")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeChatBindingStatusRejectsOtherSessionsAndRevokedAccounts(t *testing.T) {
|
||||
service, engine := newPluginConfigService(t)
|
||||
user := insertPluginTestUser(t, engine, "wechat-binding", access.RoleSuperAdmin)
|
||||
other := insertPluginTestUser(t, engine, "wechat-other", access.RoleUser)
|
||||
actor := NotificationBindingActor{UserID: user.ID, SessionVersion: user.SessionVersion,
|
||||
SecurityVersion: user.SecurityVersion, SessionHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}
|
||||
now := time.Now().UTC()
|
||||
service.now = func() time.Time { return now }
|
||||
service.wechatBindings = map[string]weChatBinding{"ticket": {Actor: actor, Status: WeChatBindingStatus{Phase: "waiting", ExpiresAt: now.Add(time.Minute)}}}
|
||||
if _, err := service.GetWeChatBinding(actor, "ticket"); err != nil {
|
||||
t.Fatalf("本人会话应能读取绑定状态:%v", err)
|
||||
}
|
||||
otherActor := actor
|
||||
otherActor.UserID = other.ID
|
||||
if _, err := service.GetWeChatBinding(otherActor, "ticket"); !errors.Is(err, ErrWeChatBinding) {
|
||||
t.Fatal("其他账户不应读取绑定请求")
|
||||
}
|
||||
otherActor = actor
|
||||
otherActor.SessionHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
||||
if _, err := service.GetWeChatBinding(otherActor, "ticket"); !errors.Is(err, ErrWeChatBinding) {
|
||||
t.Fatal("同一账户的其他会话不应接管绑定")
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
if _, err := service.GetWeChatBinding(actor, "ticket"); !errors.Is(err, ErrWeChatBinding) {
|
||||
t.Fatal("过期绑定不应继续使用")
|
||||
}
|
||||
now = now.Add(-2 * time.Minute)
|
||||
if _, err := engine.ID(user.ID).Cols("disabled").Update(&models.User{Disabled: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.GetWeChatBinding(actor, "ticket"); !errors.Is(err, ErrWeChatBinding) {
|
||||
t.Fatal("被禁用账户的绑定必须立即失效")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeChatTestMessagesAreRateLimitedPerAccount(t *testing.T) {
|
||||
service, engine := newPluginConfigService(t)
|
||||
first := insertPluginTestUser(t, engine, "wechat-rate-first", access.RoleUser)
|
||||
second := insertPluginTestUser(t, engine, "wechat-rate-second", access.RoleUser)
|
||||
now := time.Now().UTC()
|
||||
service.now = func() time.Time { return now }
|
||||
if service.ReserveWeChatTest(first.ID) != nil || service.ReserveWeChatTest(second.ID) != nil {
|
||||
t.Fatal("不同账户首次测试应相互独立")
|
||||
}
|
||||
if service.ReserveWeChatTest(first.ID) == nil {
|
||||
t.Fatal("短时间内不应重复发送测试消息")
|
||||
}
|
||||
now = now.Add(30 * time.Second)
|
||||
if err := service.ReserveWeChatTest(first.ID); err != nil {
|
||||
t.Fatalf("冷却结束后应允许重试:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryRoleKeepsIndependentOwnTelegramSettings(t *testing.T) {
|
||||
service, engine := newPluginConfigService(t)
|
||||
var users []models.User
|
||||
|
||||
289
backend/services/plugin/wechat_binding.go
Normal file
289
backend/services/plugin/wechat_binding.go
Normal file
@ -0,0 +1,289 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
"git.pcux.cn/xuans/Smail/utils/credential"
|
||||
)
|
||||
|
||||
const WeChatCallbackPath = "/api/v1/plugin/binding/wechat/callback"
|
||||
|
||||
// NotificationBindingActor 将绑定限制在发起扫码的登录会话,角色不影响本人绑定。
|
||||
type NotificationBindingActor struct {
|
||||
UserID int
|
||||
SessionVersion int
|
||||
SecurityVersion int
|
||||
SessionHash string
|
||||
}
|
||||
|
||||
type WeChatBindingStatus struct {
|
||||
Ticket string `json:"ticket,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Phase string `json:"phase"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type weChatBinding struct {
|
||||
Actor NotificationBindingActor
|
||||
Version uint64
|
||||
Fingerprint string
|
||||
Settings WeChatUserSettings
|
||||
Status WeChatBindingStatus
|
||||
}
|
||||
|
||||
type weChatBindingConfig struct {
|
||||
AppID string
|
||||
Secret string
|
||||
Callback string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
func (s *ConfigService) weChatConfiguration() (weChatBindingConfig, error) {
|
||||
var result weChatBindingConfig
|
||||
manager := config.CurrentManager()
|
||||
current := config.CurrentConfig()
|
||||
if manager == nil || current == nil {
|
||||
return result, WeChatNotice("请联系超级管理员配置微信公众号")
|
||||
}
|
||||
instance, exists := manager.Snapshot().Plugins.Plugins[weChatPluginID]
|
||||
if !exists || !instance.Enabled {
|
||||
return result, WeChatNotice("请联系超级管理员配置并启用微信公众号")
|
||||
}
|
||||
var settings struct {
|
||||
AppID string `json:"appId"`
|
||||
TemplateID string `json:"templateId"`
|
||||
WebDomain string `json:"webDomain"`
|
||||
}
|
||||
if json.Unmarshal(instance.Settings, &settings) != nil || !validWeChatIdentity(settings.AppID) || settings.TemplateID == "" {
|
||||
return result, WeChatNotice("请联系超级管理员补全微信公众号配置")
|
||||
}
|
||||
domain := strings.TrimSpace(current.WebDomain)
|
||||
parsed, err := url.Parse("https://" + domain)
|
||||
if err != nil || domain == "" || parsed.Host != domain || parsed.User != nil || parsed.Path != "" ||
|
||||
parsed.RawQuery != "" || parsed.Fragment != "" || !strings.EqualFold(settings.WebDomain, domain) {
|
||||
return result, WeChatNotice("公众号授权域名与后台域名不一致,请联系超级管理员")
|
||||
}
|
||||
secret, err := s.openSecretForPlugin(weChatPluginID, "appSecret")
|
||||
if err != nil || secret == "" {
|
||||
return result, WeChatNotice("请联系超级管理员配置微信公众号密钥")
|
||||
}
|
||||
fingerprint, err := s.vault.Fingerprint(settings.AppID + "\x00" + secret + "\x00" + domain)
|
||||
if err != nil {
|
||||
return result, ErrPluginStoreUnavailable
|
||||
}
|
||||
return weChatBindingConfig{AppID: settings.AppID, Secret: secret, Callback: "https://" + domain + WeChatCallbackPath, Fingerprint: fingerprint}, nil
|
||||
}
|
||||
|
||||
func (s *ConfigService) WeChatBindingAvailability() (bool, string) {
|
||||
if _, err := s.weChatConfiguration(); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func (s *ConfigService) validateBindingActor(actor NotificationBindingActor) error {
|
||||
user, err := s.activeNotificationUser(actor.UserID)
|
||||
if err != nil || len(actor.SessionHash) != 64 || user.SessionVersion != actor.SessionVersion || user.SecurityVersion != actor.SecurityVersion {
|
||||
return ErrWeChatBinding
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ConfigService) BeginWeChatBinding(actor NotificationBindingActor) (WeChatBindingStatus, error) {
|
||||
if err := s.validateBindingActor(actor); err != nil {
|
||||
return WeChatBindingStatus{}, err
|
||||
}
|
||||
configuration, err := s.weChatConfiguration()
|
||||
if err != nil {
|
||||
return WeChatBindingStatus{}, err
|
||||
}
|
||||
current, err := s.GetOwnWeChatSettings(actor.UserID)
|
||||
if err != nil {
|
||||
return WeChatBindingStatus{}, err
|
||||
}
|
||||
var nonce [32]byte
|
||||
if _, err := rand.Read(nonce[:]); err != nil {
|
||||
return WeChatBindingStatus{}, ErrPluginStoreUnavailable
|
||||
}
|
||||
ticket := base64.RawURLEncoding.EncodeToString(nonce[:])
|
||||
now := s.currentTime()
|
||||
status := WeChatBindingStatus{Ticket: ticket, Phase: "waiting", ExpiresAt: now.Add(5 * time.Minute)}
|
||||
s.bindingMu.Lock()
|
||||
defer s.bindingMu.Unlock()
|
||||
if s.wechatBindings == nil {
|
||||
s.wechatBindings = make(map[string]weChatBinding)
|
||||
}
|
||||
for key, binding := range s.wechatBindings {
|
||||
if !binding.Status.ExpiresAt.After(now) || binding.Actor.UserID == actor.UserID {
|
||||
delete(s.wechatBindings, key)
|
||||
}
|
||||
}
|
||||
if len(s.wechatBindings) >= 2048 {
|
||||
return WeChatBindingStatus{}, WeChatNotice("当前绑定请求较多,请稍后重试")
|
||||
}
|
||||
s.wechatBindings[ticket] = weChatBinding{Actor: actor, Version: current.Version, Fingerprint: configuration.Fingerprint, Status: status}
|
||||
query := url.Values{"appid": {configuration.AppID}, "redirect_uri": {configuration.Callback},
|
||||
"response_type": {"code"}, "scope": {"snsapi_userinfo"}, "state": {ticket}}
|
||||
status.URL = "https://open.weixin.qq.com/connect/oauth2/authorize?" + query.Encode() + "#wechat_redirect"
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (s *ConfigService) GetWeChatBinding(actor NotificationBindingActor, ticket string) (WeChatBindingStatus, error) {
|
||||
if err := s.validateBindingActor(actor); err != nil {
|
||||
return WeChatBindingStatus{}, err
|
||||
}
|
||||
s.bindingMu.Lock()
|
||||
defer s.bindingMu.Unlock()
|
||||
binding, found := s.wechatBindings[ticket]
|
||||
if !found || binding.Actor != actor || !binding.Status.ExpiresAt.After(s.currentTime()) {
|
||||
return WeChatBindingStatus{}, ErrWeChatBinding
|
||||
}
|
||||
return binding.Status, nil
|
||||
}
|
||||
|
||||
func (s *ConfigService) CancelWeChatBinding(actor NotificationBindingActor, ticket string) error {
|
||||
if _, err := s.GetWeChatBinding(actor, ticket); err != nil {
|
||||
return err
|
||||
}
|
||||
s.bindingMu.Lock()
|
||||
delete(s.wechatBindings, ticket)
|
||||
s.bindingMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClaimWeChatBinding 只认领一次外部身份,不直接写入个人设置;仍需原页面确认。
|
||||
func (s *ConfigService) ClaimWeChatBinding(ctx context.Context, ticket, code string) error {
|
||||
if s == nil || len(ticket) != 43 || code == "" || len(code) > 512 {
|
||||
return ErrWeChatBinding
|
||||
}
|
||||
s.bindingMu.Lock()
|
||||
binding, found := s.wechatBindings[ticket]
|
||||
if !found || binding.Status.Phase != "waiting" || !binding.Status.ExpiresAt.After(s.currentTime()) {
|
||||
s.bindingMu.Unlock()
|
||||
return ErrWeChatBinding
|
||||
}
|
||||
binding.Status.Phase = "processing"
|
||||
s.wechatBindings[ticket] = binding
|
||||
s.bindingMu.Unlock()
|
||||
accepted := false
|
||||
defer func() {
|
||||
if !accepted {
|
||||
s.bindingMu.Lock()
|
||||
delete(s.wechatBindings, ticket)
|
||||
s.bindingMu.Unlock()
|
||||
}
|
||||
}()
|
||||
if err := s.validateBindingActor(binding.Actor); err != nil {
|
||||
return err
|
||||
}
|
||||
configuration, err := s.weChatConfiguration()
|
||||
if err != nil || configuration.Fingerprint != binding.Fingerprint {
|
||||
return ErrWeChatBinding
|
||||
}
|
||||
var token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
OpenID string `json:"openid"`
|
||||
ErrorCode int `json:"errcode"`
|
||||
}
|
||||
if err := requestWeChatBindingJSON(ctx, "/sns/oauth2/access_token", url.Values{
|
||||
"appid": {configuration.AppID}, "secret": {configuration.Secret}, "code": {code}, "grant_type": {"authorization_code"},
|
||||
}, &token); err != nil || token.ErrorCode != 0 || !validWeChatIdentity(token.OpenID) || token.AccessToken == "" || len(token.AccessToken) > 4096 {
|
||||
return WeChatNotice("微信授权失败,请重新扫码")
|
||||
}
|
||||
var profile struct {
|
||||
OpenID string `json:"openid"`
|
||||
Nickname string `json:"nickname"`
|
||||
ErrorCode int `json:"errcode"`
|
||||
}
|
||||
if err := requestWeChatBindingJSON(ctx, "/sns/userinfo", url.Values{
|
||||
"access_token": {token.AccessToken}, "openid": {token.OpenID}, "lang": {"zh_CN"},
|
||||
}, &profile); err != nil || profile.ErrorCode != 0 || profile.OpenID != token.OpenID || len(profile.Nickname) > 256 {
|
||||
return WeChatNotice("微信身份读取失败,请重新扫码")
|
||||
}
|
||||
binding.Settings = WeChatUserSettings{AppID: configuration.AppID, OpenID: token.OpenID, DisplayName: profile.Nickname, Enabled: true}
|
||||
binding.Status.Phase = "claimed"
|
||||
binding.Status.DisplayName = profile.Nickname
|
||||
if binding.Status.DisplayName == "" {
|
||||
binding.Status.DisplayName = "微信用户 · " + token.OpenID[len(token.OpenID)-6:]
|
||||
}
|
||||
s.bindingMu.Lock()
|
||||
defer s.bindingMu.Unlock()
|
||||
if current, exists := s.wechatBindings[ticket]; !exists || current.Status.Phase != "processing" || !current.Status.ExpiresAt.After(s.currentTime()) {
|
||||
return ErrWeChatBinding
|
||||
}
|
||||
s.wechatBindings[ticket] = binding
|
||||
accepted = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ConfigService) ConfirmWeChatBinding(actor NotificationBindingActor, ticket string) (WeChatUserSettingsStatus, error) {
|
||||
if err := s.validateBindingActor(actor); err != nil {
|
||||
return WeChatUserSettingsStatus{}, err
|
||||
}
|
||||
configuration, err := s.weChatConfiguration()
|
||||
if err != nil {
|
||||
return WeChatUserSettingsStatus{}, err
|
||||
}
|
||||
s.bindingMu.Lock()
|
||||
defer s.bindingMu.Unlock()
|
||||
binding, found := s.wechatBindings[ticket]
|
||||
if !found || binding.Actor != actor || binding.Status.Phase != "claimed" ||
|
||||
!binding.Status.ExpiresAt.After(s.currentTime()) || binding.Fingerprint != configuration.Fingerprint {
|
||||
return WeChatUserSettingsStatus{}, ErrWeChatBinding
|
||||
}
|
||||
delete(s.wechatBindings, ticket)
|
||||
return s.saveWeChatSettings(actor.UserID, binding.Settings, binding.Version)
|
||||
}
|
||||
|
||||
func (s *ConfigService) ReserveWeChatTest(userID int) error {
|
||||
if _, err := s.activeNotificationUser(userID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.weChatTestMu.Lock()
|
||||
defer s.weChatTestMu.Unlock()
|
||||
now := s.currentTime()
|
||||
if s.weChatTestAttempts == nil {
|
||||
s.weChatTestAttempts = make(map[int]time.Time)
|
||||
}
|
||||
for id, last := range s.weChatTestAttempts {
|
||||
if now.Sub(last) >= 30*time.Second {
|
||||
delete(s.weChatTestAttempts, id)
|
||||
}
|
||||
}
|
||||
if _, exists := s.weChatTestAttempts[userID]; exists || len(s.weChatTestAttempts) >= 4096 {
|
||||
return WeChatNotice("测试消息发送过于频繁,请三十秒后重试")
|
||||
}
|
||||
s.weChatTestAttempts[userID] = now
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestWeChatBindingJSON(ctx context.Context, path string, query url.Values, target any) error {
|
||||
client := credential.NewRestrictedHTTPClientForTargets(10_000, []credential.NetworkTarget{{Scheme: "https", Host: "api.weixin.qq.com", Port: 443}})
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.weixin.qq.com"+path+"?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return ErrWeChatBinding
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return ErrWeChatBinding
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return ErrWeChatBinding
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, (64<<10)+1))
|
||||
if err != nil || len(data) > 64<<10 || json.Unmarshal(data, target) != nil {
|
||||
return ErrWeChatBinding
|
||||
}
|
||||
return nil
|
||||
}
|
||||
191
backend/services/plugin/wechat_settings.go
Normal file
191
backend/services/plugin/wechat_settings.go
Normal file
@ -0,0 +1,191 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
"git.pcux.cn/xuans/Smail/services/access"
|
||||
"git.pcux.cn/xuans/Smail/utils/credential"
|
||||
)
|
||||
|
||||
const weChatPluginID = "wechat_push"
|
||||
|
||||
var ErrWeChatBinding = errors.New("微信绑定已失效,请重新扫码")
|
||||
|
||||
// WeChatNotice 仅承载可公开的固定业务提示,数据库和外部请求错误不得使用此类型。
|
||||
type WeChatNotice string
|
||||
|
||||
func (notice WeChatNotice) Error() string { return string(notice) }
|
||||
|
||||
// WeChatUserSettings 只通过已验证的扫码流程写入接收身份,不接受页面直接指定 OpenID。
|
||||
type WeChatUserSettings struct {
|
||||
AppID string `json:"appId"`
|
||||
OpenID string `json:"openId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ShowContent bool `json:"showContent"`
|
||||
}
|
||||
|
||||
type WeChatUserSettingsStatus struct {
|
||||
Settings WeChatUserSettings
|
||||
Version uint64
|
||||
}
|
||||
|
||||
type WeChatUserSettingsEntry struct {
|
||||
UserID int
|
||||
Settings WeChatUserSettings
|
||||
}
|
||||
|
||||
func (s *ConfigService) activeNotificationUser(userID int) (models.User, error) {
|
||||
if s == nil || s.engine == nil || s.vault == nil || userID <= 0 {
|
||||
return models.User{}, ErrPluginStoreUnavailable
|
||||
}
|
||||
user, found, err := s.findUser(userID)
|
||||
if err != nil || !found || user.Disabled != 0 {
|
||||
return models.User{}, ErrPluginStoreUnavailable
|
||||
}
|
||||
if err := access.Authorize(access.Request{ActorRole: user.EffectiveRole(), Capability: access.CapabilityPluginWriteOwn, OwnResource: true}); err != nil {
|
||||
return models.User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *ConfigService) GetOwnWeChatSettings(userID int) (WeChatUserSettingsStatus, error) {
|
||||
if _, err := s.activeNotificationUser(userID); err != nil {
|
||||
return WeChatUserSettingsStatus{}, err
|
||||
}
|
||||
record, found, err := s.findUserSetting(weChatPluginID, userID)
|
||||
if err != nil || !found {
|
||||
return WeChatUserSettingsStatus{}, err
|
||||
}
|
||||
settings, err := s.decodeWeChatSettings(record)
|
||||
return WeChatUserSettingsStatus{Settings: settings, Version: record.Version}, err
|
||||
}
|
||||
|
||||
func (s *ConfigService) decodeWeChatSettings(record models.PluginUserSetting) (WeChatUserSettings, error) {
|
||||
var settings WeChatUserSettings
|
||||
if record.SchemaVersion != userSettingsSchemaVersion {
|
||||
return settings, ErrPluginStoreUnavailable
|
||||
}
|
||||
plaintext, err := s.vault.Decrypt(credential.CurrentVersion, record.SettingsCiphertext)
|
||||
if err != nil {
|
||||
return settings, ErrPluginStoreUnavailable
|
||||
}
|
||||
fingerprint, err := s.vault.Fingerprint(plaintext)
|
||||
if err != nil || fingerprint != record.SettingsHash || !json.Valid([]byte(plaintext)) {
|
||||
return settings, ErrPluginStoreUnavailable
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(plaintext))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&settings); err != nil || !validWeChatSettings(settings) {
|
||||
return WeChatUserSettings{}, ErrPluginStoreUnavailable
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func validWeChatIdentity(value string) bool {
|
||||
if len(value) < 8 || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '-' || char == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validWeChatSettings(settings WeChatUserSettings) bool {
|
||||
if len(settings.DisplayName) > 256 {
|
||||
return false
|
||||
}
|
||||
if settings.OpenID == "" {
|
||||
return settings.AppID == "" && !settings.Enabled
|
||||
}
|
||||
return validWeChatIdentity(settings.OpenID) && validWeChatIdentity(settings.AppID)
|
||||
}
|
||||
|
||||
func (s *ConfigService) saveWeChatSettings(userID int, settings WeChatUserSettings, expectedVersion uint64) (WeChatUserSettingsStatus, error) {
|
||||
if _, err := s.activeNotificationUser(userID); err != nil {
|
||||
return WeChatUserSettingsStatus{}, err
|
||||
}
|
||||
if !validWeChatSettings(settings) {
|
||||
return WeChatUserSettingsStatus{}, ErrWeChatBinding
|
||||
}
|
||||
payload, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return WeChatUserSettingsStatus{}, ErrPluginStoreUnavailable
|
||||
}
|
||||
ciphertext, err := s.vault.Encrypt(string(payload))
|
||||
if err != nil {
|
||||
return WeChatUserSettingsStatus{}, ErrPluginStoreUnavailable
|
||||
}
|
||||
fingerprint, err := s.vault.Fingerprint(string(payload))
|
||||
if err != nil {
|
||||
return WeChatUserSettingsStatus{}, ErrPluginStoreUnavailable
|
||||
}
|
||||
current, found, err := s.findUserSetting(weChatPluginID, userID)
|
||||
if err != nil {
|
||||
return WeChatUserSettingsStatus{}, err
|
||||
}
|
||||
if current.Version != expectedVersion || !found && expectedVersion != 0 {
|
||||
return WeChatUserSettingsStatus{}, ErrPluginSettingConflict
|
||||
}
|
||||
now := s.currentTime()
|
||||
record := models.PluginUserSetting{PluginID: weChatPluginID, UserID: userID, SchemaVersion: userSettingsSchemaVersion,
|
||||
SettingsCiphertext: ciphertext, SettingsHash: fingerprint, Enabled: true, Version: expectedVersion + 1, CreatedAt: now, UpdatedAt: now}
|
||||
if !found {
|
||||
if _, err := s.engine.Insert(&record); err != nil {
|
||||
return WeChatUserSettingsStatus{}, ErrPluginSettingConflict
|
||||
}
|
||||
} else {
|
||||
changed, err := s.engine.ID(current.ID).Where("version = ?", expectedVersion).
|
||||
Cols("schema_version", "settings_ciphertext", "settings_hash", "enabled", "version", "updated_at").Update(&record)
|
||||
if err != nil || changed != 1 {
|
||||
return WeChatUserSettingsStatus{}, ErrPluginSettingConflict
|
||||
}
|
||||
}
|
||||
return WeChatUserSettingsStatus{Settings: settings, Version: record.Version}, nil
|
||||
}
|
||||
|
||||
func (s *ConfigService) UpdateOwnWeChatPreferences(userID int, enabled, showContent, unbind bool, expectedVersion uint64) (WeChatUserSettingsStatus, error) {
|
||||
current, err := s.GetOwnWeChatSettings(userID)
|
||||
if err != nil {
|
||||
return current, err
|
||||
}
|
||||
settings := current.Settings
|
||||
if unbind {
|
||||
settings = WeChatUserSettings{}
|
||||
} else {
|
||||
settings.Enabled, settings.ShowContent = enabled, showContent
|
||||
}
|
||||
return s.saveWeChatSettings(userID, settings, expectedVersion)
|
||||
}
|
||||
|
||||
func (s *ConfigService) ListWeChatUserSettings() ([]WeChatUserSettingsEntry, error) {
|
||||
if s == nil || s.engine == nil || s.vault == nil {
|
||||
return nil, ErrPluginStoreUnavailable
|
||||
}
|
||||
var records []models.PluginUserSetting
|
||||
if err := s.engine.Where("plugin_id = ? AND enabled = ?", weChatPluginID, true).Find(&records); err != nil {
|
||||
return nil, ErrPluginStoreUnavailable
|
||||
}
|
||||
entries := make([]WeChatUserSettingsEntry, 0, len(records))
|
||||
for _, record := range records {
|
||||
user, found, err := s.findUser(record.UserID)
|
||||
if err != nil {
|
||||
return nil, ErrPluginStoreUnavailable
|
||||
}
|
||||
if !found || user.Disabled != 0 {
|
||||
continue
|
||||
}
|
||||
settings, err := s.decodeWeChatSettings(record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, WeChatUserSettingsEntry{UserID: record.UserID, Settings: settings})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user