From 7704de334cbc809fedac49a08b7334dcf272e386 Mon Sep 17 00:00:00 2001 From: poignatov Date: Thu, 1 Jan 2026 18:38:28 +0300 Subject: [PATCH] v2.0.3: Webhook user identification by URL token - Added webhook_token to telegram_integrations - Webhooks now identify users by token in URL (/webhook/telegram/{token}, /webhook/todoist/{token}) - Webhook automatically configured for all users on backend startup - Migration 011: Add webhook_token column --- VERSION | 2 +- play-life-backend/main.go | 292 ++++++++++++++---- .../migrations/011_add_webhook_tokens.sql | 17 + play-life-web/package.json | 2 +- 4 files changed, 245 insertions(+), 68 deletions(-) create mode 100644 play-life-backend/migrations/011_add_webhook_tokens.sql diff --git a/VERSION b/VERSION index e9307ca..50ffc5a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.2 +2.0.3 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 0a5ddca..e3008d0 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -281,6 +281,16 @@ func generateRefreshToken() (string, error) { return base64.URLEncoding.EncodeToString(b), nil } +// generateWebhookToken generates a unique token for webhook URL identification +func generateWebhookToken() (string, error) { + b := make([]byte, 24) // 24 bytes = 32 chars in base64 + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + func (a *App) generateAccessToken(userID int) (string, error) { claims := JWTClaims{ UserID: userID, @@ -2500,6 +2510,10 @@ func (a *App) initAuthDB() error { // Create new unique constraint per user for progress a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_progress_word_user_unique ON progress(word_id, user_id)") + // Add webhook_token to telegram_integrations for URL-based user identification + a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS webhook_token VARCHAR(255)") + a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_webhook_token ON telegram_integrations(webhook_token) WHERE webhook_token IS NOT NULL") + // Clean up expired refresh tokens a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at < NOW()") @@ -3085,20 +3099,46 @@ func main() { jwtSecret: []byte(jwtSecret), } - // Пытаемся настроить webhook автоматически при старте, если есть base URL и bot token в БД + // Пытаемся настроить webhook автоматически при старте для всех пользователей с bot_token webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "") if webhookBaseURL != "" { - integration, err := app.getTelegramIntegration() - if err == nil && integration.BotToken != nil && *integration.BotToken != "" { - webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram" - log.Printf("Attempting to setup Telegram webhook at startup. WEBHOOK_BASE_URL='%s'", webhookBaseURL) - if err := setupTelegramWebhook(*integration.BotToken, webhookURL); err != nil { - log.Printf("Warning: Failed to setup Telegram webhook at startup: %v. Webhook will be configured when user saves bot token.", err) - } else { - log.Printf("SUCCESS: Telegram webhook configured successfully at startup: %s", webhookURL) - } + log.Printf("Setting up Telegram webhooks for all users at startup...") + rows, err := app.DB.Query(` + SELECT user_id, bot_token, webhook_token + FROM telegram_integrations + WHERE bot_token IS NOT NULL + AND bot_token != '' + AND webhook_token IS NOT NULL + AND webhook_token != '' + AND user_id IS NOT NULL + `) + if err != nil { + log.Printf("Warning: Failed to query telegram integrations at startup: %v", err) } else { - log.Printf("Telegram bot token not found in database. Webhook will be configured when user saves bot token.") + defer rows.Close() + configuredCount := 0 + for rows.Next() { + var userID int + var botToken, webhookToken string + if err := rows.Scan(&userID, &botToken, &webhookToken); err != nil { + log.Printf("Warning: Failed to scan telegram integration: %v", err) + continue + } + + webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram/" + webhookToken + log.Printf("Setting up Telegram webhook for user_id=%d: URL=%s", userID, webhookURL) + if err := setupTelegramWebhook(botToken, webhookURL); err != nil { + log.Printf("Warning: Failed to setup Telegram webhook for user_id=%d: %v", userID, err) + } else { + log.Printf("SUCCESS: Telegram webhook configured for user_id=%d: %s", userID, webhookURL) + configuredCount++ + } + } + if configuredCount > 0 { + log.Printf("Telegram webhooks configured for %d user(s) at startup", configuredCount) + } else { + log.Printf("No Telegram integrations found with bot_token and webhook_token. Webhooks will be configured when users save bot tokens.") + } } } else { log.Printf("WEBHOOK_BASE_URL not set. Webhook will be configured when user saves bot token.") @@ -3137,8 +3177,8 @@ func main() { // Webhooks - no auth (external services) r.HandleFunc("/webhook/message/post", app.messagePostHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/webhook/telegram", app.telegramWebhookHandler).Methods("POST", "OPTIONS") + r.HandleFunc("/webhook/todoist/{token}", app.todoistWebhookHandler).Methods("POST", "OPTIONS") + r.HandleFunc("/webhook/telegram/{token}", app.telegramWebhookHandler).Methods("POST", "OPTIONS") // Admin pages (basic access, consider adding auth later) r.HandleFunc("/admin", app.adminHandler).Methods("GET") @@ -3285,35 +3325,42 @@ func roundToFourDecimals(val float64) float64 { // TelegramIntegration представляет запись из таблицы telegram_integrations type TelegramIntegration struct { - ID int `json:"id"` - ChatID *string `json:"chat_id"` - BotToken *string `json:"bot_token"` + ID int `json:"id"` + ChatID *string `json:"chat_id"` + BotToken *string `json:"bot_token"` + WebhookToken *string `json:"webhook_token"` } // getTelegramIntegration получает telegram интеграцию из БД // getTelegramIntegrationForUser gets telegram integration for specific user func (a *App) getTelegramIntegrationForUser(userID int) (*TelegramIntegration, error) { var integration TelegramIntegration - var chatID, botToken sql.NullString + var chatID, botToken, webhookToken sql.NullString err := a.DB.QueryRow(` - SELECT id, chat_id, bot_token + SELECT id, chat_id, bot_token, webhook_token FROM telegram_integrations WHERE user_id = $1 ORDER BY id DESC LIMIT 1 - `, userID).Scan(&integration.ID, &chatID, &botToken) + `, userID).Scan(&integration.ID, &chatID, &botToken, &webhookToken) if err == sql.ErrNoRows { - // Если записи нет, создаем новую для этого пользователя + // Если записи нет, создаем новую для этого пользователя с webhook токеном + webhookToken, err := generateWebhookToken() + if err != nil { + return nil, fmt.Errorf("failed to generate webhook token: %w", err) + } + err = a.DB.QueryRow(` - INSERT INTO telegram_integrations (chat_id, bot_token, user_id) - VALUES (NULL, NULL, $1) + INSERT INTO telegram_integrations (chat_id, bot_token, user_id, webhook_token) + VALUES (NULL, NULL, $1, $2) RETURNING id - `, userID).Scan(&integration.ID) + `, userID, webhookToken).Scan(&integration.ID) if err != nil { return nil, fmt.Errorf("failed to create telegram integration: %w", err) } + integration.WebhookToken = &webhookToken return &integration, nil } else if err != nil { return nil, fmt.Errorf("failed to get telegram integration: %w", err) @@ -3325,6 +3372,24 @@ func (a *App) getTelegramIntegrationForUser(userID int) (*TelegramIntegration, e if botToken.Valid { integration.BotToken = &botToken.String } + if webhookToken.Valid { + integration.WebhookToken = &webhookToken.String + } else { + // Если токена нет, генерируем его + newToken, err := generateWebhookToken() + if err != nil { + return nil, fmt.Errorf("failed to generate webhook token: %w", err) + } + _, err = a.DB.Exec(` + UPDATE telegram_integrations + SET webhook_token = $1 + WHERE id = $2 + `, newToken, integration.ID) + if err != nil { + return nil, fmt.Errorf("failed to update webhook token: %w", err) + } + integration.WebhookToken = &newToken + } return &integration, nil } @@ -3404,11 +3469,15 @@ func (a *App) saveTelegramBotTokenForUser(botToken string, userID int) error { // Проверяем, есть ли уже запись для этого пользователя integration, err := a.getTelegramIntegrationForUser(userID) if err != nil { - // Если записи нет, создаем новую + // Если записи нет, создаем новую с webhook токеном + webhookToken, err := generateWebhookToken() + if err != nil { + return fmt.Errorf("failed to generate webhook token: %w", err) + } _, err = a.DB.Exec(` - INSERT INTO telegram_integrations (bot_token, chat_id, user_id) - VALUES ($1, NULL, $2) - `, botToken, userID) + INSERT INTO telegram_integrations (bot_token, chat_id, user_id, webhook_token) + VALUES ($1, NULL, $2, $3) + `, botToken, userID, webhookToken) if err != nil { return fmt.Errorf("failed to create telegram bot token: %w", err) } @@ -3422,6 +3491,21 @@ func (a *App) saveTelegramBotTokenForUser(botToken string, userID int) error { if err != nil { return fmt.Errorf("failed to update telegram bot token: %w", err) } + // Убеждаемся, что webhook_token есть + if integration.WebhookToken == nil || *integration.WebhookToken == "" { + webhookToken, err := generateWebhookToken() + if err != nil { + return fmt.Errorf("failed to generate webhook token: %w", err) + } + _, err = a.DB.Exec(` + UPDATE telegram_integrations + SET webhook_token = $1 + WHERE id = $2 + `, webhookToken, integration.ID) + if err != nil { + return fmt.Errorf("failed to update webhook token: %w", err) + } + } } return nil } @@ -3541,7 +3625,8 @@ func utf16LengthToUTF8(text string, utf16Offset, utf16Length int) int { // processTelegramMessage обрабатывает сообщение из Telegram с использованием entities // Логика отличается от processMessage: использует entities для определения жирного текста // и не отправляет сообщение обратно в Telegram -func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity) (*ProcessedEntry, error) { +// userID может быть nil, если пользователь не определен +func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity, userID *int) (*ProcessedEntry, error) { fullText = strings.TrimSpace(fullText) // Регулярное выражение: project+/-score (без **) @@ -3646,7 +3731,7 @@ func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity) // Вставляем данные в БД только если есть nodes if len(scoreNodes) > 0 { - err := a.insertMessageData(processedText, createdDate, scoreNodes, nil) // nil userID for webhook + err := a.insertMessageData(processedText, createdDate, scoreNodes, userID) if err != nil { log.Printf("Error inserting message data: %v", err) return nil, fmt.Errorf("error inserting data: %w", err) @@ -4802,12 +4887,6 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Method: %s", r.Method) log.Printf("URL: %s", r.URL.String()) log.Printf("RemoteAddr: %s", r.RemoteAddr) - log.Printf("Headers:") - for key, values := range r.Header { - for _, value := range values { - log.Printf(" %s: %s", key, value) - } - } if r.Method == "OPTIONS" { log.Printf("OPTIONS request, returning OK") @@ -4817,6 +4896,35 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) { } setCORSHeaders(w) + // Извлекаем токен из URL + vars := mux.Vars(r) + token := vars["token"] + if token == "" { + log.Printf("Todoist webhook: missing token in URL") + sendErrorWithCORS(w, "Missing webhook token", http.StatusBadRequest) + return + } + + // Находим пользователя по токену из telegram_integrations (используем тот же механизм) + var userID int + err := a.DB.QueryRow(` + SELECT user_id FROM telegram_integrations + WHERE webhook_token = $1 AND user_id IS NOT NULL + LIMIT 1 + `, token).Scan(&userID) + + if err == sql.ErrNoRows { + log.Printf("Todoist webhook: invalid token: %s", token) + sendErrorWithCORS(w, "Invalid webhook token", http.StatusUnauthorized) + return + } else if err != nil { + log.Printf("Error finding user by webhook token: %v", err) + sendErrorWithCORS(w, "Internal server error", http.StatusInternalServerError) + return + } + + log.Printf("Todoist webhook: token=%s, user_id=%d", token, userID) + // Читаем тело запроса для логирования bodyBytes, err := io.ReadAll(r.Body) if err != nil { @@ -4925,8 +5033,9 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) { title, len(title), description, len(description), combinedText, len(combinedText)) // Обрабатываем сообщение через существующую логику (без отправки в Telegram) - log.Printf("Calling processMessageWithoutTelegram with combined text...") - response, err := a.processMessageWithoutTelegram(combinedText, nil) // nil userID for webhook + userIDPtr := &userID + log.Printf("Calling processMessageWithoutTelegram with combined text, user_id=%d...", userID) + response, err := a.processMessageWithoutTelegram(combinedText, userIDPtr) if err != nil { log.Printf("ERROR processing Todoist message: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) @@ -4977,6 +5086,35 @@ func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) { } setCORSHeaders(w) + // Извлекаем токен из URL + vars := mux.Vars(r) + token := vars["token"] + if token == "" { + log.Printf("Telegram webhook: missing token in URL") + sendErrorWithCORS(w, "Missing webhook token", http.StatusBadRequest) + return + } + + // Находим пользователя по токену + var userID int + err := a.DB.QueryRow(` + SELECT user_id FROM telegram_integrations + WHERE webhook_token = $1 AND user_id IS NOT NULL + LIMIT 1 + `, token).Scan(&userID) + + if err == sql.ErrNoRows { + log.Printf("Telegram webhook: invalid token: %s", token) + sendErrorWithCORS(w, "Invalid webhook token", http.StatusUnauthorized) + return + } else if err != nil { + log.Printf("Error finding user by webhook token: %v", err) + sendErrorWithCORS(w, "Internal server error", http.StatusInternalServerError) + return + } + + log.Printf("Telegram webhook: token=%s, user_id=%d", token, userID) + // Парсим webhook от Telegram var update TelegramUpdate if err := json.NewDecoder(r.Body).Decode(&update); err != nil { @@ -5002,31 +5140,34 @@ func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) { return } - log.Printf("Telegram webhook: message present, chat_id=%d", message.Chat.ID) + log.Printf("Telegram webhook: message present, chat_id=%d, user_id=%d", message.Chat.ID, userID) - // Сохраняем chat_id при первом сообщении (даже если нет текста) + // Сохраняем chat_id при первом сообщении (если еще не сохранен) if message.Chat.ID != 0 { chatIDStr := strconv.FormatInt(message.Chat.ID, 10) - log.Printf("Processing chat_id: %s", chatIDStr) - integration, err := a.getTelegramIntegration() - if err != nil { - log.Printf("Error getting telegram integration: %v", err) - } else { + var existingChatID sql.NullString + err := a.DB.QueryRow(` + SELECT chat_id FROM telegram_integrations + WHERE user_id = $1 + LIMIT 1 + `, userID).Scan(&existingChatID) + + if err == nil && (!existingChatID.Valid || existingChatID.String == "") { // Сохраняем chat_id, если его еще нет - if integration.ChatID == nil || *integration.ChatID == "" { - log.Printf("Attempting to save chat_id: %s", chatIDStr) - if err := a.saveTelegramChatID(chatIDStr); err != nil { - log.Printf("Warning: Failed to save chat_id: %v", err) - } else { - log.Printf("Successfully saved chat_id from first message: %s", chatIDStr) - } + _, err = a.DB.Exec(` + UPDATE telegram_integrations + SET chat_id = $1 + WHERE user_id = $2 + `, chatIDStr, userID) + if err != nil { + log.Printf("Warning: Failed to save chat_id: %v", err) } else { - log.Printf("Chat_id already exists in database: %s", *integration.ChatID) + log.Printf("Successfully saved chat_id from first message: %s", chatIDStr) } } - } else { - log.Printf("Warning: message.Chat.ID is 0, cannot save chat_id") } + + userIDPtr := &userID // Проверяем, что есть текст в сообщении if message.Text == "" { @@ -5044,10 +5185,10 @@ func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) { entities = []TelegramEntity{} } - log.Printf("Processing Telegram message: text='%s', entities count=%d", fullText, len(entities)) + log.Printf("Processing Telegram message: text='%s', entities count=%d, user_id=%d", fullText, len(entities), userID) // Обрабатываем сообщение через новую логику (с entities, без отправки обратно в Telegram) - response, err := a.processTelegramMessage(fullText, entities) + response, err := a.processTelegramMessage(fullText, entities, userIDPtr) if err != nil { log.Printf("Error processing Telegram message: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) @@ -5207,11 +5348,18 @@ func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Re return } + // Получаем обновленную интеграцию с webhook токеном + integration, err := a.getTelegramIntegrationForUser(userID) + if err != nil { + sendErrorWithCORS(w, fmt.Sprintf("Failed to get updated integration: %v", err), http.StatusInternalServerError) + return + } + // Настраиваем webhook автоматически при сохранении токена webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "") log.Printf("Attempting to setup Telegram webhook. WEBHOOK_BASE_URL='%s'", webhookBaseURL) - if webhookBaseURL != "" { - webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram" + if webhookBaseURL != "" && integration.WebhookToken != nil && *integration.WebhookToken != "" { + webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram/" + *integration.WebhookToken log.Printf("Setting up Telegram webhook: URL=%s", webhookURL) if err := setupTelegramWebhook(req.BotToken, webhookURL); err != nil { log.Printf("ERROR: Failed to setup Telegram webhook: %v", err) @@ -5220,13 +5368,7 @@ func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Re log.Printf("SUCCESS: Telegram webhook configured successfully: %s", webhookURL) } } else { - log.Printf("WARNING: WEBHOOK_BASE_URL not set. Webhook will not be configured automatically.") - } - - integration, err := a.getTelegramIntegrationForUser(userID) - if err != nil { - sendErrorWithCORS(w, fmt.Sprintf("Failed to get updated integration: %v", err), http.StatusInternalServerError) - return + log.Printf("WARNING: WEBHOOK_BASE_URL not set or webhook_token missing. Webhook will not be configured automatically.") } w.Header().Set("Content-Type", "application/json") @@ -5242,6 +5384,24 @@ func (a *App) getTodoistWebhookURLHandler(w http.ResponseWriter, r *http.Request } setCORSHeaders(w) + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Получаем webhook токен для пользователя + integration, err := a.getTelegramIntegrationForUser(userID) + if err != nil { + sendErrorWithCORS(w, fmt.Sprintf("Failed to get telegram integration: %v", err), http.StatusInternalServerError) + return + } + + if integration.WebhookToken == nil || *integration.WebhookToken == "" { + sendErrorWithCORS(w, "Webhook token not available", http.StatusInternalServerError) + return + } + // Получаем base URL из env baseURL := getEnv("WEBHOOK_BASE_URL", "") if baseURL == "" { @@ -5249,7 +5409,7 @@ func (a *App) getTodoistWebhookURLHandler(w http.ResponseWriter, r *http.Request return } - webhookURL := strings.TrimRight(baseURL, "/") + "/webhook/todoist" + webhookURL := strings.TrimRight(baseURL, "/") + "/webhook/todoist/" + *integration.WebhookToken w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ diff --git a/play-life-backend/migrations/011_add_webhook_tokens.sql b/play-life-backend/migrations/011_add_webhook_tokens.sql new file mode 100644 index 0000000..f871e9f --- /dev/null +++ b/play-life-backend/migrations/011_add_webhook_tokens.sql @@ -0,0 +1,17 @@ +-- Migration: Add webhook_token to telegram_integrations +-- This allows identifying user by webhook URL token + +-- Add webhook_token column to telegram_integrations +ALTER TABLE telegram_integrations +ADD COLUMN IF NOT EXISTS webhook_token VARCHAR(255); + +-- Create unique index on webhook_token for fast lookups +CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_webhook_token +ON telegram_integrations(webhook_token) +WHERE webhook_token IS NOT NULL; + +-- Generate webhook tokens for existing integrations +-- This will be handled by application code, but we ensure the column exists + +COMMENT ON COLUMN telegram_integrations.webhook_token IS 'Unique token for webhook URL identification (e.g., /webhook/telegram/{token})'; + diff --git a/play-life-web/package.json b/play-life-web/package.json index c52f27c..316fbf4 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "2.0.2", + "version": "2.0.3", "type": "module", "scripts": { "dev": "vite",