431 lines
20 KiB
Go
431 lines
20 KiB
Go
package handlers
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"errors"
|
||
"log/slog"
|
||
"net/http"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"building-mgmt/internal/crypt"
|
||
"building-mgmt/internal/ratelimit"
|
||
"building-mgmt/internal/repository"
|
||
"building-mgmt/internal/telegram"
|
||
)
|
||
|
||
const (
|
||
// bindCodeTTL — срок жизни кода привязки: ровно столько, чтобы
|
||
// успеть перейти по ссылке и написать боту, не дольше — код,
|
||
// лежащий в чате, бесполезен через сутки.
|
||
bindCodeTTL = 15 * time.Minute
|
||
// bindCodeLen / bindCodeAlphabet — 8 знаков из 32 (~40 бит) без
|
||
// похожих символов (0/O, 1/I): код переносят в бота руками,
|
||
// опечатка должна быть заметна.
|
||
bindCodeLen = 8
|
||
bindCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||
)
|
||
|
||
// targetPattern — единственный формат ручной цели: @имя (группа или
|
||
// канал, куда бот добавлен) либо числовой chat_id. Личный @ник сюда
|
||
// не проходит намеренно: бот не видит человека, пока тот сам ему не
|
||
// напишет, — поэтому личная привязка идёт отдельно, через код /start.
|
||
var targetPattern = regexp.MustCompile(`^(@[A-Za-z0-9_]{5,32}|-?[0-9]{5,20})$`)
|
||
|
||
// botTokenPattern — формат токена Bot API от @BotFather: "<id>:<секрет>".
|
||
// Проверка до сети: мусор не доходит до getMe, а у реального токена
|
||
// формат стабилен уже много лет. Непонятное значение → 400.
|
||
var botTokenPattern = regexp.MustCompile(`^\d{5,40}:[A-Za-z0-9_-]{10,200}$`)
|
||
|
||
// BotRegistry — выбор бота для пользователя и управление его поллером.
|
||
// Реализация — notify.Registry: собственный токен пользователя
|
||
// приоритетнее глобального TELEGRAM_BOT_TOKEN.
|
||
type BotRegistry interface {
|
||
// BotFor — личный бот пользователя, иначе глобальный (может быть
|
||
// выключенным — проверяет Enabled()).
|
||
BotFor(userID string) telegram.API
|
||
// Register поднимает поллер личного бота (замена токена гасит прежний).
|
||
Register(userID string, bot telegram.API)
|
||
// Unregister гасит поллер и убирает бота из реестра.
|
||
Unregister(userID string)
|
||
}
|
||
|
||
// NewBot — фабрика клиента Bot API по токену: сеть поднимается отдельно
|
||
// (Init), здесь создаётся только клиент (в cmd/api — telegram.New).
|
||
type NewBot func(token string) telegram.API
|
||
|
||
// TelegramConfig — зависимости Telegram-хендлера.
|
||
type TelegramConfig struct {
|
||
Repo *repository.TelegramRepo
|
||
// Bots — реестр: чей бот использовать (личный или глобальный) и
|
||
// как его поллер остановить.
|
||
Bots BotRegistry
|
||
// Cipher шифрует токены перед записью в БД; nil — ввод токена на
|
||
// сервере выключен (нет DATA_ENCRYPTION_KEY), ответ 400.
|
||
Cipher *crypt.Cipher
|
||
// NewBot — фабрика для проверки введённого токена.
|
||
NewBot NewBot
|
||
// CodeLimiter — лимит выдачи кодов привязки (ключ — user_id).
|
||
CodeLimiter ratelimit.Allower
|
||
// TokenLimiter — лимит сохранения токена: там сетевой getMe.
|
||
TokenLimiter ratelimit.Allower
|
||
}
|
||
|
||
// TelegramHandler — уведомления в Telegram: собственный бот пользователя,
|
||
// привязка чата (одноразовый код), ручной ввод группы/канала,
|
||
// отключение.
|
||
type TelegramHandler struct {
|
||
repo *repository.TelegramRepo
|
||
bots BotRegistry
|
||
cipher *crypt.Cipher
|
||
newBot NewBot
|
||
codeLimiter ratelimit.Allower
|
||
tokenLimiter ratelimit.Allower
|
||
}
|
||
|
||
// NewTelegramHandler собирает хендлер.
|
||
func NewTelegramHandler(cfg TelegramConfig) *TelegramHandler {
|
||
return &TelegramHandler{
|
||
repo: cfg.Repo,
|
||
bots: cfg.Bots,
|
||
cipher: cfg.Cipher,
|
||
newBot: cfg.NewBot,
|
||
codeLimiter: cfg.CodeLimiter,
|
||
tokenLimiter: cfg.TokenLimiter,
|
||
}
|
||
}
|
||
|
||
// TelegramStatusResponse — состояние уведомлений текущего пользователя.
|
||
// Токен сюда не входит никогда: по статусу видно только, есть ли он.
|
||
type TelegramStatusResponse struct {
|
||
// Enabled — может ли этот пользователь получать уведомления вообще:
|
||
// есть свой токен бота либо настроен глобальный бот сервера.
|
||
Enabled bool `json:"enabled"`
|
||
// Bound — есть ли уже цель уведомлений.
|
||
Bound bool `json:"bound"`
|
||
// Target — текущая цель: числовой chat_id либо @группа.
|
||
Target string `json:"target,omitempty"`
|
||
// BotUsername — @имя бота, через который придут уведомления и
|
||
// ссылка привязки; пусто, пока getMe не удался.
|
||
BotUsername string `json:"bot_username,omitempty"`
|
||
// HasBotToken — подключён ли пользователь свой бот (токен в БД).
|
||
HasBotToken bool `json:"has_bot_token"`
|
||
// TokenConfigurable — можно ли ввести токен прямо сейчас: false,
|
||
// если на сервере не задан DATA_ENCRYPTION_KEY.
|
||
TokenConfigurable bool `json:"token_configurable"`
|
||
}
|
||
|
||
// Status — GET /auth/telegram.
|
||
//
|
||
// @Summary Статус уведомлений в Telegram
|
||
// @Description Куда сейчас приходят уведомления о заявках: цель (чат или группа), имя бота (свой или глобальный), подключён ли собственный токен бота (has_bot_token) и можно ли его ввести (token_configurable — зависит от DATA_ENCRYPTION_KEY на сервере). Сам токен наружу не отдаётся.
|
||
// @Tags auth
|
||
// @Produce json
|
||
// @Success 200 {object} handlers.TelegramStatusResponse
|
||
// @Failure 401 {object} handlers.ErrorResponse "нет входа"
|
||
// @Failure 404 {object} handlers.ErrorResponse "профиль не найден"
|
||
// @Router /auth/telegram [get]
|
||
func (h *TelegramHandler) Status(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
userID := c.GetString("user_id")
|
||
|
||
target, err := h.repo.Target(ctx, userID)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
hasToken, storedName, err := h.repo.BotTokenInfo(ctx, userID)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
|
||
// Имя бота: живое из реестра (getMe при сохранении или на старте),
|
||
// иначе то, что записали в базу.
|
||
bot := h.botFor(userID)
|
||
username := bot.Username()
|
||
if username == "" {
|
||
username = storedName
|
||
}
|
||
|
||
c.JSON(http.StatusOK, TelegramStatusResponse{
|
||
Enabled: bot.Enabled(),
|
||
Bound: target != "",
|
||
Target: target,
|
||
BotUsername: username,
|
||
HasBotToken: hasToken,
|
||
TokenConfigurable: h.cipher != nil,
|
||
})
|
||
}
|
||
|
||
// BindCodeResponse — ответ на выпуск кода: сам код (его можно набрать
|
||
// в боте руками) и ссылка, открывающая бота с кодом уже подставленным.
|
||
type BindCodeResponse struct {
|
||
Code string `json:"code"`
|
||
Link string `json:"link"`
|
||
ExpiresAt time.Time `json:"expires_at"`
|
||
}
|
||
|
||
// IssueCode — POST /auth/telegram/code. Код одноразовый: бот получает
|
||
// его как аргумент /start, поллер гасит и привязывает чат к аккаунту.
|
||
// Код выдаёт бот пользователя — свой токен либо глобальный.
|
||
//
|
||
// @Summary Выдать код привязки Telegram
|
||
// @Description Одноразовый код и deep-link t.me/{bot}?start={code}: откройте ссылку в Telegram и отправьте боту код — этот чат начнёт получать уведомления о заявках. Бот — ваш собственный токен, а если его нет — глобальный бот сервера. Код живёт 15 минут, прежние коды гаснут. Лимит: 5 выпусков за 5 минут на пользователя.
|
||
// @Tags auth
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Success 200 {object} handlers.BindCodeResponse
|
||
// @Failure 400 {object} handlers.ErrorResponse "ни свой бот, ни глобальный не настроены"
|
||
// @Failure 404 {object} handlers.ErrorResponse "профиль не найден"
|
||
// @Failure 429 {object} handlers.ErrorResponse "слишком много кодов"
|
||
// @Router /auth/telegram/code [post]
|
||
func (h *TelegramHandler) IssueCode(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
userID := c.GetString("user_id")
|
||
bot := h.botFor(userID)
|
||
if !bot.Enabled() {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "telegram not configured"})
|
||
return
|
||
}
|
||
if !h.codeLimiter.Allow("tg_code|" + userID) {
|
||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
|
||
return
|
||
}
|
||
|
||
// Имя бота для ссылки: на старте getMe мог не пройти (сеть),
|
||
// поэтому даём ему второй шанс именно здесь, у редкого запроса.
|
||
// Проверяем раньше базы — инфраструктурная проблема не должна
|
||
// выглядеть как «профиль не найден».
|
||
if h.username(ctx, bot) == "" {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "telegram unavailable"})
|
||
return
|
||
}
|
||
|
||
// Сессия могла пережить удаление профиля: код без владельца
|
||
// привязать не к кому, а отдельного «приветствия» тут не будет.
|
||
if _, err := h.repo.Target(ctx, userID); errors.Is(err, repository.ErrNotFound) {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
return
|
||
} else if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
|
||
code, err := randomCode()
|
||
if err != nil {
|
||
slog.Error("telegram bind code generation failed", "err", err, "user_id", userID)
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
|
||
return
|
||
}
|
||
expires, err := h.repo.CreateBindCode(ctx, userID, telegram.CodeHash(code), bindCodeTTL)
|
||
if err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, BindCodeResponse{
|
||
Code: code,
|
||
Link: telegram.Link(bot.Username(), code),
|
||
ExpiresAt: expires.UTC(),
|
||
})
|
||
}
|
||
|
||
type setTelegramReq struct {
|
||
// Target — @группа/@канал или числовой chat_id; пусто — отключить.
|
||
Target string `json:"target"`
|
||
}
|
||
|
||
// Set — PUT /auth/telegram. Ручной ввод цели (обычно группа или канал)
|
||
// и очистка её пустым значением. Чат проверяет бот пользователя: чужая
|
||
// группа недоступна для его бота так же, как для глобального.
|
||
//
|
||
// @Summary Указать цель уведомлений вручную
|
||
// @Description Принимает @имя группы/канала (бот обязан быть в нём) или числовой chat_id; пустое значение отключает уведомления. Сначала проверяется формат, затем бот проверяет чат через getChat — чужой или недоступный чат не сохраняется.
|
||
// @Tags auth
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param body body setTelegramReq true "Цель: @группа, chat_id или пусто"
|
||
// @Success 200 {object} handlers.OKResponse
|
||
// @Failure 400 {object} handlers.ErrorResponse "invalid telegram target"
|
||
// @Failure 401 {object} handlers.ErrorResponse "нет входа"
|
||
// @Router /auth/telegram [put]
|
||
func (h *TelegramHandler) Set(c *gin.Context) {
|
||
var req setTelegramReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
bindingError(c, err)
|
||
return
|
||
}
|
||
target := strings.TrimSpace(req.Target)
|
||
if target != "" && !targetPattern.MatchString(target) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid telegram target"})
|
||
return
|
||
}
|
||
|
||
// Без включённого бота формат не с чем сверить — проверяем только
|
||
// его (иначе локальная разработка без бота ломается на своих же данных).
|
||
bot := h.botFor(c.GetString("user_id"))
|
||
if target != "" && bot.Enabled() {
|
||
if err := bot.GetChat(c.Request.Context(), target); err != nil {
|
||
// Причина не различается наружу (нет чата / бота выгнали /
|
||
// нет прав — это одно «цель не работает»), детали — в лог.
|
||
slog.Warn("telegram getChat failed", "err", err, "target", target)
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid telegram target"})
|
||
return
|
||
}
|
||
}
|
||
if err := h.repo.SetTarget(c.Request.Context(), c.GetString("user_id"), target); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, OKResponse{OK: true})
|
||
}
|
||
|
||
// Unbind — DELETE /auth/telegram. Убирает цель: уведомления прекращаются.
|
||
//
|
||
// @Summary Отключить уведомления в Telegram
|
||
// @Description Очищает цель уведомлений текущего пользователя (то же, что PUT с пустым значением). Токен бота при этом не трогается — он живёт на своём эндпоинте.
|
||
// @Tags auth
|
||
// @Produce json
|
||
// @Success 200 {object} handlers.OKResponse
|
||
// @Failure 401 {object} handlers.ErrorResponse "нет входа"
|
||
// @Router /auth/telegram [delete]
|
||
func (h *TelegramHandler) Unbind(c *gin.Context) {
|
||
if err := h.repo.SetTarget(c.Request.Context(), c.GetString("user_id"), ""); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, OKResponse{OK: true})
|
||
}
|
||
|
||
type setBotReq struct {
|
||
// Token — токен бота от @BotFather; пусто невалидно (нужен явный ввод).
|
||
Token string `json:"token"`
|
||
}
|
||
|
||
// SetBot — POST /auth/telegram/bot. Сохраняет собственного бота
|
||
// пользователя: проверка токена getMe, шифрование, запись в БД и
|
||
// запуск его поллера.
|
||
//
|
||
// @Summary Подключить своего бота
|
||
// @Description Принимает токен Bot API (формат «id:secret» от @BotFather), проверяет его через getMe, шифрует и сохраняет. Дальше уведомления пользователя идут через этого бота, его поллер поднимается сразу. Лимит: 5 попыток за 5 минут. Без DATA_ENCRYPTION_KEY на сервере — 400.
|
||
// @Tags auth
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Param body body setBotReq true "Токен бота"
|
||
// @Success 200 {object} handlers.OKResponse
|
||
// @Failure 400 {object} handlers.ErrorResponse "токен не сохранён (формат, getMe или фича выключена)"
|
||
// @Failure 401 {object} handlers.ErrorResponse "нет входа"
|
||
// @Failure 404 {object} handlers.ErrorResponse "профиль не найден"
|
||
// @Failure 429 {object} handlers.ErrorResponse "слишком много попыток"
|
||
// @Router /auth/telegram/bot [post]
|
||
func (h *TelegramHandler) SetBot(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
userID := c.GetString("user_id")
|
||
|
||
// Шифрования нет — токен сохранить некуда (секрет лежал бы открытым).
|
||
if h.cipher == nil || h.newBot == nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "telegram bot token not configured"})
|
||
return
|
||
}
|
||
var req setBotReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
bindingError(c, err)
|
||
return
|
||
}
|
||
token := strings.TrimSpace(req.Token)
|
||
// Формат раньше сети: мусор не доходит до getMe.
|
||
if !botTokenPattern.MatchString(token) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot token"})
|
||
return
|
||
}
|
||
if !h.tokenLimiter.Allow("tg_token|" + userID) {
|
||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
|
||
return
|
||
}
|
||
|
||
// Проверка токена: неверный или отозванный не сохраняем, чтобы
|
||
// пользователь не получил молча нерабочего бота.
|
||
bot := h.newBot(token)
|
||
if err := bot.Init(ctx); err != nil {
|
||
slog.Warn("telegram bot token rejected", "err", err, "user_id", userID)
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "bot token rejected"})
|
||
return
|
||
}
|
||
|
||
enc, err := h.cipher.Seal(token)
|
||
if err != nil {
|
||
slog.Error("telegram token seal failed", "err", err, "user_id", userID)
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
|
||
return
|
||
}
|
||
if err := h.repo.SetBotToken(ctx, userID, enc, bot.Username()); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
// Реестр поднимает поллер бота (и гасит прежний, если токен меняли).
|
||
h.bots.Register(userID, bot)
|
||
c.JSON(http.StatusOK, OKResponse{OK: true})
|
||
}
|
||
|
||
// DeleteBot — DELETE /auth/telegram/bot. Убирает собственного бота:
|
||
// поллер гасится, уведомления (если цель осталась) пойдут через
|
||
// глобальный бот сервера либо перестанут уходить, когда его нет.
|
||
//
|
||
// @Summary Отключить своего бота
|
||
// @Description Стирает сохранённый токен текущего пользователя и останавливает его поллер. Повторный вызов безопасен — ответ тот же 200.
|
||
// @Tags auth
|
||
// @Produce json
|
||
// @Success 200 {object} handlers.OKResponse
|
||
// @Failure 401 {object} handlers.ErrorResponse "нет входа"
|
||
// @Failure 404 {object} handlers.ErrorResponse "профиль не найден"
|
||
// @Router /auth/telegram/bot [delete]
|
||
func (h *TelegramHandler) DeleteBot(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
userID := c.GetString("user_id")
|
||
if err := h.repo.ClearBotToken(ctx, userID); err != nil {
|
||
respondError(c, err)
|
||
return
|
||
}
|
||
h.bots.Unregister(userID)
|
||
c.JSON(http.StatusOK, OKResponse{OK: true})
|
||
}
|
||
|
||
// botFor — бот пользователя: собственный токен, иначе глобальный.
|
||
// nil-реестр не бывает: конструктор его всегда получает.
|
||
func (h *TelegramHandler) botFor(userID string) telegram.API {
|
||
return h.bots.BotFor(userID)
|
||
}
|
||
|
||
// username — @имя бота, с повторной попыткой getMe, если на старте
|
||
// не вышло. Пустая строка означает, что ссылку привязки не собрать.
|
||
func (h *TelegramHandler) username(ctx context.Context, bot telegram.API) string {
|
||
if bot == nil || !bot.Enabled() {
|
||
return ""
|
||
}
|
||
if name := bot.Username(); name != "" {
|
||
return name
|
||
}
|
||
if err := bot.Init(ctx); err != nil {
|
||
slog.Warn("telegram getMe failed", "err", err)
|
||
return ""
|
||
}
|
||
return bot.Username()
|
||
}
|
||
|
||
// randomCode — код привязки: crypto/rand по алфавиту без похожих знаков.
|
||
// Алфавит 32 символа — 256 делится на 32 ровно, смещения в байте нет.
|
||
func randomCode() (string, error) {
|
||
raw := make([]byte, bindCodeLen)
|
||
if _, err := rand.Read(raw); err != nil {
|
||
return "", err
|
||
}
|
||
for i, b := range raw {
|
||
raw[i] = bindCodeAlphabet[int(b)%len(bindCodeAlphabet)]
|
||
}
|
||
return string(raw), nil
|
||
}
|