diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index db6cb31f..a5196c34 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -31,6 +31,7 @@ import ( "github.com/umputun/remark42/backend/app/migrator" "github.com/umputun/remark42/backend/app/notify" + "github.com/umputun/remark42/backend/app/providers" "github.com/umputun/remark42/backend/app/rest/api" "github.com/umputun/remark42/backend/app/rest/proxy" "github.com/umputun/remark42/backend/app/store" @@ -477,8 +478,9 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) { log.Printf("[WARN] failed to prepare notify destinations, %s", err) } + // telegramService is used for getting user replies for both notifications and authorization var telegramService *notify.Telegram - if contains("telegram", s.Notify.Users) || contains("telegram", s.Notify.Admins) { + if contains("telegram", s.Notify.Users) || contains("telegram", s.Notify.Admins) || s.Auth.Telegram { telegramService, err = s.makeTelegramNotify() if err != nil { log.Printf("[WARN] failed to make telegram notify service, %s", err) @@ -487,8 +489,12 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) { var telegramBotUsername string if telegramService != nil { + if contains("telegram", s.Notify.Users) { + telegramBotUsername = telegramService.GetBotUsername() + } notifyDestinations = append(notifyDestinations, telegramService) - telegramBotUsername = telegramService.GetBotUsername() + // start generic update for both notify and auth services + go providers.DispatchTelegramUpdates(ctx, telegramService, []providers.TGUpdatesReceiver{telegramService}, time.Second*5) } notifyService := s.makeNotifyService(dataService, notifyDestinations) @@ -525,6 +531,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) { Authenticator: authenticator, Cache: loadingCache, NotifyService: notifyService, + TelegramService: telegramService, SSLConfig: sslConfig, UpdateLimiter: s.UpdateLimit, ImageService: imageService, @@ -780,34 +787,34 @@ func (s *ServerCommand) makeCache() (LoadingCache, error) { func (s *ServerCommand) addAuthProviders(ctx context.Context, authenticator *auth.Service) error { - providers := 0 + providersCount := 0 if s.Auth.Google.CID != "" && s.Auth.Google.CSEC != "" { authenticator.AddProvider("google", s.Auth.Google.CID, s.Auth.Google.CSEC) - providers++ + providersCount++ } if s.Auth.Github.CID != "" && s.Auth.Github.CSEC != "" { authenticator.AddProvider("github", s.Auth.Github.CID, s.Auth.Github.CSEC) - providers++ + providersCount++ } if s.Auth.Facebook.CID != "" && s.Auth.Facebook.CSEC != "" { authenticator.AddProvider("facebook", s.Auth.Facebook.CID, s.Auth.Facebook.CSEC) - providers++ + providersCount++ } if s.Auth.Microsoft.CID != "" && s.Auth.Microsoft.CSEC != "" { authenticator.AddProvider("microsoft", s.Auth.Microsoft.CID, s.Auth.Microsoft.CSEC) - providers++ + providersCount++ } if s.Auth.Yandex.CID != "" && s.Auth.Yandex.CSEC != "" { authenticator.AddProvider("yandex", s.Auth.Yandex.CID, s.Auth.Yandex.CSEC) - providers++ + providersCount++ } if s.Auth.Twitter.CID != "" && s.Auth.Twitter.CSEC != "" { authenticator.AddProvider("twitter", s.Auth.Twitter.CID, s.Auth.Twitter.CSEC) - providers++ + providersCount++ } if s.Auth.Patreon.CID != "" && s.Auth.Patreon.CSEC != "" { authenticator.AddProvider("patreon", s.Auth.Patreon.CID, s.Auth.Patreon.CSEC) - providers++ + providersCount++ } if s.Auth.Telegram { telegram := &provider.TelegramHandler{ @@ -828,13 +835,13 @@ func (s *ServerCommand) addAuthProviders(ctx context.Context, authenticator *aut }() authenticator.AddCustomHandler(telegram) - providers++ + providersCount++ } if s.Auth.Dev { log.Print("[INFO] dev access enabled") authenticator.AddProvider("dev", "", "") - providers++ + providersCount++ } if s.Auth.Email.Enable { @@ -886,7 +893,7 @@ func (s *ServerCommand) addAuthProviders(ctx context.Context, authenticator *aut })) } - if providers == 0 { + if providersCount == 0 { log.Printf("[WARN] no auth providers defined") } @@ -1012,6 +1019,7 @@ func (s *ServerCommand) makeTelegramNotify() (*notify.Telegram, error) { UserNotifications: contains("telegram", s.Notify.Users), Token: s.Telegram.Token, Timeout: s.Telegram.Timeout, + SuccessMsg: "✅ You have successfully subscribed for notifications, check the web!", } tg, err := notify.NewTelegram(telegramParams) if err != nil { diff --git a/backend/app/notify/notify.go b/backend/app/notify/notify.go index dd10c2b5..bdcef9d9 100644 --- a/backend/app/notify/notify.go +++ b/backend/app/notify/notify.go @@ -51,11 +51,10 @@ type Request struct { // VerificationRequest notification for user type VerificationRequest struct { - SiteID string - User string - Email string // if set, send email only - Telegram string // if set, send telegram only - Token string + SiteID string + User string + Email string // if set, send email only + Token string } const defaultQueueSize = 100 diff --git a/backend/app/notify/telegram.go b/backend/app/notify/telegram.go index b37f8b66..89194ad7 100644 --- a/backend/app/notify/telegram.go +++ b/backend/app/notify/telegram.go @@ -7,8 +7,11 @@ import ( "fmt" "io" "net/http" + neturl "net/url" "strconv" "strings" + "sync" + "sync/atomic" "time" "github.com/hashicorp/go-multierror" @@ -21,10 +24,11 @@ import ( // TelegramParams contain settings for telegram notifications type TelegramParams struct { - AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername) - Token string // token for telegram bot API interactions - Timeout time.Duration // http client timeout - UserNotifications bool // flag which enables user notifications + AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername) + Token string // token for telegram bot API interactions + Timeout time.Duration // http client timeout + UserNotifications bool // flag which enables user notifications + ErrorMsg, SuccessMsg string // messages for successful and unsuccessful subscription requests to bot apiPrefix string // changed only in tests } @@ -33,7 +37,18 @@ type TelegramParams struct { type Telegram struct { TelegramParams - username string // bot username + // Identifier of the first update to be requested. + // Should be equal to LastSeenUpdateID + 1 + // See https://core.telegram.org/bots/api#getupdates + updateOffset int + apiPollInterval time.Duration // interval to check updates from Telegram API and answer to users + expiredCleanupInterval time.Duration // interval to check and clean up expired notification requests + username string // bot username + run int32 // non-zero if Run goroutine has started + requests struct { + sync.RWMutex + data map[string]tgAuthRequest + } } // telegramMsg is used to send message trough Telegram bot API @@ -42,6 +57,14 @@ type telegramMsg struct { ParseMode string `json:"parse_mode,omitempty"` } +type tgAuthRequest struct { + confirmed bool // whether login request has been confirmed and user info set + expires time.Time + telegramID string + user string + site string +} + // TelegramBotInfo structure contains information about telegram bot, which is used from whole telegram API response type TelegramBotInfo struct { Username string `json:"username"` @@ -49,6 +72,8 @@ type TelegramBotInfo struct { const telegramTimeOut = 5000 * time.Millisecond const telegramAPIPrefix = "https://api.telegram.org/bot" +const tgPollInterval = time.Second * 5 +const tgCleanupInterval = time.Minute * 5 // NewTelegram makes telegram bot for notifications func NewTelegram(params TelegramParams) (*Telegram, error) { @@ -60,6 +85,8 @@ func NewTelegram(params TelegramParams) (*Telegram, error) { if res.Timeout == 0 { res.Timeout = telegramTimeOut } + res.apiPollInterval = tgPollInterval + res.expiredCleanupInterval = tgCleanupInterval log.Printf("[DEBUG] create new telegram notifier for api=%s, timeout=%s", res.apiPrefix, res.Timeout) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -71,6 +98,8 @@ func NewTelegram(params TelegramParams) (*Telegram, error) { } res.username = botInfo.Username + res.requests.data = make(map[string]tgAuthRequest) + return &res, nil } @@ -108,7 +137,7 @@ func (t *Telegram) sendMessage(ctx context.Context, b []byte, chatID string) err } url := fmt.Sprintf("sendMessage?chat_id=%s&disable_web_page_preview=true", chatID) - return t.request(ctx, url, b, &struct{}{}) + return t.Request(ctx, url, b, &struct{}{}) } // buildMessage generates message for generic notification about new comment @@ -159,39 +188,24 @@ func escapeTelegramText(text string) string { return text } -// SendVerification sends user verification message to the specified user -func (t *Telegram) SendVerification(ctx context.Context, req VerificationRequest) error { - if req.Telegram == "" { - // this means we can't send this request via Telegram - return nil - } - select { - case <-ctx.Done(): - return errors.Errorf("sending message to %q aborted due to canceled context", req.User) - default: - } - - log.Printf("[DEBUG] send verification via %s, user %s", t, req.User) - msg, err := t.buildVerificationMessage(req.User, req.Token, req.SiteID) - if err != nil { - return err - } - - return t.sendMessage(ctx, msg, req.Telegram) +// SendVerification is not needed for telegram +func (t *Telegram) SendVerification(_ context.Context, _ VerificationRequest) error { + return nil } -// buildVerificationMessage generates verification telegram message based on given input -func (t *Telegram) buildVerificationMessage(user, token, site string) ([]byte, error) { - result := fmt.Sprintf("Confirmation for %s on site %s\n"+ - "Please copy and paste this text into “token” field on comments page to confirm subscription:\n\n\n"+ - "
%s
", - escapeTelegramText(user), escapeTelegramText(site), escapeTelegramText(token)) - body := telegramMsg{Text: result, ParseMode: "HTML"} - b, err := json.Marshal(body) - if err != nil { - return nil, err - } - return b, nil +// TelegramUpdate contains update information, which is used from whole telegram API response +type TelegramUpdate struct { + Result []struct { + UpdateID int `json:"update_id"` + Message struct { + Chat struct { + ID int `json:"id"` + Name string `json:"first_name"` + Type string `json:"type"` + } `json:"chat"` + Text string `json:"text"` + } `json:"message"` + } `json:"result"` } // GetBotUsername returns bot username @@ -199,6 +213,110 @@ func (t *Telegram) GetBotUsername() string { return t.username } +// AddToken adds token +func (t *Telegram) AddToken(token, user, site string, expires time.Time) { + t.requests.Lock() + t.requests.data[token] = tgAuthRequest{ + expires: expires, + user: user, + site: site, + } + t.requests.Unlock() +} + +// CheckToken verifies incoming token, returns the user address if it's confirmed and empty string otherwise +func (t *Telegram) CheckToken(token, user string) (telegram, site string, err error) { + t.requests.RLock() + authRequest, ok := t.requests.data[token] + t.requests.RUnlock() + + if !ok { + return "", "", errors.New("request is not found") + } + + if time.Now().After(authRequest.expires) { + t.requests.Lock() + delete(t.requests.data, token) + t.requests.Unlock() + return "", "", errors.New("request expired") + } + + if !authRequest.confirmed { + return "", "", errors.New("request is not verified yet") + } + + if authRequest.user != user { + return "", "", errors.New("user does not match original requester") + } + + // Delete request + t.requests.Lock() + delete(t.requests.data, token) + t.requests.Unlock() + + return authRequest.telegramID, authRequest.site, nil +} + +// Run starts processing login requests sent in Telegram, required for user notifications to work +// Blocks caller +func (t *Telegram) Run(ctx context.Context) { + atomic.AddInt32(&t.run, 1) + processUpdatedTicker := time.NewTicker(t.apiPollInterval) + cleanupTicker := time.NewTicker(t.expiredCleanupInterval) + + for { + select { + case <-ctx.Done(): + processUpdatedTicker.Stop() + cleanupTicker.Stop() + atomic.AddInt32(&t.run, -1) + return + case <-processUpdatedTicker.C: + updates, err := t.getUpdates(ctx) + if err != nil { + log.Printf("[WARN] Error while getting telegram updates: %v", err) + continue + } + t.processUpdates(ctx, updates) + case <-cleanupTicker.C: + now := time.Now() + t.requests.Lock() + for key, req := range t.requests.data { + if now.After(req.expires) { + delete(t.requests.data, key) + } + } + t.requests.Unlock() + } + } +} + +// ProcessUpdate is alternative to Run, it processes provided plain text update from Telegram +// so that caller could get updates and send it not only there but to multiple sources +func (t *Telegram) ProcessUpdate(ctx context.Context, textUpdate string) error { + if atomic.LoadInt32(&t.run) != 0 { + return errors.New("Run goroutine should not be used with ProcessUpdate") + } + defer func() { + // as Run goroutine is not running, clean up old requests on each update + // even if we hit json decode error + now := time.Now() + t.requests.Lock() + for key, req := range t.requests.data { + if now.After(req.expires) { + delete(t.requests.data, key) + } + } + t.requests.Unlock() + }() + var updates TelegramUpdate + if err := json.Unmarshal([]byte(textUpdate), &updates); err != nil { + return errors.Wrap(err, "failed to decode provided telegram update") + } + t.processUpdates(ctx, &updates) + return nil +} + func (t *Telegram) String() string { result := "telegram" if t.AdminChannelID != "" { @@ -210,13 +328,81 @@ func (t *Telegram) String() string { return result } +// getUpdates fetches incoming updates +func (t *Telegram) getUpdates(ctx context.Context) (*TelegramUpdate, error) { + url := `getUpdates?allowed_updates=["message"]` + if t.updateOffset != 0 { + url += fmt.Sprintf("&offset=%d", t.updateOffset) + } + + var result TelegramUpdate + + err := t.Request(ctx, url, nil, &result) + if err != nil { + return nil, errors.Wrap(err, "failed to fetch updates") + } + + for _, u := range result.Result { + if u.UpdateID >= t.updateOffset { + t.updateOffset = u.UpdateID + 1 + } + } + + return &result, nil +} + +// processUpdates processes a batch of updates from telegram servers +func (t *Telegram) processUpdates(ctx context.Context, updates *TelegramUpdate) { + for _, update := range updates.Result { + if update.Message.Chat.Type != "private" { + continue + } + + if !strings.HasPrefix(update.Message.Text, "/start ") { + continue + } + + token := strings.TrimPrefix(update.Message.Text, "/start ") + + t.requests.RLock() + authRequest, ok := t.requests.data[token] + if !ok { // No such token + t.requests.RUnlock() + if t.ErrorMsg != "" { + if err := t.sendText(ctx, update.Message.Chat.ID, t.ErrorMsg); err != nil { + log.Printf("[WARN] failed to notify telegram peer: %v", err) + } + } + continue + } + t.requests.RUnlock() + + authRequest.confirmed = true + authRequest.telegramID = strconv.Itoa(update.Message.Chat.ID) + + t.requests.Lock() + t.requests.data[token] = authRequest + t.requests.Unlock() + + if err := t.sendText(ctx, update.Message.Chat.ID, t.SuccessMsg); err != nil { + log.Printf("[ERROR] failed to notify telegram peer: %v", err) + } + } +} + +// sendText sends a plain text message to telegram peer +func (t *Telegram) sendText(ctx context.Context, recipientID int, msg string) error { + url := fmt.Sprintf("sendMessage?chat_id=%d&text=%s", recipientID, neturl.PathEscape(msg)) + return t.Request(ctx, url, nil, &struct{}{}) +} + // botInfo returns info about configured bot func (t *Telegram) botInfo(ctx context.Context) (*TelegramBotInfo, error) { var resp = struct { Result *TelegramBotInfo `json:"result"` }{} - err := t.request(ctx, "getMe", nil, &resp) + err := t.Request(ctx, "getMe", nil, &resp) if err != nil { return nil, err } @@ -227,7 +413,8 @@ func (t *Telegram) botInfo(ctx context.Context) (*TelegramBotInfo, error) { return resp.Result, nil } -func (t *Telegram) request(ctx context.Context, method string, b []byte, data interface{}) error { +// Request makes a request to the Telegram API and return the result +func (t *Telegram) Request(ctx context.Context, method string, b []byte, data interface{}) error { return repeater.NewDefault(3, time.Millisecond*250).Do(ctx, func() error { url := fmt.Sprintf("%s%s/%s", t.apiPrefix, t.Token, method) diff --git a/backend/app/notify/telegram_test.go b/backend/app/notify/telegram_test.go index 4e3f5afd..5df299e7 100644 --- a/backend/app/notify/telegram_test.go +++ b/backend/app/notify/telegram_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -179,28 +180,282 @@ func TestTelegram_SendVerification(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, tb) - // proper VerificationRequest without telegram - req := VerificationRequest{ - SiteID: "remark", - User: "test_username", - Token: "secret_", - } - assert.NoError(t, tb.SendVerification(context.TODO(), req)) + // empty VerificationRequest should return no error and no nothing, as well as any other + assert.NoError(t, tb.SendVerification(context.TODO(), VerificationRequest{})) +} - // proper VerificationRequest with telegram - req.Telegram = "test" - assert.NoError(t, tb.SendVerification(context.TODO(), req)) +const getUpdatesResp = `{ + "ok": true, + "result": [ + { + "update_id": 998, + "message": { + "chat": { + "type": "group" + } + } + }, + { + "update_id": 999, + "message": { + "text": "not starting with /start", + "chat": { + "type": "private" + } + } + }, + { + "update_id": 1000, + "message": { + "message_id": 4, + "from": { + "id": 313131313, + "is_bot": false, + "first_name": "Joe", + "username": "joe123", + "language_code": "en" + }, + "chat": { + "id": 313131313, + "first_name": "Joe", + "username": "joe123", + "type": "private" + }, + "date": 1601665548, + "text": "/start token", + "entities": [ + { + "offset": 0, + "length": 6, + "type": "bot_command" + } + ] + } + } + ] +}` - // VerificationRequest with canceled context - ctx, cancel := context.WithCancel(context.TODO()) - cancel() - assert.EqualError(t, tb.SendVerification(ctx, req), "sending message to \"test_username\" aborted due to canceled context") - - // test buildVerificationMessage separately for message text - res, err := tb.buildVerificationMessage(req.User, req.Token, req.SiteID) +func TestTelegram_GetUpdatesFlow(t *testing.T) { + first := true + ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.String(), "sendMessage") { + // respond normally to processUpdates attempt to send message back to user + _, _ = w.Write([]byte("{}")) + return + } + // responses to get updates calls to API + if first { + assert.Equal(t, "", r.URL.Query().Get("offset")) + first = false + } else { + assert.Equal(t, "1001", r.URL.Query().Get("offset")) + } + _, _ = w.Write([]byte(getUpdatesResp)) + }) + defer ts.Close() + tb, err := NewTelegram(TelegramParams{ + AdminChannelID: "remark_test", + Token: "xxxsupersecretxxx", + UserNotifications: true, + apiPrefix: ts.URL + "/", + }) assert.NoError(t, err) - assert.Contains(t, string(res), `Confirmation for \u003ci\u003etest_username\u003c/i\u003e on site remark`) - assert.Contains(t, string(res), `secret_`) + + // send request with no offset + upd, err := tb.getUpdates(context.Background()) + assert.NoError(t, err) + + assert.Len(t, upd.Result, 3) + assert.Equal(t, 1001, tb.updateOffset) + assert.Equal(t, "/start token", upd.Result[len(upd.Result)-1].Message.Text) + + tb.AddToken("token", "user", "site", time.Now().Add(time.Minute)) + _, _, err = tb.CheckToken("token", "user") + assert.Error(t, err) + tb.processUpdates(context.Background(), upd) + tgID, site, err := tb.CheckToken("token", "user") + assert.NoError(t, err) + assert.Equal(t, "313131313", tgID) + assert.Equal(t, "site", site) + + // send request with offset + _, err = tb.getUpdates(context.Background()) + assert.NoError(t, err) +} + +func TestTelegram_ProcessUpdateFlow(t *testing.T) { + ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) { + // respond normally to processUpdates attempt to send message back to user + _, _ = w.Write([]byte("{}")) + }) + defer ts.Close() + tb, err := NewTelegram(TelegramParams{ + AdminChannelID: "remark_test", + Token: "xxxsupersecretxxx", + UserNotifications: true, + apiPrefix: ts.URL + "/", + }) + assert.NoError(t, err) + + tb.AddToken("token", "user", "site", time.Now().Add(time.Minute)) + tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute)) + assert.Len(t, tb.requests.data, 2) + _, _, err = tb.CheckToken("token", "user") + assert.Error(t, err) + assert.NoError(t, tb.ProcessUpdate(context.Background(), getUpdatesResp)) + assert.Len(t, tb.requests.data, 1, "expired token was cleaned up") + tgID, site, err := tb.CheckToken("token", "user") + assert.NoError(t, err) + assert.Len(t, tb.requests.data, 0, "token is deleted after successful check") + assert.Equal(t, "313131313", tgID) + assert.Equal(t, "site", site) + + tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute)) + assert.Len(t, tb.requests.data, 1) + assert.EqualError(t, tb.ProcessUpdate(context.Background(), ""), "failed to decode provided telegram update: unexpected end of JSON input") + assert.Len(t, tb.requests.data, 0, "expired token should be cleaned up despite the error") +} + +const sendMessageResp = `{ + "ok": true, + "result": { + "message_id": 100, + "from": { + "id": 666666666, + "is_bot": true, + "first_name": "Test auth bot", + "username": "TestAuthBot" + }, + "chat": { + "id": 313131313, + "first_name": "Joe", + "username": "joe123", + "type": "private" + }, + "date": 1602430546, + "text": "123" + } +}` + +func TestTelegram_SendText(t *testing.T) { + ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "123", r.URL.Query().Get("chat_id")) + assert.Equal(t, "hello there", r.URL.Query().Get("text")) + _, _ = w.Write([]byte(sendMessageResp)) + }) + defer ts.Close() + tb, err := NewTelegram(TelegramParams{ + AdminChannelID: "remark_test", + Token: "xxxsupersecretxxx", + UserNotifications: true, + apiPrefix: ts.URL + "/", + }) + assert.NoError(t, err) + + err = tb.sendText(context.Background(), 123, "hello there") + assert.NoError(t, err) +} + +const errorResp = `{"ok":false,"error_code":400,"description":"Very bad request"}` + +func TestTelegram_Error(t *testing.T) { + ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(errorResp)) + }) + defer ts.Close() + tb, err := NewTelegram(TelegramParams{ + AdminChannelID: "remark_test", + Token: "xxxsupersecretxxx", + UserNotifications: true, + apiPrefix: ts.URL + "/", + }) + assert.NoError(t, err) + + _, err = tb.getUpdates(context.Background()) + assert.EqualError(t, err, "failed to fetch updates: unexpected telegram API status code 400, error: \"Very bad request\"") +} + +func TestTelegram_TokenVerification(t *testing.T) { + ts := mockTelegramServer(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.String(), "sendMessage") { + // respond normally to processUpdates attempt to send message back to user + _, _ = w.Write([]byte("{}")) + return + } + // responses to get updates calls to API + _, _ = w.Write([]byte(getUpdatesResp)) + }) + defer ts.Close() + + tb, err := NewTelegram(TelegramParams{ + AdminChannelID: "remark_test", + Token: "good-token", + apiPrefix: ts.URL + "/", + }) + assert.NoError(t, err) + assert.NotNil(t, tb) + tb.AddToken("token", "user", "site", time.Now().Add(time.Minute)) + assert.Len(t, tb.requests.data, 1) + + // wrong token + tgID, site, err := tb.CheckToken("unknown token", "user") + assert.Empty(t, tgID) + assert.Empty(t, site) + assert.EqualError(t, err, "request is not found") + + // right token and user, not verified yet + tgID, site, err = tb.CheckToken("token", "user") + assert.Empty(t, tgID) + assert.Empty(t, site) + assert.EqualError(t, err, "request is not verified yet") + + // confirm request + authRequest, ok := tb.requests.data["token"] + assert.True(t, ok) + authRequest.confirmed = true + authRequest.telegramID = "telegramID" + tb.requests.data["token"] = authRequest + + // wrong user + tgID, site, err = tb.CheckToken("token", "wrong user") + assert.Empty(t, tgID) + assert.Empty(t, site) + assert.EqualError(t, err, "user does not match original requester") + + // successful check + tgID, site, err = tb.CheckToken("token", "user") + assert.NoError(t, err) + assert.Equal(t, "telegramID", tgID) + assert.Equal(t, "site", site) + + // expired token + tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute)) + tgID, site, err = tb.CheckToken("expired token", "user") + assert.Empty(t, tgID) + assert.Empty(t, site) + assert.EqualError(t, err, "request expired") + assert.Len(t, tb.requests.data, 0) + + // expired token, cleaned up by the cleanup + tb.apiPollInterval = time.Millisecond * 15 + tb.expiredCleanupInterval = time.Millisecond * 10 + ctx, cancel := context.WithCancel(context.Background()) + go tb.Run(ctx) + assert.Eventually(t, func() bool { + return tb.ProcessUpdate(ctx, "").Error() == "Run goroutine should not be used with ProcessUpdate" + }, time.Millisecond*100, time.Millisecond*10, "ProcessUpdate should not work same time as Run") + tb.AddToken("expired token", "user", "site", time.Now().Add(-time.Minute)) + tb.requests.RLock() + assert.Len(t, tb.requests.data, 1) + tb.requests.RUnlock() + time.Sleep(tb.expiredCleanupInterval * 2) + tb.requests.RLock() + assert.Len(t, tb.requests.data, 0) + tb.requests.RUnlock() + cancel() + // give enough time for Run() to finish + time.Sleep(tb.expiredCleanupInterval) } const getMeResp = `{"ok": true, @@ -211,7 +466,16 @@ const getMeResp = `{"ok": true, "username": "remark42_test_bot" }}` -func mockTelegramServer(_ http.HandlerFunc) *httptest.Server { +func mockTelegramServer(h http.HandlerFunc) *httptest.Server { + if h != nil { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.String(), "getMe") { + _, _ = w.Write([]byte(getMeResp)) + return + } + h(w, r) + })) + } router := chi.NewRouter() router.Get("/good-token/getMe", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(getMeResp)) diff --git a/backend/app/providers/telegram.go b/backend/app/providers/telegram.go new file mode 100644 index 00000000..691e77d1 --- /dev/null +++ b/backend/app/providers/telegram.go @@ -0,0 +1,72 @@ +package providers + +// Both Telegram auth and notifications need to receive messages received by Telegram bot in the loop, +// and below is the implementation of such loop which dispatched received events to both receivers, +// so that they could work at the same time. + +import ( + "context" + "encoding/json" + "fmt" + "time" + + log "github.com/go-pkgz/lgr" + + "github.com/umputun/remark42/backend/app/notify" +) + +type tgRequester interface { + Request(ctx context.Context, method string, b []byte, data interface{}) error +} + +// TGUpdatesReceiver used to dispatch telegram updates to multiple receivers +type TGUpdatesReceiver interface { + fmt.Stringer + ProcessUpdate(ctx context.Context, textUpdate string) error +} + +// DispatchTelegramUpdates dispatches telegram updates to provided list of receivers +// Blocks caller +func DispatchTelegramUpdates(ctx context.Context, requester tgRequester, receivers []TGUpdatesReceiver, period time.Duration) { + // Identifier of the first update to be requested. + // Should be equal to LastSeenUpdateID + 1 + // See https://core.telegram.org/bots/api#getupdates + var updateOffset int + + processUpdatedTicker := time.NewTicker(period) + for { + select { + case <-ctx.Done(): + processUpdatedTicker.Stop() + return + case <-processUpdatedTicker.C: + url := `getUpdates?allowed_updates=["message"]` + if updateOffset != 0 { + url += fmt.Sprintf("&offset=%d", updateOffset) + } + + var update notify.TelegramUpdate + + err := requester.Request(ctx, url, nil, &update) + if err != nil { + log.Printf("[WARN] failed to fetch updates: %v", err) + continue + } + + for _, u := range update.Result { + if u.UpdateID >= updateOffset { + updateOffset = u.UpdateID + 1 + } + } + + if raw, err := json.Marshal(update); err == nil { + for _, r := range receivers { + e := r.ProcessUpdate(ctx, string(raw)) + if e != nil { + log.Printf("[ERROR] failure from destination %s on processing telegram update %v", r, e) + } + } + } + } + } +} diff --git a/backend/app/providers/telegram_test.go b/backend/app/providers/telegram_test.go new file mode 100644 index 00000000..5f479382 --- /dev/null +++ b/backend/app/providers/telegram_test.go @@ -0,0 +1,72 @@ +package providers + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/umputun/remark42/backend/app/notify" +) + +func TestDispatchTelegramUpdates(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + poolPeriod := time.Millisecond * 100 + go DispatchTelegramUpdates(ctx, &mockTGRequester{t: t}, []TGUpdatesReceiver{&mockTGUpdatesReceiver{t: t}}, poolPeriod) + time.Sleep(poolPeriod * 3) + cancel() + time.Sleep(poolPeriod) +} + +const getUpdatesResp = `{ + "ok": true, + "result": [ + { + "update_id": 998, + "message": { + "chat": { + "type": "group" + } + } + } +] +}` + +type mockTGRequester struct { + hit int + t *testing.T +} + +func (m *mockTGRequester) Request(_ context.Context, _ string, _ []byte, data interface{}) error { + if m.hit < 2 { + m.hit++ + assert.NoError(m.t, json.Unmarshal([]byte(getUpdatesResp), data)) + return nil + } + return errors.New("test error") +} + +type mockTGUpdatesReceiver struct { + t *testing.T + hit int +} + +func (m *mockTGUpdatesReceiver) String() string { + return "mock updater" +} + +func (m *mockTGUpdatesReceiver) ProcessUpdate(_ context.Context, textUpdate string) error { + var result notify.TelegramUpdate + err := json.Unmarshal([]byte(textUpdate), &result) + assert.NoError(m.t, err) + if m.hit < 2 { + assert.NotNil(m.t, result.Result) + m.hit++ + return nil + } + assert.Nil(m.t, result.Result) + return errors.New("test error") +} diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index 33b249fe..d96fd54a 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -44,6 +44,7 @@ type Rest struct { CommentFormatter *store.CommentFormatter Migrator *Migrator NotifyService *notify.Service + TelegramService telegramService ImageService *image.Service AnonVote bool @@ -324,8 +325,7 @@ func (s *Rest) routes() chi.Router { rauth.With(rejectAnonUser).Post("/email/subscribe", s.privRest.sendEmailConfirmationCtrl) rauth.With(rejectAnonUser).Post("/email/confirm", s.privRest.setConfirmedEmailCtrl) rauth.With(rejectAnonUser).Delete("/email", s.privRest.deleteEmailCtrl) - rauth.With(rejectAnonUser).Post("/telegram/subscribe", s.privRest.sendTelegramConfirmationCtrl) - rauth.With(rejectAnonUser).Post("/telegram/confirm", s.privRest.setConfirmedTelegramCtrl) + rauth.With(rejectAnonUser).Get("/telegram/subscribe", s.privRest.telegramSubscribeCtrl) rauth.With(rejectAnonUser).Delete("/telegram", s.privRest.deleteTelegramCtrl) }) @@ -374,6 +374,7 @@ func (s *Rest) controllerGroups() (public, private, admin, rss) { readOnlyAge: s.ReadOnlyAge, authenticator: s.Authenticator, notifyService: s.NotifyService, + telegramService: s.TelegramService, remarkURL: s.RemarkURL, anonVote: s.AnonVote, templates: templates.NewFS(), diff --git a/backend/app/rest/api/rest_private.go b/backend/app/rest/api/rest_private.go index 2c7e67ec..467fbbf8 100644 --- a/backend/app/rest/api/rest_private.go +++ b/backend/app/rest/api/rest_private.go @@ -3,8 +3,9 @@ package api import ( "bytes" "compress/gzip" + "crypto/rand" + "crypto/sha1" //nolint:gosec //not used for security "encoding/json" - "errors" "fmt" "html/template" "io" @@ -21,6 +22,7 @@ import ( R "github.com/go-pkgz/rest" "github.com/golang-jwt/jwt" "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" "github.com/umputun/remark42/backend/app/notify" "github.com/umputun/remark42/backend/app/rest" @@ -39,11 +41,19 @@ type private struct { imageService *image.Service notifyService *notify.Service authenticator *auth.Service + telegramService telegramService remarkURL string anonVote bool templates templates.FileReader } +// telegramService is a subset of Telegram service used for setting up user telegram notifications +type telegramService interface { + AddToken(token, user, site string, expires time.Time) + CheckToken(token, userID string) (telegram, site string, err error) + GetBotUsername() string +} + type privStore interface { Create(comment store.Comment) (commentID string, err error) EditComment(locator store.Locator, commentID string, req service.EditRequest) (comment store.Comment, err error) @@ -349,55 +359,64 @@ func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Reque render.JSON(w, r, R.JSON{"user": user, "address": address}) } -// sendTelegramConfirmationCtrl gets address and siteID from query, makes confirmation token and sends it to user. -// GET /telegram/subscribe?site=siteID&address=@someone -//nolint:dupl // too hard to deduplicate that logic, as then it's tricky to use SendErrorJSON -func (s *private) sendTelegramConfirmationCtrl(w http.ResponseWriter, r *http.Request) { +// telegramSubscribeCtrl generates and verifies telegram notification request +// GET /telegram/subscribe?site=siteID<&tkn=token> +func (s *private) telegramSubscribeCtrl(w http.ResponseWriter, r *http.Request) { user := rest.MustGetUserInfo(r) - address := r.URL.Query().Get("address") - siteID := r.URL.Query().Get("site") - if address == "" { - rest.SendErrorJSON(w, r, http.StatusBadRequest, - errors.New("missing parameter"), "address parameter is required", rest.ErrInternal) + + if s.telegramService == nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, + errors.New("not enabled"), "telegram notifications are not enabled", rest.ErrActionRejected) return } - existingAddress, err := s.dataService.GetUserTelegram(siteID, user.ID) + + queryToken := r.URL.Query().Get("tkn") + if queryToken == "" { + // GET /telegram/subscribe?site=siteID (No token supplied) + siteID := r.URL.Query().Get("site") + if siteID == "" { + rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New("missing parameter"), "site parameter is required", rest.ErrInternal) + return + } + // we don't care as much if we can't retrieve the current value of that field for the user, so ignore the error + if existingAddress, _ := s.dataService.GetUserTelegram(siteID, user.ID); existingAddress != "" { + rest.SendErrorJSON(w, r, http.StatusConflict, + errors.New("already subscribed"), "telegram subscription is already set for this user, delete if first to re-subscribe", rest.ErrActionRejected) + return + } + // Generate and send token + tkn, err := randToken() + if err != nil { + rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to generate verification token", rest.ErrInternal) + return + } + expires := time.Now().Add(10 * time.Minute) + + s.telegramService.AddToken(tkn, user.ID, siteID, expires) + + render.JSON(w, r, R.JSON{"token": tkn, "bot": s.telegramService.GetBotUsername()}) + + return + } + + // GET /telegram/subscribe?tkn=token (verify token) + var address, siteID string + address, siteID, err := s.telegramService.CheckToken(queryToken, user.ID) if err != nil { - // we don't care as much if we can't retrieve the current value of that field for the user, - // as it's only used to check if we're trying to set to the same value it's already set to - log.Printf("[WARN] can't read telegram for %s, %v", user.ID, err) - } - if address == existingAddress { - rest.SendErrorJSON(w, r, http.StatusConflict, - errors.New("already verified"), "telegram address is already verified for this user", rest.ErrInternal) + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't set telegram for user", rest.ErrInternal) return } - claims := token.Claims{ - Handshake: &token.Handshake{ID: user.ID + "::" + address}, - StandardClaims: jwt.StandardClaims{ - Audience: r.URL.Query().Get("site"), - ExpiresAt: time.Now().Add(30 * time.Minute).Unix(), - NotBefore: time.Now().Add(-1 * time.Minute).Unix(), - Issuer: "remark42", - }, - } - tkn, err := s.authenticator.TokenService().Token(claims) + log.Printf("[DEBUG] set telegram notifications for user %s", user.ID) + + val, err := s.dataService.SetUserTelegram(siteID, user.ID, address) if err != nil { - rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to make verification token", rest.ErrInternal) + code := parseError(err, rest.ErrInternal) + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set telegram for user", code) return } - s.notifyService.SubmitVerification( - notify.VerificationRequest{ - SiteID: siteID, - User: user.Name, - Telegram: address, - Token: tkn, - }, - ) - - render.JSON(w, r, R.JSON{"user": user, "address": address}) + render.JSON(w, r, R.JSON{"updated": true, "address": val}) } // setConfirmedEmailCtrl uses provided token parameter (generated by sendEmailConfirmationCtrl) to set email and add it to user token @@ -453,47 +472,6 @@ func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request) render.JSON(w, r, R.JSON{"updated": true, "address": val}) } -// setConfirmedTelegramCtrl uses provided token parameter (generated by sendTelegramConfirmationCtrl) to set telegram and add it to user token -// PUT /telegram/confirm?site=siteID&tkn=jwt -func (s *private) setConfirmedTelegramCtrl(w http.ResponseWriter, r *http.Request) { - tkn := r.URL.Query().Get("tkn") - if tkn == "" { - rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New("missing parameter"), "token parameter is required", rest.ErrInternal) - return - } - user := rest.MustGetUserInfo(r) - siteID := r.URL.Query().Get("site") - - confClaims, err := s.authenticator.TokenService().Parse(tkn) - if err != nil { - rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal) - return - } - - if s.authenticator.TokenService().IsExpired(confClaims) { - rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("expired"), "failed to verify confirmation token", rest.ErrInternal) - return - } - - // Handshake.ID is user.ID + "::" + address - elems := strings.Split(confClaims.Handshake.ID, "::") - if len(elems) != 2 || elems[0] != user.ID { - rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New(confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal) - return - } - address := elems[1] - - log.Printf("[DEBUG] set telegram for user %s", user.ID) - - val, err := s.dataService.SetUserTelegram(siteID, user.ID, address) - if err != nil { - code := parseError(err, rest.ErrInternal) - rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set telegram for user", code) - return - } - render.JSON(w, r, R.JSON{"updated": true, "address": val}) -} - // POST/GET /email/unsubscribe.html?site=siteID&tkn=jwt - unsubscribe the user in token from email notifications func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) { tkn := r.URL.Query().Get("tkn") @@ -748,3 +726,15 @@ func (s *private) isReadOnly(locator store.Locator) bool { } return s.dataService.IsReadOnly(locator) // ro manually } + +func randToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", errors.Wrap(err, "can't get random") + } + s := sha1.New() //nolint:gosec // not used for security + if _, err := s.Write(b); err != nil { + return "", errors.Wrap(err, "can't write randoms to sha1") + } + return fmt.Sprintf("%x", s.Sum(nil)), nil +} diff --git a/backend/app/rest/api/rest_private_test.go b/backend/app/rest/api/rest_private_test.go index 64605ddb..88156ebf 100644 --- a/backend/app/rest/api/rest_private_test.go +++ b/backend/app/rest/api/rest_private_test.go @@ -6,6 +6,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "io/ioutil" @@ -578,6 +579,7 @@ func TestRest_EmailAndTelegram(t *testing.T) { defer teardown() srv.privRest.templates = &MockFS{} + srv.privRest.telegramService = &mockTelegram{site: "remark42"} // issue good token claims := token.Claims{ @@ -620,14 +622,13 @@ func TestRest_EmailAndTelegram(t *testing.T) { {description: "issue delete request without auth", url: "/api/v1/telegram", method: http.MethodDelete, responseCode: http.StatusUnauthorized, noAuth: true}, {description: "issue delete request without site_id", url: "/api/v1/telegram", method: http.MethodDelete, responseCode: http.StatusBadRequest}, {description: "delete non-existent user telegram", url: "/api/v1/telegram?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK}, - {description: "set user telegram, token not set", url: "/api/v1/telegram/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest}, - {description: "send confirmation without address", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest}, - {description: "send confirmation", url: "/api/v1/telegram/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK}, - {description: "set user telegram, token is good", url: fmt.Sprintf("/api/v1/telegram/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK}, - {description: "send confirmation with same address", url: "/api/v1/telegram/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusConflict}, + {description: "send telegram confirmation, no siteID", url: "/api/v1/telegram/subscribe", method: http.MethodGet, responseCode: http.StatusBadRequest}, + {description: "send telegram confirmation", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusOK}, + {description: "set user telegram, token is good", url: "/api/v1/telegram/subscribe?site=remark42&tkn=good_token", method: http.MethodGet, responseCode: http.StatusOK}, + {description: "send confirmation with same address", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusConflict}, {description: "delete user telegram", url: "/api/v1/telegram?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK}, - {description: "send another confirmation", url: "/api/v1/telegram/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK}, - {description: "set user telegram, token is good", url: fmt.Sprintf("/api/v1/telegram/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK}, + {description: "send another confirmation", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusOK}, + {description: "set user telegram, token is good", url: "/api/v1/telegram/subscribe?site=remark42&tkn=good_token", method: http.MethodGet, responseCode: http.StatusOK}, } client := http.Client{} for _, x := range testData { @@ -852,8 +853,8 @@ func TestRest_TelegramNotification(t *testing.T) { require.Equal(t, 2, len(mockDestination.Get())) assert.Empty(t, mockDestination.Get()[1].Telegrams) - // send confirmation token for telegram - req, err = http.NewRequest(http.MethodPost, ts.URL+"/api/v1/telegram/subscribe?site=remark42&address=good_telegram", nil) + // subscribe to telegram while the telegram destination is absent + req, err = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/telegram/subscribe?site=remark42", nil) require.NoError(t, err) req.Header.Add("X-JWT", devToken) resp, err = client.Do(req) @@ -861,15 +862,13 @@ func TestRest_TelegramNotification(t *testing.T) { body, err = ioutil.ReadAll(resp.Body) require.NoError(t, err) require.NoError(t, resp.Body.Close()) - require.Equal(t, http.StatusOK, resp.StatusCode, string(body)) - // wait for mock notification Submit to kick off - time.Sleep(time.Millisecond * 30) - require.Equal(t, 1, len(mockDestination.GetVerify())) - assert.Equal(t, "good_telegram", mockDestination.GetVerify()[0].Telegram) - verificationToken := mockDestination.GetVerify()[0].Token + require.Equal(t, http.StatusInternalServerError, resp.StatusCode, string(body)) + assert.Equal(t, `{"code":17,"details":"telegram notifications are not enabled","error":"not enabled"}`+"\n", string(body)) - // verify telegram - req, err = http.NewRequest(http.MethodPost, ts.URL+fmt.Sprintf("/api/v1/telegram/confirm?site=remark42&tkn=%s", verificationToken), nil) + mockTlgrm := &mockTelegram{notVerified: true, site: "unknown_site"} + srv.privRest.telegramService = mockTlgrm + // send confirmation token for telegram + req, err = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/telegram/subscribe?site=remark42", nil) require.NoError(t, err) req.Header.Add("X-JWT", devToken) resp, err = client.Do(req) @@ -878,6 +877,58 @@ func TestRest_TelegramNotification(t *testing.T) { require.NoError(t, err) require.NoError(t, resp.Body.Close()) require.Equal(t, http.StatusOK, resp.StatusCode, string(body)) + var subscribeRequest struct { + Bot string `json:"bot"` + Token string `json:"token"` + } + err = json.Unmarshal(body, &subscribeRequest) + assert.NoError(t, err) + assert.Equal(t, "botUsername", subscribeRequest.Bot) + + // verify telegram, unsuccessfully because of not verified + req, err = http.NewRequest(http.MethodGet, ts.URL+fmt.Sprintf("/api/v1/telegram/subscribe?site=remark42&tkn=%s", subscribeRequest.Token), nil) + require.NoError(t, err) + req.Header.Add("X-JWT", devToken) + resp, err = client.Do(req) + require.NoError(t, err) + body, err = ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusInternalServerError, resp.StatusCode, string(body)) + require.Equal(t, `{"code":0,"details":"can't set telegram for user","error":"not verified"}`+"\n", string(body)) + + mockTlgrm.notVerified = false + + // verify telegram, unsuccessfully because of unknown site + req, err = http.NewRequest(http.MethodGet, ts.URL+fmt.Sprintf("/api/v1/telegram/subscribe?site=remark42&tkn=%s", subscribeRequest.Token), nil) + require.NoError(t, err) + req.Header.Add("X-JWT", devToken) + resp, err = client.Do(req) + require.NoError(t, err) + body, err = ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, string(body)) + require.Equal(t, `{"code":0,"details":"can't set telegram for user","error":"site \"unknown_site\" not found"}`+"\n", string(body)) + + mockTlgrm.site = "remark42" + // verify telegram, successfully + req, err = http.NewRequest(http.MethodGet, ts.URL+fmt.Sprintf("/api/v1/telegram/subscribe?site=remark42&tkn=%s", subscribeRequest.Token), nil) + require.NoError(t, err) + req.Header.Add("X-JWT", devToken) + resp, err = client.Do(req) + require.NoError(t, err) + body, err = ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode, string(body)) + var subscribeResult struct { + Address string `json:"address"` + Updated bool `json:"updated"` + } + err = json.Unmarshal(body, &subscribeResult) + assert.NoError(t, err) + assert.True(t, subscribeResult.Updated) // get user information to verify the subscription req, err = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/user?site=remark42", nil) @@ -1224,3 +1275,21 @@ func TestRest_CreateWithPictures(t *testing.T) { assert.NoError(t, err, "picture %d moved from staging and available in permanent location", i) } } + +type mockTelegram struct { + notVerified bool + site string +} + +func (m *mockTelegram) AddToken(string, string, string, time.Time) {} + +func (m *mockTelegram) GetBotUsername() string { + return "botUsername" +} + +func (m *mockTelegram) CheckToken(string, string) (telegram, site string, err error) { + if m.notVerified { + return "", "", errors.New("not verified") + } + return "good_telegram", m.site, nil +} diff --git a/backend/remark.rest b/backend/remark.rest index 8220480f..23de4a7e 100644 --- a/backend/remark.rest +++ b/backend/remark.rest @@ -134,12 +134,13 @@ X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYw DELETE {{host}}/api/v1/email?site={{site}} X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4 -### send confirmation token for current user to specified telegram. auth token for dev user for secret=12345. -POST {{host}}/api/v1/telegram/subscribe?site={{site}}&address={{telegram}} +### get information for sending confirmation token for current user. auth token for dev user for secret=12345. +### After you'll get the response, construct link with it and open it: https://t.me/?start= +GET {{host}}/api/v1/telegram/subscribe?site={{site}} X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4 -### add telegram for notifications for current user via token from telegram. auth token for dev user for secret=12345. -POST {{host}}/api/v1/telegram/confirm?site={{site}}&tkn={{token}} +### verify telegram notifications for current user via token obtained in the previous step, after talking to bot. auth token for dev user for secret=12345. +GET {{host}}/api/v1/telegram/subscribe?tkn={{token}} X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4 ### delete current user telegram. auth token for dev user for secret=12345.