From 754a2578fb15442bcfb727d826dd8a42c2b57556 Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 28 Sep 2026 22:09:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=90=84=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E9=80=9A=E7=9F=A5=E7=BB=91=E5=AE=9A=E7=9A=84?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=E6=A3=80=E6=9F=A5=E5=92=8C=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/plugin_telegram.go | 102 +++++++++++++----- backend/services/plugin/config_test.go | 113 ++++++++++++++++++++ backend/services/plugin/telegram_binding.go | 15 ++- 3 files changed, 200 insertions(+), 30 deletions(-) diff --git a/backend/controllers/plugin_telegram.go b/backend/controllers/plugin_telegram.go index 6954d83b..624de106 100644 --- a/backend/controllers/plugin_telegram.go +++ b/backend/controllers/plugin_telegram.go @@ -2,6 +2,7 @@ package controllers import ( "encoding/json" + "io" "net/http" "git.pcux.cn/xuans/Smail/config" @@ -23,7 +24,9 @@ func telegramRequestService(ctx *appcontext.Context, w http.ResponseWriter, rece return nil } service := hooks.PluginConfigService() - if service == nil { response.NewErrorResponse(response.ServerError, "通知配置服务不可用", "").FPrint(w) } + if service == nil { + response.NewErrorResponse(response.ServerError, "通知配置服务不可用", "").FPrint(w) + } return service } @@ -32,7 +35,7 @@ func telegramPublicSettings(service *pluginservice.ConfigService, status plugins return map[string]any{ "version": status.Version, "bound": settings.ChatID != "", "displayName": settings.DisplayName, "needsRebind": service.TelegramBindingNeedsRebind(settings), - "enabled": settings.ChatID != "" && settings.Verified && !settings.Disabled, + "enabled": settings.ChatID != "" && settings.Verified && !settings.Disabled, "showContent": settings.ShowContent, "spoilerContent": settings.SpoilerContent, "sendAttachments": settings.SendAttachments, "disableLinkPreview": settings.DisableLinkPreview, } @@ -47,14 +50,21 @@ func writeTelegramUserSettings(w http.ResponseWriter, service *pluginservice.Con // TelegramUserSettings 的接收身份只能来自授权流程,页面仅维护本人偏好。 func TelegramUserSettings(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) { service := telegramRequestService(ctx, w, req.Method != http.MethodGet) - if service == nil { return } + if service == nil { + return + } if req.Method == http.MethodGet { status, err := service.GetOwnTelegramSettings(ctx.UserID) - if err != nil { writeTelegramSettingsError(w, err); return } + if err != nil { + writeTelegramSettingsError(w, err) + return + } data := telegramPublicSettings(service, status) configured, message := service.TelegramBindingAvailability() _, running := hooks.GetHook("telegram_push") - if configured && !running { message = "Telegram 推送插件未运行,请联系超级管理员" } + if configured && !running { + message = "Telegram 推送插件未运行,请联系超级管理员" + } data["configured"], data["running"], data["available"], data["message"] = configured, running, configured && running, message response.NewSuccessResponse(data).FPrint(w) return @@ -65,28 +75,43 @@ func TelegramUserSettings(ctx *appcontext.Context, w http.ResponseWriter, req *h return } var input struct { - Version uint64 `json:"version"` - Enabled bool `json:"enabled"` - ShowContent bool `json:"showContent"` - SpoilerContent bool `json:"spoilerContent"` - SendAttachments bool `json:"sendAttachments"` - DisableLinkPreview bool `json:"disableLinkPreview"` + Version uint64 `json:"version"` + Enabled bool `json:"enabled"` + ShowContent bool `json:"showContent"` + SpoilerContent bool `json:"spoilerContent"` + SendAttachments bool `json:"sendAttachments"` + DisableLinkPreview bool `json:"disableLinkPreview"` + } + if !decodePluginJSONBody(w, req, &input) { + return } - if !decodePluginJSONBody(w, req, &input) { return } preferences := pluginservice.TelegramUserSettings{Disabled: !input.Enabled, ShowContent: input.ShowContent, SpoilerContent: input.SpoilerContent, SendAttachments: input.SendAttachments, DisableLinkPreview: input.DisableLinkPreview} status, err := service.UpdateOwnTelegramPreferences(ctx.UserID, preferences, req.Method == http.MethodDelete, input.Version) - if err != nil { writeTelegramSettingsError(w, err); return } + if err != nil { + writeTelegramSettingsError(w, err) + return + } writeTelegramUserSettings(w, service, ctx.UserID, status) } func TelegramBinding(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 } + var input struct { + Action string `json:"action"` + Ticket string `json:"ticket"` + } + if !decodePluginJSONBody(w, req, &input) { + return + } service := telegramRequestService(ctx, w, input.Action == "begin" || input.Action == "confirm") - if service == nil { return } + if service == nil { + return + } actor, err := notificationBindingActor(ctx) - if err != nil { writeTelegramSettingsError(w, pluginservice.ErrTelegramBinding); return } + if err != nil { + writeTelegramSettingsError(w, pluginservice.ErrTelegramBinding) + return + } var result any switch input.Action { case "begin": @@ -102,11 +127,17 @@ func TelegramBinding(ctx *appcontext.Context, w http.ResponseWriter, req *http.R case "confirm": var status pluginservice.UserSettingsStatus status, err = service.ConfirmTelegramBinding(actor, input.Ticket) - if err == nil { writeTelegramUserSettings(w, service, ctx.UserID, status); return } + if err == nil { + writeTelegramUserSettings(w, service, ctx.UserID, status) + return + } default: err = pluginservice.TelegramNotice("不支持的绑定操作") } - if err != nil { writeTelegramSettingsError(w, err); return } + if err != nil { + writeTelegramSettingsError(w, err) + return + } response.NewSuccessResponse(result).FPrint(w) } @@ -116,7 +147,9 @@ func TelegramCallbackSettings(ctx *appcontext.Context, w http.ResponseWriter, re return } service := telegramRequestService(ctx, w, req.Method == http.MethodPost) - if service == nil { return } + if service == nil { + return + } var status pluginservice.TelegramCallbackStatus var err error switch req.Method { @@ -129,7 +162,10 @@ func TelegramCallbackSettings(ctx *appcontext.Context, w http.ResponseWriter, re http.Error(w, "请求方法不支持", http.StatusMethodNotAllowed) return } - if err != nil { writeTelegramSettingsError(w, err); return } + if err != nil { + writeTelegramSettingsError(w, err) + return + } response.NewSuccessResponse(status).FPrint(w) } @@ -138,10 +174,16 @@ func TelegramBindingCallback(w http.ResponseWriter, req *http.Request) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Content-Type-Options", "nosniff") service := hooks.PluginConfigService() - if !config.IsSetupReady() || service == nil { http.Error(w, "服务未就绪", http.StatusServiceUnavailable); return } + if !config.IsSetupReady() || service == nil { + http.Error(w, "服务未就绪", http.StatusServiceUnavailable) + return + } var update pluginservice.TelegramBindingUpdate decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, 64<<10)) - if decoder.Decode(&update) != nil { http.Error(w, "回调格式无效", http.StatusBadRequest); return } + if decoder.Decode(&update) != nil || decoder.Decode(new(any)) != io.EOF { + http.Error(w, "回调格式无效", http.StatusBadRequest) + return + } if err := service.ClaimTelegramBinding(req.Header.Get("X-Telegram-Bot-Api-Secret-Token"), update); err != nil { http.Error(w, "回调认证失败", http.StatusForbidden) return @@ -151,7 +193,9 @@ func TelegramBindingCallback(w http.ResponseWriter, req *http.Request) { func TestBoundTelegramMessage(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) { service := telegramRequestService(ctx, w, true) - if service == nil { return } + if service == nil { + return + } status, err := service.GetOwnTelegramSettings(ctx.UserID) if err != nil || !service.TelegramCanNotify(status.Settings) { writeTelegramSettingsError(w, pluginservice.TelegramNotice("请先绑定本人 Telegram 并启用通知")) @@ -159,8 +203,14 @@ func TestBoundTelegramMessage(ctx *appcontext.Context, w http.ResponseWriter, re } hook, _ := hooks.GetHook("telegram_push") provider, ok := hook.(framework.TestMessageProvider) - if !ok { writeTelegramSettingsError(w, pluginservice.TelegramNotice("Telegram 推送插件未运行,请联系超级管理员")); return } - if err := service.ReserveTelegramTest(ctx.UserID); err != nil { writeTelegramSettingsError(w, err); return } + if !ok { + writeTelegramSettingsError(w, pluginservice.TelegramNotice("Telegram 推送插件未运行,请联系超级管理员")) + return + } + if err := service.ReserveTelegramTest(ctx.UserID); err != nil { + writeTelegramSettingsError(w, err) + return + } if provider.TestMessage(pluginservice.TelegramDeliverySettings(status.Settings)) != nil { writeTelegramSettingsError(w, pluginservice.TelegramNotice("测试消息发送失败,请确认没有屏蔽机器人")) return diff --git a/backend/services/plugin/config_test.go b/backend/services/plugin/config_test.go index 377f3559..8d6debc6 100644 --- a/backend/services/plugin/config_test.go +++ b/backend/services/plugin/config_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "path/filepath" + "strconv" "strings" "testing" "time" @@ -381,6 +382,118 @@ func TestConfigServiceBulkTicketIsBoundOneTimeAndVersioned(t *testing.T) { } } +func TestEveryRoleKeepsIndependentOwnTelegramBinding(t *testing.T) { + service, engine := newPluginConfigService(t) + var users []models.User + for _, role := range []access.Role{access.RoleSuperAdmin, access.RoleAdmin, access.RoleUser} { + user := insertPluginTestUser(t, engine, "telegram-own-"+string(role), role) + users = append(users, user) + status, err := service.GetOwnTelegramSettings(user.ID) + if err != nil || status.Settings.ChatID != "" || status.Version != 0 { + t.Fatalf("角色 %s 不应自动继承系统接收人:%v", role, err) + } + if _, err := service.UpdateOwnTelegramPreferences(user.ID, TelegramUserSettings{}, false, 0); !errors.Is(err, ErrTelegramBinding) { + t.Fatal("未完成绑定时不应直接启用通知") + } + identity := TelegramUserSettings{ChatID: strconv.Itoa(10000 + user.ID), BotID: "123456", Verified: true, DisableLinkPreview: true} + if _, err := service.UpdateOwnTelegramSettings(user.ID, identity, 0); !errors.Is(err, ErrTelegramBinding) { + t.Fatal("任何角色都不能通过偏好设置伪造已授权身份") + } + if _, err := service.updateTelegramSettings(user.ID, identity, 0); err != nil { + t.Fatal(err) + } + status, err = service.UpdateOwnTelegramPreferences(user.ID, TelegramUserSettings{Disabled: true}, false, 1) + if err != nil || status.Settings.ChatID != identity.ChatID || !status.Settings.Verified || !status.Settings.Disabled || status.Version != 2 { + t.Fatalf("角色 %s 停用通知时应保留本人绑定:%v", role, err) + } + } + if _, err := service.UpdateOwnTelegramPreferences(users[0].ID, TelegramUserSettings{}, true, 2); err != nil { + t.Fatal(err) + } + for index, user := range users { + status, err := service.GetOwnTelegramSettings(user.ID) + if err != nil { + t.Fatal(err) + } + if index == 0 { + if status.Settings.ChatID != "" || status.Settings.Verified || status.Version != 3 { + t.Fatal("超级管理员应能单独解绑本人身份") + } + } else if status.Settings.ChatID != strconv.Itoa(10000+user.ID) || status.Version != 2 { + t.Fatal("超级管理员解绑不得改动其他账户的身份") + } + } +} + +func TestTelegramBindingTracksBotIdentityNotRuntimeAvailability(t *testing.T) { + service, _ := newPluginConfigService(t) + settings := TelegramUserSettings{ChatID: "456789", BotID: "123456", Verified: true} + for index, botID := range []string{"123456", "123456", "987654"} { + _, err := service.ChangeSecret(telegramPluginID, "botToken", config.SecretChange{ + Operation: config.SecretReplace, + Value: &config.SecretInput{Plaintext: botID + ":" + strings.Repeat(string(rune('a'+index)), 32)}, + }, uint64(index)) + if err != nil { + t.Fatal(err) + } + if got := service.TelegramBindingNeedsRebind(settings); got != (index == 2) { + t.Fatal("同一机器人轮换令牌或临时停用不应强制重绑,更换机器人必须重绑") + } + } + settings.Verified = false + if !service.TelegramBindingNeedsRebind(settings) { + t.Fatal("旧版手填身份必须重新授权") + } + if service.TelegramBindingNeedsRebind(TelegramUserSettings{}) { + t.Fatal("尚未绑定不应显示为需要重新绑定") + } +} + +func TestTelegramBindingCancellationRemainsAccountAndSessionScoped(t *testing.T) { + service, engine := newPluginConfigService(t) + super := insertPluginTestUser(t, engine, "telegram-cancel-super", access.RoleSuperAdmin) + user := insertPluginTestUser(t, engine, "telegram-cancel-user", access.RoleUser) + actor := NotificationBindingActor{UserID: super.ID, SessionHash: strings.Repeat("a", 64), + SessionVersion: super.SessionVersion, SecurityVersion: super.SecurityVersion} + now := time.Now().UTC() + service.now = func() time.Time { return now } + service.telegramBindings = map[string]telegramBinding{"ticket": {Actor: actor, + Status: TelegramBindingStatus{Phase: "waiting", ExpiresAt: now.Add(time.Minute)}}} + if _, err := service.GetTelegramBinding(actor, "ticket"); err != nil { + t.Fatal(err) + } + other := actor + other.SessionHash = strings.Repeat("b", 64) + if err := service.CancelTelegramBinding(other, "ticket"); !errors.Is(err, ErrTelegramBinding) { + t.Fatal("同一账户的其他会话不能取消绑定") + } + other = NotificationBindingActor{UserID: user.ID, SessionHash: strings.Repeat("c", 64), + SessionVersion: user.SessionVersion, SecurityVersion: user.SecurityVersion} + if _, err := service.GetTelegramBinding(other, "ticket"); !errors.Is(err, ErrTelegramBinding) { + t.Fatal("其他账户不能读取超级管理员的绑定请求") + } + if err := service.CancelTelegramBinding(other, "ticket"); !errors.Is(err, ErrTelegramBinding) { + t.Fatal("其他账户不能取消超级管理员的绑定请求") + } + now = now.Add(2 * time.Minute) + if _, err := service.GetTelegramBinding(actor, "ticket"); !errors.Is(err, ErrTelegramBinding) { + t.Fatal("过期请求不能继续确认") + } + for attempt := 0; attempt < 2; attempt++ { + if err := service.CancelTelegramBinding(actor, "ticket"); err != nil { + t.Fatal("本人的过期请求应能幂等取消", err) + } + } + service.telegramBindings["revoked"] = telegramBinding{Actor: actor, + Status: TelegramBindingStatus{Phase: "waiting", ExpiresAt: now.Add(time.Minute)}} + if _, err := engine.ID(super.ID).Cols("security_version").Update(&models.User{SecurityVersion: super.SecurityVersion + 1}); err != nil { + t.Fatal(err) + } + if _, err := service.GetTelegramBinding(actor, "revoked"); !errors.Is(err, ErrTelegramBinding) { + t.Fatal("安全版本变化必须撤销旧会话的绑定权限") + } +} + func newPluginConfigService(t *testing.T) (*ConfigService, *xorm.Engine) { t.Helper() engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "plugin-config.db")) diff --git a/backend/services/plugin/telegram_binding.go b/backend/services/plugin/telegram_binding.go index cb5bf280..4723b0eb 100644 --- a/backend/services/plugin/telegram_binding.go +++ b/backend/services/plugin/telegram_binding.go @@ -134,7 +134,9 @@ type TelegramCallbackStatus struct { } func (s *ConfigService) TelegramBindingAvailability() (bool, string) { - if _, err := s.telegramConfiguration(); err != nil { return false, err.Error() } + if _, err := s.telegramConfiguration(); err != nil { + return false, err.Error() + } return true, "" } @@ -388,12 +390,17 @@ func (s *ConfigService) TelegramBindingNeedsRebind(settings TelegramUserSettings if settings.ChatID == "" { return false } - configuration, err := s.telegramConfiguration() - return err != nil || !settings.Verified || settings.BotID != configuration.BotID + if s == nil || s.vault == nil || s.engine == nil { + return true + } + token, err := s.openSecretForPlugin(telegramPluginID, "botToken") + parts := telegramTokenPattern.FindStringSubmatch(token) + return err != nil || len(parts) != 2 || !settings.Verified || settings.BotID != parts[1] } func (s *ConfigService) TelegramCanNotify(settings TelegramUserSettings) bool { - return settings.ChatID != "" && !settings.Disabled && !s.TelegramBindingNeedsRebind(settings) + configuration, err := s.telegramConfiguration() + return err == nil && settings.ChatID != "" && settings.Verified && !settings.Disabled && settings.BotID == configuration.BotID } func (s *ConfigService) UpdateOwnTelegramPreferences(userID int, preferences TelegramUserSettings, unbind bool, expectedVersion uint64) (UserSettingsStatus, error) {