收口Telegram旧接口并补充超级管理员绑定用例
Some checks are pending
Security Checks / security (push) Waiting to run

将旧Telegram兼容入口收口到当前账户绑定流程,禁止请求体伪造Chat ID,并补充超级管理员本人插件设置权限回归用例。
This commit is contained in:
smail 2026-09-29 16:36:24 +08:00
parent c7bcde6f0b
commit 0aa6ed67e3
3 changed files with 39 additions and 113 deletions

View File

@ -5,7 +5,6 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"strings"
"git.pcux.cn/xuans/Smail/dto/response" "git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/hooks" "git.pcux.cn/xuans/Smail/hooks"
@ -18,56 +17,12 @@ import (
const maxTelegramUserSettingsBodyBytes int64 = 16 << 10 const maxTelegramUserSettingsBodyBytes int64 = 16 << 10
type telegramSettingsRequest struct {
Settings pluginservice.TelegramUserSettings `json:"settings"`
Version uint64 `json:"version"`
}
func GetTelegramUserSettings(ctx *context.Context, w http.ResponseWriter, req *http.Request) { func GetTelegramUserSettings(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if !authorizedPluginUser(ctx) { TelegramUserSettings(ctx, w, req)
response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w)
return
}
service := hooks.PluginConfigService()
if service == nil {
response.NewErrorResponse(response.ServerError, "插件配置服务不可用", "").FPrint(w)
return
}
settings, err := service.GetOwnTelegramSettings(ctx.UserID)
if err != nil {
writeTelegramSettingsError(w, err)
return
}
response.NewSuccessResponse(settings).FPrint(w)
} }
func UpdateTelegramUserSettings(ctx *context.Context, w http.ResponseWriter, req *http.Request) { func UpdateTelegramUserSettings(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if !authorizedPluginUser(ctx) { TelegramUserSettings(ctx, w, req)
response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w)
return
}
var input telegramSettingsRequest
if !decodePluginJSONBody(w, req, &input) {
return
}
if err := pluginservice.ValidateTelegramUserSettings(input.Settings); err != nil {
writeTelegramSettingsError(w, err)
return
}
service := hooks.PluginConfigService()
if service == nil {
response.NewErrorResponse(response.ServerError, "插件配置服务不可用", "").FPrint(w)
return
}
settings, err := service.UpdateOwnTelegramSettings(ctx.UserID, input.Settings, input.Version)
if err != nil {
writeTelegramSettingsError(w, err)
return
}
if err := hooks.SyncTelegramUserSettings(ctx.UserID, settings.Settings); err != nil {
log.WithError(err).Warnf("Telegram 用户设置同步失败,用户 ID=%d", ctx.UserID)
}
response.NewSuccessResponse(settings).FPrint(w)
} }
func GetTelegramBotInfo(ctx *context.Context, w http.ResponseWriter, req *http.Request) { func GetTelegramBotInfo(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
@ -95,43 +50,7 @@ func GetTelegramBotInfo(ctx *context.Context, w http.ResponseWriter, req *http.R
} }
func TestTelegramMessage(ctx *context.Context, w http.ResponseWriter, req *http.Request) { func TestTelegramMessage(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if !authenticatedPluginUser(ctx) { TestBoundTelegramMessage(ctx, w, req)
response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w)
return
}
var input telegramSettingsRequest
if !decodePluginJSONBody(w, req, &input) {
return
}
if strings.TrimSpace(input.Settings.ChatID) == "" {
response.NewErrorResponse(response.ParamsError, "请输入 Telegram 聊天 ID", "").FPrint(w)
return
}
if err := pluginservice.ValidateTelegramUserSettings(input.Settings); err != nil {
writeTelegramSettingsError(w, err)
return
}
hook, ok := hooks.GetHook("telegram_push")
if !ok {
response.NewErrorResponse(response.ServerError, "Telegram 插件不可用", "").FPrint(w)
return
}
provider, ok := hook.(framework.TestMessageProvider)
if !ok {
response.NewErrorResponse(response.ServerError, "Telegram 插件不支持测试消息", "").FPrint(w)
return
}
payload, err := json.Marshal(input.Settings)
if err != nil {
response.NewErrorResponse(response.ServerError, "测试消息参数处理失败", "").FPrint(w)
return
}
if err := provider.TestMessage(payload); err != nil {
log.WithError(err).Warn("发送 Telegram 测试消息失败")
response.NewErrorResponse(response.ServerError, "发送 Telegram 测试消息失败", "").FPrint(w)
return
}
response.NewSuccessResponse(map[string]string{"message": "测试消息已发送"}).FPrint(w)
} }
func authenticatedPluginUser(ctx *context.Context) bool { func authenticatedPluginUser(ctx *context.Context) bool {

View File

@ -50,30 +50,36 @@ func TestTelegramUserSettingsRequiresLogin(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/api/v1/plugin/settings/telegram_push/user", nil) request := httptest.NewRequest(http.MethodGet, "/api/v1/plugin/settings/telegram_push/user", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
GetTelegramUserSettings(nil, recorder, request) GetTelegramUserSettings(nil, recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "请先登录") { if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "无权访问或请求不满足安全条件") {
t.Fatalf("unauthenticated GET status=%d body=%s", recorder.Code, recorder.Body.String()) t.Fatalf("unauthenticated GET status=%d body=%s", recorder.Code, recorder.Body.String())
} }
request = httptest.NewRequest(http.MethodPut, "/api/v1/plugin/settings/telegram_push/user", strings.NewReader(`{}`)) request = httptest.NewRequest(http.MethodPut, "/api/v1/plugin/settings/telegram_push/user", strings.NewReader(`{}`))
recorder = httptest.NewRecorder() recorder = httptest.NewRecorder()
UpdateTelegramUserSettings(&appcontext.Context{}, recorder, request) UpdateTelegramUserSettings(&appcontext.Context{}, recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "请先登录") { if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "无权访问或请求不满足安全条件") {
t.Fatalf("unauthenticated PUT status=%d body=%s", recorder.Code, recorder.Body.String()) t.Fatalf("unauthenticated PUT status=%d body=%s", recorder.Code, recorder.Body.String())
} }
} }
func TestTelegramUserSettingsRejectsUnknownAndTrailingFields(t *testing.T) { func TestTelegramUserSettingsRejectsUnknownAndTrailingFields(t *testing.T) {
hooks.SetPluginConfigService(nil) service, engine := newTelegramControllerConfigService(t)
t.Cleanup(func() { hooks.SetPluginConfigService(nil) }) previousService := hooks.PluginConfigService()
hooks.SetPluginConfigService(service)
t.Cleanup(func() { hooks.SetPluginConfigService(previousService) })
user := models.User{Account: "telegram-parse-user", Name: "telegram-parse-user", Password: "hash"}
if _, err := engine.Insert(&user); err != nil {
t.Fatal(err)
}
cases := []string{ cases := []string{
`{"settings":{"chatId":"123","userId":99},"version":0}`, `{"enabled":false,"chatId":"123","version":0}`,
`{"settings":{"chatId":"123"},"version":0} {}`, `{"enabled":false,"version":0} {}`,
} }
for _, body := range cases { for _, body := range cases {
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPut, "/api/v1/plugin/settings/telegram_push/user", bytes.NewBufferString(body)) request := httptest.NewRequest(http.MethodPut, "/api/v1/plugin/settings/telegram_push/user", bytes.NewBufferString(body))
UpdateTelegramUserSettings(&appcontext.Context{UserID: 7}, recorder, request) UpdateTelegramUserSettings(&appcontext.Context{UserID: user.ID, RecentAuth: true}, recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "请求参数格式不正确") { if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "请求参数格式不正确") {
t.Fatalf("body=%s status=%d response=%s", body, recorder.Code, recorder.Body.String()) t.Fatalf("body=%s status=%d response=%s", body, recorder.Code, recorder.Body.String())
} }
@ -92,8 +98,8 @@ func TestTelegramUserSettingsUsesSessionUserAndVersion(t *testing.T) {
} }
firstRecorder := httptest.NewRecorder() firstRecorder := httptest.NewRecorder()
firstRequest := httptest.NewRequest(http.MethodPut, "/api/v1/plugin/settings/telegram_push/user", strings.NewReader(`{"settings":{"chatId":"-1001234567890","showContent":true},"version":0}`)) firstRequest := httptest.NewRequest(http.MethodPut, "/api/v1/plugin/settings/telegram_push/user", strings.NewReader(`{"enabled":false,"showContent":true,"version":0}`))
UpdateTelegramUserSettings(&appcontext.Context{UserID: user.ID}, firstRecorder, firstRequest) UpdateTelegramUserSettings(&appcontext.Context{UserID: user.ID, RecentAuth: true}, firstRecorder, firstRequest)
if firstRecorder.Code != http.StatusOK || !strings.Contains(firstRecorder.Body.String(), `"version":1`) { if firstRecorder.Code != http.StatusOK || !strings.Contains(firstRecorder.Body.String(), `"version":1`) {
t.Fatalf("first update status=%d body=%s", firstRecorder.Code, firstRecorder.Body.String()) t.Fatalf("first update status=%d body=%s", firstRecorder.Code, firstRecorder.Body.String())
} }
@ -102,15 +108,15 @@ func TestTelegramUserSettingsUsesSessionUserAndVersion(t *testing.T) {
} }
staleRecorder := httptest.NewRecorder() staleRecorder := httptest.NewRecorder()
staleRequest := httptest.NewRequest(http.MethodPut, "/api/v1/plugin/settings/telegram_push/user", strings.NewReader(`{"settings":{"chatId":"123"},"version":0}`)) staleRequest := httptest.NewRequest(http.MethodPut, "/api/v1/plugin/settings/telegram_push/user", strings.NewReader(`{"enabled":false,"showContent":false,"version":0}`))
UpdateTelegramUserSettings(&appcontext.Context{UserID: user.ID}, staleRecorder, staleRequest) UpdateTelegramUserSettings(&appcontext.Context{UserID: user.ID, RecentAuth: true}, staleRecorder, staleRequest)
if staleRecorder.Code != http.StatusOK || !strings.Contains(staleRecorder.Body.String(), "配置已被其他操作更新") { if staleRecorder.Code != http.StatusOK || !strings.Contains(staleRecorder.Body.String(), "配置已被其他操作更新") {
t.Fatalf("stale update status=%d body=%s", staleRecorder.Code, staleRecorder.Body.String()) t.Fatalf("stale update status=%d body=%s", staleRecorder.Code, staleRecorder.Body.String())
} }
getRecorder := httptest.NewRecorder() getRecorder := httptest.NewRecorder()
GetTelegramUserSettings(&appcontext.Context{UserID: user.ID}, getRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/plugin/settings/telegram_push/user", nil)) GetTelegramUserSettings(&appcontext.Context{UserID: user.ID}, getRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/plugin/settings/telegram_push/user", nil))
if getRecorder.Code != http.StatusOK || !strings.Contains(getRecorder.Body.String(), "-1001234567890") { if getRecorder.Code != http.StatusOK || !strings.Contains(getRecorder.Body.String(), `"bound":false`) || strings.Contains(getRecorder.Body.String(), "-1001234567890") {
t.Fatalf("GET status=%d body=%s", getRecorder.Code, getRecorder.Body.String()) t.Fatalf("GET status=%d body=%s", getRecorder.Code, getRecorder.Body.String())
} }
} }
@ -131,30 +137,30 @@ func TestTelegramBotInfoDoesNotExposeSecret(t *testing.T) {
} }
} }
func TestTelegramTestMessageValidatesChatIDAndDoesNotPersist(t *testing.T) { func TestTelegramTestMessageRequiresCurrentAccountBinding(t *testing.T) {
service, engine := newTelegramControllerConfigService(t)
previousService := hooks.PluginConfigService()
hooks.SetPluginConfigService(service)
t.Cleanup(func() { hooks.SetPluginConfigService(previousService) })
user := models.User{Account: "telegram-test-user", Name: "telegram-test-user", Password: "hash"}
if _, err := engine.Insert(&user); err != nil {
t.Fatal(err)
}
previousHooks := hooks.HookList previousHooks := hooks.HookList
hook := &telegramControllerTestHook{} hook := &telegramControllerTestHook{}
hooks.HookList = map[string]framework.EmailHook{"telegram_push": hook} hooks.HookList = map[string]framework.EmailHook{"telegram_push": hook}
t.Cleanup(func() { hooks.HookList = previousHooks }) t.Cleanup(func() { hooks.HookList = previousHooks })
invalidRecorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
invalidRequest := httptest.NewRequest(http.MethodPost, "/api/v1/plugin/settings/telegram_push/test", strings.NewReader(`{"settings":{"chatId":"not-a-chat"}}`)) request := httptest.NewRequest(http.MethodPost, "/api/v1/plugin/settings/telegram_push/test", strings.NewReader(`{"settings":{"chatId":"123"}}`))
TestTelegramMessage(&appcontext.Context{UserID: 7}, invalidRecorder, invalidRequest) TestTelegramMessage(&appcontext.Context{UserID: user.ID, RecentAuth: true}, recorder, request)
if invalidRecorder.Code != http.StatusOK || !strings.Contains(invalidRecorder.Body.String(), "聊天 ID 格式不正确") { if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "请先绑定本人 Telegram 并启用通知") {
t.Fatalf("invalid message status=%d body=%s", invalidRecorder.Code, invalidRecorder.Body.String()) t.Fatalf("unbound test message status=%d body=%s", recorder.Code, recorder.Body.String())
} }
if hook.testPayload != nil { if hook.testPayload != nil {
t.Fatal("invalid test message reached plugin") t.Fatal("caller-supplied Chat ID reached plugin without a verified binding")
}
validRecorder := httptest.NewRecorder()
validRequest := httptest.NewRequest(http.MethodPost, "/api/v1/plugin/settings/telegram_push/test", strings.NewReader(`{"settings":{"chatId":"123"},"version":99}`))
TestTelegramMessage(&appcontext.Context{UserID: 7}, validRecorder, validRequest)
if validRecorder.Code != http.StatusOK || !strings.Contains(validRecorder.Body.String(), "测试消息已发送") {
t.Fatalf("valid message status=%d body=%s", validRecorder.Code, validRecorder.Body.String())
}
if !strings.Contains(string(hook.testPayload), `"chatId":"123"`) {
t.Fatalf("plugin did not receive validated settings: %s", hook.testPayload)
} }
} }

View File

@ -22,6 +22,7 @@ func TestCapabilityMatrix(t *testing.T) {
{name: "admin writes user plugin settings", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPluginWriteAnyUser, TargetRole: RoleUser}, allowed: true}, {name: "admin writes user plugin settings", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPluginWriteAnyUser, TargetRole: RoleUser}, allowed: true},
{name: "admin cannot write admin plugin settings", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPluginWriteAnyUser, TargetRole: RoleAdmin}}, {name: "admin cannot write admin plugin settings", req: Request{ActorRole: RoleAdmin, Capability: CapabilityPluginWriteAnyUser, TargetRole: RoleAdmin}},
{name: "super admin writes all user plugin settings", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityPluginWriteAnyUser, TargetRole: RoleSuperAdmin}, allowed: true}, {name: "super admin writes all user plugin settings", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityPluginWriteAnyUser, TargetRole: RoleSuperAdmin}, allowed: true},
{name: "super admin writes own plugin settings", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityPluginWriteOwn, OwnResource: true}, allowed: true},
{name: "user writes own plugin settings", req: Request{ActorRole: RoleUser, Capability: CapabilityPluginWriteOwn, OwnResource: true}, allowed: true}, {name: "user writes own plugin settings", req: Request{ActorRole: RoleUser, Capability: CapabilityPluginWriteOwn, OwnResource: true}, allowed: true},
{name: "user cannot write another plugin settings", req: Request{ActorRole: RoleUser, Capability: CapabilityPluginWriteOwn}}, {name: "user cannot write another plugin settings", req: Request{ActorRole: RoleUser, Capability: CapabilityPluginWriteOwn}},
{name: "authenticated super admin changes own password", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityPasswordChangeOwn, OwnResource: true}, allowed: true}, {name: "authenticated super admin changes own password", req: Request{ActorRole: RoleSuperAdmin, Capability: CapabilityPasswordChangeOwn, OwnResource: true}, allowed: true},