diff --git a/backend/controllers/plugin_wechat.go b/backend/controllers/plugin_wechat.go new file mode 100644 index 0000000..dc2951f --- /dev/null +++ b/backend/controllers/plugin_wechat.go @@ -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) +} diff --git a/backend/hooks/base.go b/backend/hooks/base.go index 2cec823..a792b34 100644 --- a/backend/hooks/base.go +++ b/backend/hooks/base.go @@ -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") diff --git a/backend/hooks/wechat_push/wechat_push.go b/backend/hooks/wechat_push/wechat_push.go index 471628e..032c7f0 100644 --- a/backend/hooks/wechat_push/wechat_push.go +++ b/backend/hooks/wechat_push/wechat_push.go @@ -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 } diff --git a/backend/hooks/wechat_push/wechat_push_test.go b/backend/hooks/wechat_push/wechat_push_test.go index 034e90d..263339a 100644 --- a/backend/hooks/wechat_push/wechat_push_test.go +++ b/backend/hooks/wechat_push/wechat_push_test.go @@ -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 { diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index c426d6f..732ebc9 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -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))) } diff --git a/backend/services/plugin/config.go b/backend/services/plugin/config.go index 007defb..b1e62f5 100644 --- a/backend/services/plugin/config.go +++ b/backend/services/plugin/config.go @@ -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) { diff --git a/backend/services/plugin/config_test.go b/backend/services/plugin/config_test.go index 39e8c2f..5aaa01f 100644 --- a/backend/services/plugin/config_test.go +++ b/backend/services/plugin/config_test.go @@ -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 diff --git a/backend/services/plugin/wechat_binding.go b/backend/services/plugin/wechat_binding.go new file mode 100644 index 0000000..d605210 --- /dev/null +++ b/backend/services/plugin/wechat_binding.go @@ -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 +} diff --git a/backend/services/plugin/wechat_settings.go b/backend/services/plugin/wechat_settings.go new file mode 100644 index 0000000..6a44629 --- /dev/null +++ b/backend/services/plugin/wechat_settings.go @@ -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 +}