Smail/backend/hooks/wechat_push/wechat_push.go
smail 781e2c1559
Some checks are pending
Security Checks / security (push) Waiting to run
微信验证码提取基础实现检查点
2026-09-27 23:53:33 +08:00

422 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"encoding/json"
"fmt"
"git.pcux.cn/xuans/Smail/dto/parsemail"
"git.pcux.cn/xuans/Smail/hooks/framework"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/credential"
log "github.com/sirupsen/logrus"
"io"
"net"
"net/http"
urlpkg "net/url"
"os"
"strings"
"sync"
"time"
"unicode/utf8"
)
const (
maxWeChatResponseBytes = 1 << 20
maxWeChatMessageBytes = 4 << 10
)
var weChatHTTPClient = credential.NewRestrictedHTTPClient(10_000)
type accessTokenRes struct {
AccessToken string `db:"access_token" json:"access_token"`
ExpiresIn int `db:"expires_in" json:"expires_in"`
}
type WeChatPushHook struct {
appId string
secret string
tokenMu sync.Mutex
token string
tokenExpires int64
templateId string
pushUser string
webDomain string
httpClient *http.Client
}
func (w *WeChatPushHook) GetName(ctx *context.Context) string {
return "WeChatPushHook"
}
// Capabilities 声明插件需要的能力和配置字段。
func (w *WeChatPushHook) Capabilities() framework.Capabilities {
return framework.Capabilities{
PluginID: "wechat_push",
Capabilities: []string{"network"},
Settings: []string{"appId", "templateId", "userId", "webDomain"},
Protocol: 1,
}
}
type weChatRuntimeConfig struct {
PluginID string `json:"pluginId"`
Generation uint64 `json:"generation"`
Settings json.RawMessage `json:"settings,omitempty"`
Secrets map[string]string `json:"secrets,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
Network []framework.PluginNetworkTarget `json:"network,omitempty"`
}
type weChatSettings struct {
AppID string `json:"appId"`
TemplateID string `json:"templateId"`
UserID string `json:"userId"`
WebDomain string `json:"webDomain"`
}
// ValidateConfig 只校验主程序下发的运行时配置,不读取本地配置文件或环境变量。
func (w *WeChatPushHook) ValidateConfig(data json.RawMessage) error {
_, _, _, err := parseWeChatRuntimeConfig(data)
return err
}
// Configure 应用主程序下发的运行时配置,并在配置变更后清理缓存令牌。
func (w *WeChatPushHook) Configure(data json.RawMessage) error {
_, settings, secret, err := parseWeChatRuntimeConfig(data)
if err != nil {
return err
}
w.tokenMu.Lock()
w.appId = settings.AppID
w.secret = secret
w.templateId = settings.TemplateID
w.pushUser = settings.UserID
w.webDomain = settings.WebDomain
w.httpClient = credential.NewRestrictedHTTPClientForTargets(10_000, []credential.NetworkTarget{{Scheme: "https", Host: "api.weixin.qq.com", Port: 443}})
w.token = ""
w.tokenExpires = 0
w.tokenMu.Unlock()
return nil
}
// Health 检查插件是否已经完成安全运行时配置。
func (w *WeChatPushHook) Health() error {
if w == nil {
return fmt.Errorf("微信插件不可用")
}
w.tokenMu.Lock()
configured := w.appId != "" && w.secret != "" && w.templateId != "" && w.pushUser != "" && w.webDomain != "" && w.httpClient != nil
w.tokenMu.Unlock()
if !configured {
return fmt.Errorf("微信插件尚未完成运行时配置")
}
return nil
}
// SettingsHtml 插件页面
func (w *WeChatPushHook) SettingsHtml(ctx *context.Context, url string, requestData string) string {
return fmt.Sprintf(`
<div>
暂无设置页面
</div>
`)
}
func (w *WeChatPushHook) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail) {
w.tokenMu.Lock()
appID := w.appId
secret := w.secret
pushUser := w.pushUser
w.tokenMu.Unlock()
if email == nil || appID == "" || secret == "" || pushUser == "" {
return
}
for _, u := range ue {
if u == nil {
continue
}
// 管理员(Uid=1)收到邮件且非已读、非已删除 触发通知
if u.UserID == 1 && u.IsRead == 0 && u.Status == 0 && email.MessageId > 0 {
w.sendUserMsg(ctx, pushUser, buildWeChatMessage(email))
}
}
}
func (w *WeChatPushHook) SendBefore(ctx *context.Context, email *parsemail.Email) {
}
func (w *WeChatPushHook) SendAfter(ctx *context.Context, email *parsemail.Email, err map[string]error) {
}
func (w *WeChatPushHook) ReceiveParseBefore(ctx *context.Context, email *[]byte) {
}
func (w *WeChatPushHook) ReceiveParseAfter(ctx *context.Context, email *parsemail.Email) {}
func (w *WeChatPushHook) getWxAccessToken() string {
w.tokenMu.Lock()
defer w.tokenMu.Unlock()
if w.token != "" && w.tokenExpires > time.Now().Unix() {
return w.token
}
appID, err := credential.ValidateSecret(w.appId, 256)
if err != nil {
return ""
}
secret, err := credential.ValidateSecret(w.secret, 4096)
if err != nil {
return ""
}
endpoint, _ := urlpkg.Parse("https://api.weixin.qq.com/cgi-bin/token")
query := endpoint.Query()
query.Set("grant_type", "client_credential")
query.Set("appid", appID)
query.Set("secret", secret)
endpoint.RawQuery = query.Encode()
request, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
if err != nil {
return ""
}
client := w.httpClient
if client == nil {
client = weChatHTTPClient
}
resp, err := client.Do(request)
if err != nil || resp == nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return ""
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxWeChatResponseBytes+1))
if err != nil || len(body) > maxWeChatResponseBytes {
return ""
}
var ret accessTokenRes
if json.Unmarshal(body, &ret) != nil || ret.ExpiresIn <= 0 {
return ""
}
if token, err := credential.ValidateSecret(ret.AccessToken, 4096); err == nil {
w.token = token
w.tokenExpires = time.Now().Unix() + int64(ret.ExpiresIn)
}
return w.token
}
type sendMsgRequest struct {
Touser string `db:"touser" json:"touser"`
Template_id string `db:"template_id" json:"template_id"`
Url string `db:"url" json:"url"`
Data SendData `db:"data" json:"data"`
}
type SendData struct {
Content DataItem `json:"Content"`
}
type DataItem struct {
Value string `json:"value"`
Color string `json:"color"`
}
func (w *WeChatPushHook) sendUserMsg(ctx *context.Context, userId string, content string) {
content = truncateWeChatText(content, maxWeChatMessageBytes)
w.tokenMu.Lock()
webDomain := w.webDomain
templateID := w.templateId
client := w.httpClient
w.tokenMu.Unlock()
if client == nil {
client = weChatHTTPClient
}
url := "https://" + webDomain
sendMsgReq, err := json.Marshal(sendMsgRequest{
Touser: userId,
Template_id: templateID,
Url: url,
Data: SendData{Content: DataItem{Value: content, Color: "#000000"}},
})
if err != nil {
log.WithContext(ctx).Error("wechat push request build failed")
return
}
token := w.getWxAccessToken()
if token == "" {
log.WithContext(ctx).Error("wechat push authentication failed")
return
}
endpoint, _ := urlpkg.Parse("https://api.weixin.qq.com/cgi-bin/message/template/send")
query := endpoint.Query()
query.Set("access_token", token)
endpoint.RawQuery = query.Encode()
request, err := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(sendMsgReq))
if err != nil {
log.WithContext(ctx).Error("wechat push request build failed")
return
}
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
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
log.WithContext(ctx).Error("wechat push request was rejected")
return
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxWeChatResponseBytes+1))
var result struct {
ErrorCode *int `json:"errcode"`
}
if err != nil || len(body) > maxWeChatResponseBytes || json.Unmarshal(body, &result) != nil || result.ErrorCode == nil {
log.WithContext(ctx).Error("微信推送响应无效或超过大小限制")
return
}
if *result.ErrorCode != 0 {
// 只记录业务码,不能输出可能含凭据或收件人信息的响应正文。
log.WithContext(ctx).WithField("errcode", *result.ErrorCode).Error("微信推送业务请求失败")
switch *result.ErrorCode {
case 40001, 40014, 42001:
w.tokenMu.Lock()
if w.token == token {
w.token, w.tokenExpires = "", 0
}
w.tokenMu.Unlock()
}
}
}
func buildWeChatMessage(email *parsemail.Email) string {
if email == nil {
return ""
}
if code, ok := extractWeChatVerificationCode(email); ok {
return "验证码:" + code
}
const prefix = "<<"
const separator = ">> "
subjectLimit := maxWeChatMessageBytes - len(prefix) - len(separator)
subject := truncateWeChatText(email.Subject, subjectLimit)
remaining := maxWeChatMessageBytes - len(prefix) - len(subject) - len(separator)
text := truncateWeChatText(string(email.Text), remaining)
return prefix + subject + separator + text
}
func truncateWeChatText(value string, maxBytes int) string {
if maxBytes <= 0 {
return ""
}
if len(value) <= maxBytes {
return value
}
cut := maxBytes
for cut > 0 && !utf8.RuneStart(value[cut]) {
cut--
}
return value[:cut]
}
func NewWechatPushHook() *WeChatPushHook {
return &WeChatPushHook{}
}
func parseWeChatRuntimeConfig(data json.RawMessage) (weChatRuntimeConfig, weChatSettings, string, error) {
var payload weChatRuntimeConfig
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信插件运行时配置无效")
}
if payload.PluginID != "wechat_push" {
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信插件 ID 不匹配")
}
if payload.Generation == 0 {
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信插件配置代次无效")
}
if !hasOnlyWeChatCapability(payload.Capabilities, "network") {
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信插件网络权限未配置")
}
if len(payload.Settings) == 0 || len(payload.Settings) > maxWeChatResponseBytes {
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信插件配置无效")
}
var settings weChatSettings
settingsDecoder := json.NewDecoder(bytes.NewReader(payload.Settings))
settingsDecoder.DisallowUnknownFields()
if err := settingsDecoder.Decode(&settings); err != nil || settingsDecoder.Decode(&struct{}{}) != io.EOF {
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信插件配置无效")
}
appID, err := credential.ValidateSecret(settings.AppID, 256)
if err != nil {
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信应用 ID 无效")
}
templateID, err := credential.ValidateSecret(settings.TemplateID, 256)
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("微信回调域名无效")
}
secret, err := credential.ValidateSecret(payload.Secrets["appSecret"], 4096)
if err != nil {
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信应用密钥未配置")
}
for name := range payload.Secrets {
if name != "appSecret" {
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信插件密钥字段无效")
}
}
if !hasOnlyWeChatNetworkTarget(payload.Network) {
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信网络权限未配置")
}
settings.AppID = appID
settings.TemplateID = templateID
settings.UserID = userID
settings.WebDomain = webDomain
return payload, settings, secret, nil
}
func hasOnlyWeChatCapability(capabilities []string, expected string) bool {
return len(capabilities) == 1 && capabilities[0] == expected
}
func hasOnlyWeChatNetworkTarget(targets []framework.PluginNetworkTarget) bool {
return len(targets) == 1 && strings.EqualFold(targets[0].Scheme, "https") && strings.EqualFold(targets[0].Host, "api.weixin.qq.com") && targets[0].Port == 443
}
func normalizeWeChatWebDomain(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" || len(value) > 253 || strings.ContainsAny(value, "/\\?#\x00\r\n\t ") {
return "", fmt.Errorf("invalid domain")
}
if net.ParseIP(strings.Trim(value, "[]")) != nil {
return "", fmt.Errorf("invalid domain")
}
parsed, err := urlpkg.Parse("https://" + value)
if err != nil || parsed.Host != value || parsed.Hostname() == "" || parsed.User != nil {
return "", fmt.Errorf("invalid domain")
}
return strings.TrimSuffix(strings.ToLower(parsed.Host), "."), nil
}
// 插件将以独立进程运行,因此需要主函数。
func main() {
if err := framework.CreatePlugin("wechat_push", NewWechatPushHook()).Run(); err != nil {
log.WithError(err).Error("wechat push plugin stopped")
os.Exit(1)
}
}