From be6b8bf27f570174642d6ad51c01f76243cbe877 Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Sat, 30 May 2026 01:54:26 +0800 Subject: [PATCH] fix: tighten telegram channel binding --- README.md | 24 ++-- README_EN.md | 22 ++-- docker-compose.yml | 2 +- internal/service/notify_channels.go | 33 +++-- internal/service/telegram_bot.go | 175 +++++++++++++++++++++++++-- web/src/pages/NotifyChannelsPage.tsx | 31 +++-- 6 files changed, 235 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 74d90dc..cb4e8be 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ mkdir -p data cache media downloads ```bash cat > .env <<'EOF' # 固定版本;需要升级时改成新的 MediaStationGo-vX.Y.Z 后执行 docker compose pull && docker compose up -d -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.22 MEDIASTATION_HTTP_PORT=18080 # 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。 @@ -307,7 +307,7 @@ vim docker-compose.yml # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.22 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 @@ -516,7 +516,7 @@ docker compose up -d ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.22 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -779,26 +779,26 @@ cd MediaStationGo | 平台 | 包名示例 | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.21-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.21-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.21-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.21-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.21-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.22-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.22-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.22-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.22-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.22-darwin-arm64.tar.gz` | 部署步骤: ```bash # Linux 示例 -tar -xzf MediaStationGo-v0.0.21-linux-amd64.tar.gz -cd MediaStationGo-v0.0.21-linux-amd64 +tar -xzf MediaStationGo-v0.0.22-linux-amd64.tar.gz +cd MediaStationGo-v0.0.22-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows: ```powershell -Expand-Archive .\MediaStationGo-v0.0.21-windows-amd64.zip -cd .\MediaStationGo-v0.0.21-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.22-windows-amd64.zip +cd .\MediaStationGo-v0.0.22-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` diff --git a/README_EN.md b/README_EN.md index f264aaf..6af14fd 100644 --- a/README_EN.md +++ b/README_EN.md @@ -235,7 +235,7 @@ mkdir -p data cache media downloads ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.22 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -344,7 +344,7 @@ For production, pin a specific release tag instead of using `latest`. Recommende ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.22 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -593,25 +593,25 @@ Each release provides multi-platform archives: | Platform | Package example | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.21-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.21-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.21-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.21-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.21-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.22-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.22-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.22-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.22-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.22-darwin-arm64.tar.gz` | Linux example: ```bash -tar -xzf MediaStationGo-v0.0.21-linux-amd64.tar.gz -cd MediaStationGo-v0.0.21-linux-amd64 +tar -xzf MediaStationGo-v0.0.22-linux-amd64.tar.gz +cd MediaStationGo-v0.0.22-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows example: ```powershell -Expand-Archive .\MediaStationGo-v0.0.21-windows-amd64.zip -cd .\MediaStationGo-v0.0.21-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.22-windows-amd64.zip +cd .\MediaStationGo-v0.0.22-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` diff --git a/docker-compose.yml b/docker-compose.yml index ee363df..ca552ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.21 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.22 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 diff --git a/internal/service/notify_channels.go b/internal/service/notify_channels.go index ce3195d..a450f88 100644 --- a/internal/service/notify_channels.go +++ b/internal/service/notify_channels.go @@ -95,11 +95,11 @@ func (s *NotifyChannelService) Create(ctx context.Context, in ChannelInput) (*ch cfgBlob, _ := json.Marshal(in.Config) evBlob, _ := json.Marshal(in.Events) n := &model.NotifyChannel{ - Name: strings.TrimSpace(in.Name), - Type: in.Type, - Config: string(cfgBlob), - Events: string(evBlob), - Enabled: true, + Name: strings.TrimSpace(in.Name), + Type: in.Type, + Config: string(cfgBlob), + Events: string(evBlob), + Enabled: true, } if in.Enabled != nil { n.Enabled = *in.Enabled @@ -119,10 +119,10 @@ func (s *NotifyChannelService) Update(ctx context.Context, id string, in Channel cfgBlob, _ := json.Marshal(in.Config) evBlob, _ := json.Marshal(in.Events) patch := map[string]any{ - "name": strings.TrimSpace(in.Name), - "type": in.Type, - "config": string(cfgBlob), - "events": string(evBlob), + "name": strings.TrimSpace(in.Name), + "type": in.Type, + "config": string(cfgBlob), + "events": string(evBlob), } if in.Enabled != nil { patch["enabled"] = *in.Enabled @@ -320,6 +320,21 @@ func validateChannel(in ChannelInput) error { default: return fmt.Errorf("unsupported channel type %q", in.Type) } + if in.Type == "telegram" { + cfg := in.Config + if str(cfg["bot_token"]) == "" { + return errors.New("telegram bot_token required") + } + if str(cfg["chat_id"]) == "" { + return errors.New("telegram notification chat_id required") + } + if str(cfg["admin_user_ids"]) == "" { + return errors.New("telegram admin_user_ids required") + } + if str(cfg["group_chat_id"]) == "" && str(cfg["channel_chat_id"]) == "" && str(cfg["command_chat_id"]) == "" { + return errors.New("telegram group_chat_id or channel_chat_id required") + } + } return nil } diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index 34e0290..60389b2 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -114,8 +114,17 @@ func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) err zap.String("text", text), ) - // 获取该 chat_id 对应的通知渠道配置 - channel := s.findChannelByChatID(ctx, msg.Chat.ID) + // 获取该消息可使用的 Telegram 通知渠道配置。群组/频道消息必须来自 + // 已配置的群组/频道;私聊消息会选择一个可验证该用户成员身份的 Bot。 + channel := s.findChannelForMessage(ctx, msg) + if channel == nil { + s.log.Warn("telegram channel not allowed or not configured", + zap.Int("chat_id", msg.Chat.ID), + zap.String("chat_type", msg.Chat.Type), + zap.Int("telegram_user_id", msg.From.ID), + ) + return nil + } // 解析并执行命令 reply, err := s.executeCommand(ctx, channel, msg, text) @@ -155,22 +164,22 @@ func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model. case "/hideadult", "/hide_adult", "/adult": return s.cmdHideAdult(ctx, msg, args), nil case "/status": - if !s.telegramUserIsAdmin(ctx, msg.From.ID) { + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { return telegramCommandReply{Text: "此命令仅管理员可用。普通用户只能使用 /start 绑定账号,并通过按钮隐藏成人目录。"}, nil } return s.cmdStatus(ctx) case "/search": - if !s.telegramUserIsAdmin(ctx, msg.From.ID) { + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil } return s.cmdSearch(ctx, args) case "/downloads": - if !s.telegramUserIsAdmin(ctx, msg.From.ID) { + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil } return s.cmdDownloads(ctx) case "/stats": - if !s.telegramUserIsAdmin(ctx, msg.From.ID) { + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil } return s.cmdStats(ctx) @@ -202,6 +211,10 @@ func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, } return telegramCommandReply{Text: "欢迎使用 MediaStationGo\n\n普通用户请先绑定账号:\n/start 用户名 密码\n或:/start 用户名-密码\n\n如果没有账号,请联系管理员注册。"} } + channel := s.findChannelForMessage(ctx, msg) + if !s.telegramUserCanBind(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "当前 Telegram 账号不在已绑定的群组/频道中,无法绑定媒体中心账号。请先加入管理员配置的群组或频道。"} + } username, password := parseStartCredentials(args) if username == "" || password == "" { return telegramCommandReply{Text: "绑定格式不正确,请使用:\n/start 用户名 密码\n或:/start 用户名-密码"} @@ -230,7 +243,8 @@ func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, // cmdHelp 处理 /help 命令。 func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage) string { - if !s.telegramUserIsAdmin(ctx, msg.From.ID) { + channel := s.findChannelForMessage(ctx, msg) + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { return "MediaStationGo 用户命令\n\n" + "/start 用户名 密码 — 绑定账号\n" + "/hideadult on|off — 隐藏或显示成人目录\n\n" + @@ -253,6 +267,10 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage) // cmdStatus 处理 /status 命令。 func (s *TelegramBotService) cmdHideAdult(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { + channel := s.findChannelForMessage(ctx, msg) + if !s.telegramUserCanBind(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "当前 Telegram 账号不在已绑定的群组/频道中,无法使用成人目录隐藏开关。"} + } binding := s.telegramBinding(ctx, msg.From.ID) if binding == nil { return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"} @@ -610,7 +628,8 @@ func (s *TelegramBotService) findChannelByChatID(ctx context.Context, chatID int } var cfg map[string]string json.Unmarshal([]byte(configStr), &cfg) - if cfg["chat_id"] == target || cfg["command_chat_id"] == target { + if cfg["chat_id"] == target || cfg["command_chat_id"] == target || + cfg["group_chat_id"] == target || cfg["channel_chat_id"] == target { return &ch } } @@ -620,6 +639,33 @@ func (s *TelegramBotService) findChannelByChatID(ctx context.Context, chatID int return nil } +func (s *TelegramBotService) findChannelForMessage(ctx context.Context, msg *TelegramMessage) *model.NotifyChannel { + if msg == nil { + return nil + } + if msg.Chat.Type != "" && msg.Chat.Type != "private" { + return s.findChannelByChatID(ctx, msg.Chat.ID) + } + channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram") + if err != nil { + return nil + } + var first *model.NotifyChannel + for i := range channels { + ch := channels[i] + if !ch.Enabled { + continue + } + if first == nil { + first = &ch + } + if s.telegramUserIsAdmin(ctx, &ch, msg.From.ID) || s.telegramUserCanBind(ctx, &ch, msg.From.ID) { + return &ch + } + } + return first +} + func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCallbackQuery) error { if cb == nil || cb.Message == nil { return nil @@ -649,7 +695,10 @@ func (s *TelegramBotService) telegramBinding(ctx context.Context, telegramUserID return &binding } -func (s *TelegramBotService) telegramUserIsAdmin(ctx context.Context, telegramUserID int) bool { +func (s *TelegramBotService) telegramUserIsAdmin(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) bool { + if s.telegramUserIDConfigured(channel, telegramUserID) { + return true + } binding := s.telegramBinding(ctx, telegramUserID) if binding == nil { return false @@ -671,13 +720,115 @@ func (s *TelegramBotService) telegramChatAllowed(channel *model.NotifyChannel, c return false } target := strconv.Itoa(chatID) - commandChatID := strings.TrimSpace(cfg["command_chat_id"]) - if commandChatID != "" { - return commandChatID == target + for _, key := range []string{"group_chat_id", "channel_chat_id", "command_chat_id"} { + if configured := strings.TrimSpace(cfg[key]); configured != "" && configured == target { + return true + } + } + if strings.TrimSpace(cfg["group_chat_id"]) != "" || strings.TrimSpace(cfg["channel_chat_id"]) != "" || strings.TrimSpace(cfg["command_chat_id"]) != "" { + return false } return strings.TrimSpace(cfg["chat_id"]) == target } +func (s *TelegramBotService) telegramUserCanBind(ctx context.Context, channel *model.NotifyChannel, telegramUserID int) bool { + if telegramUserID == 0 || channel == nil { + return false + } + if s.telegramUserIDConfigured(channel, telegramUserID) { + return true + } + cfg := s.telegramChannelConfig(channel) + groupID := strings.TrimSpace(cfg["group_chat_id"]) + channelID := strings.TrimSpace(cfg["channel_chat_id"]) + if groupID == "" && channelID == "" { + return true + } + for _, chatID := range []string{groupID, channelID} { + if chatID == "" { + continue + } + if s.telegramUserIsChatMember(ctx, channel, chatID, telegramUserID) { + return true + } + } + return false +} + +func (s *TelegramBotService) telegramUserIsChatMember(ctx context.Context, channel *model.NotifyChannel, chatID string, telegramUserID int) bool { + token := strings.TrimSpace(s.telegramChannelConfig(channel)["bot_token"]) + if token == "" || chatID == "" || telegramUserID == 0 { + return false + } + payload, _ := json.Marshal(map[string]interface{}{ + "chat_id": chatID, + "user_id": telegramUserID, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + fmt.Sprintf("https://api.telegram.org/bot%s/getChatMember", token), + bytes.NewReader(payload)) + if err != nil { + return false + } + req.Header.Set("Content-Type", "application/json") + resp, err := (&http.Client{Timeout: 8 * time.Second}).Do(req) + if err != nil { + s.log.Warn("telegram getChatMember failed", zap.String("chat_id", chatID), zap.Int("telegram_user_id", telegramUserID), zap.Error(err)) + return false + } + defer resp.Body.Close() + var result struct { + OK bool `json:"ok"` + Result struct { + Status string `json:"status"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || !result.OK { + return false + } + switch strings.ToLower(result.Result.Status) { + case "creator", "administrator", "member", "restricted": + return true + default: + return false + } +} + +func (s *TelegramBotService) telegramUserIDConfigured(channel *model.NotifyChannel, telegramUserID int) bool { + if channel == nil || telegramUserID == 0 { + return false + } + cfg := s.telegramChannelConfig(channel) + target := strconv.Itoa(telegramUserID) + for _, value := range strings.FieldsFunc(cfg["admin_user_ids"], func(r rune) bool { + return r == ',' || r == ';' || r == ',' || r == ' ' || r == '\n' || r == '\t' + }) { + if strings.TrimSpace(value) == target { + return true + } + } + return false +} + +func (s *TelegramBotService) telegramChannelConfig(channel *model.NotifyChannel) map[string]string { + return telegramConfigFromChannel(s.crypto, channel) +} + +func telegramConfigFromChannel(crypto *CryptoService, channel *model.NotifyChannel) map[string]string { + if channel == nil { + return map[string]string{} + } + configStr := channel.Config + if crypto != nil && configStr != "" { + configStr = crypto.Decrypt(configStr) + } + var cfg map[string]string + if err := json.Unmarshal([]byte(configStr), &cfg); err != nil || cfg == nil { + return map[string]string{} + } + return cfg +} + func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *TelegramMessage, userID string) error { name := strings.TrimSpace(msg.From.FirstName) if msg.From.Username != "" { diff --git a/web/src/pages/NotifyChannelsPage.tsx b/web/src/pages/NotifyChannelsPage.tsx index ad32c90..7c97e2b 100644 --- a/web/src/pages/NotifyChannelsPage.tsx +++ b/web/src/pages/NotifyChannelsPage.tsx @@ -183,7 +183,7 @@ function channelSummary(ch: NotifyChannel): string { const cfg = ch.config ?? {} switch (ch.type) { case 'telegram': - return `Bot ${String(cfg.bot_token ?? '').slice(0, 10)}… → 通知 ${cfg.chat_id ?? '-'} · 命令 ${cfg.command_chat_id ?? cfg.chat_id ?? '-'}` + return `Bot ${String(cfg.bot_token ?? '').slice(0, 10)}… → 通知 ${cfg.chat_id ?? '-'} · 管理员 ${cfg.admin_user_ids ?? '-'} · 群组 ${cfg.group_chat_id ?? '-'} · 频道 ${cfg.channel_chat_id ?? '-'}` case 'wechat': return `SendKey ${String(cfg.sendkey ?? '').slice(0, 10)}…` case 'bark': @@ -200,7 +200,7 @@ function channelSummary(ch: NotifyChannel): string { // ─── Form Modal ───────────────────────────────────────────────────────────── const EMPTY_CONFIG: Record> = { - telegram: { bot_token: '', chat_id: '', command_chat_id: '' }, + telegram: { bot_token: '', chat_id: '', admin_user_ids: '', group_chat_id: '', channel_chat_id: '' }, wechat: { sendkey: '' }, bark: { device_key: '', server: '' }, webhook: { url: '', method: 'POST', headers: '', body_template: '' }, @@ -311,16 +311,33 @@ function ChannelFormModal({ onChange={(e) => updateConfig('chat_id', e.target.value)} /> - + + updateConfig('admin_user_ids', e.target.value)} + /> + + updateConfig('command_chat_id', e.target.value)} + placeholder="如 -1001234567890;群组成员才允许唤醒/绑定" + value={config.group_chat_id ?? ''} + onChange={(e) => updateConfig('group_chat_id', e.target.value)} + /> + + + updateConfig('channel_chat_id', e.target.value)} />
- 普通用户只能通过 /start 用户名 密码 绑定账号,并使用隐藏成人目录按钮;/status、/search、/downloads、/stats 仅管理员可用。 + 必须至少填写群组 ID 或频道 ID。只有配置群组/频道中的成员可以唤醒 Bot、使用 /start 用户名 密码 绑定账号和隐藏成人目录;/status、/search、/downloads、/stats 仅管理员 Telegram ID 或已绑定的本地管理员可用。
)}