完善各角色独立通知绑定的隔离检查和回归用例
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-28 22:09:15 +08:00
parent e8c2e7867e
commit 754a2578fb
3 changed files with 200 additions and 30 deletions

View File

@ -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

View File

@ -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"))

View File

@ -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) {