From 200733ed03411e63dfaa183d74d64259fcb39893 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Mon, 14 Jun 2021 02:09:42 +0200 Subject: [PATCH] add user telegram notifications --- README.md | 4 +- backend/app/cmd/server.go | 102 ++++++++++++--------- backend/app/notify/notify.go | 9 +- backend/app/notify/telegram.go | 77 ++++++++++++++-- backend/app/notify/telegram_test.go | 38 ++++++-- backend/app/rest/api/rest.go | 86 ++++++++++-------- backend/app/rest/api/rest_private.go | 106 ++++++++++++++++++++++ backend/app/rest/api/rest_private_test.go | 67 ++++++++++++-- backend/remark.rest | 12 +++ frontend/app/__stubs__/static-config.ts | 1 + frontend/app/common/static-store.ts | 1 + frontend/app/common/types.ts | 1 + 12 files changed, 392 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index 0f86ce03..593bea15 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engi * Extractor for recent comments, cross-post * RSS for all comments and each post * Telegram, Slack and email notifications for Admins (get notified for each new comment) -* Email notifications for users (get notified when someone responds to your comment) +* Email and Telegram notifications for users (get notified when someone responds to your comment) * Export data to json with automatic backups * No external databases, everything embedded in a single data file * Fully dockerized and can be deployed in a single command @@ -156,7 +156,7 @@ _this is the recommended way to run remark42_ | auth.email.subj | AUTH_EMAIL_SUBJ | `remark42 confirmation` | email subject | | auth.email.content-type | AUTH_EMAIL_CONTENT_TYPE | `text/html` | email content type | | auth.email.template | AUTH_EMAIL_TEMPLATE | none (predefined) | custom email message template file | -| notify.users | NOTIFY_USERS | none | type of user notifications (email) | +| notify.users | NOTIFY_USERS | none | type of user notifications (telegram, email) | | notify.admins | NOTIFY_ADMINS | none | type of admin notifications (telegram, slack and/or email) | | notify.queue | NOTIFY_QUEUE | `100` | size of notification queue | | notify.telegram.chan | NOTIFY_TELEGRAM_CHAN | | telegram channel | diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index 9afd2ed2..c805f17b 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -211,7 +211,7 @@ type SMTPGroup struct { // NotifyGroup defines options for notification type NotifyGroup struct { Type []string `long:"type" env:"TYPE" description:"[deprecated, use user and admin types instead] types of notifications" choice:"none" choice:"telegram" choice:"email" choice:"slack" default:"none" env-delim:","` //nolint - Users []string `long:"users" env:"USERS" description:"types of user notifications" choice:"none" choice:"email" default:"none" env-delim:","` //nolint + Users []string `long:"users" env:"USERS" description:"types of user notifications" choice:"none" choice:"email" choice:"telegram" default:"none" env-delim:","` //nolint Admins []string `long:"admins" env:"ADMINS" description:"types of admin notifications" choice:"none" choice:"telegram" choice:"email" choice:"slack" default:"none" env-delim:","` //nolint QueueSize int `long:"queue" env:"QUEUE" description:"size of notification queue" default:"100"` Telegram struct { @@ -462,16 +462,22 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { } var emailNotifications bool - notifyService, err := s.makeNotify(dataService, authenticator) + var telegramNotificationsBotUsername string + notifyService, telegramBotUsername, err := s.makeNotify(dataService, authenticator) if contains("email", s.Notify.Users) { emailNotifications = true } + if contains("telegram", s.Notify.Users) { + telegramNotificationsBotUsername = telegramBotUsername + } + if err != nil { log.Printf("[WARN] failed to make notify service, %s", err) - notifyService = notify.NopService // disable notifier - emailNotifications = false // email notifications are not available in this case + notifyService = notify.NopService // disable notifier + emailNotifications = false // email notifications are not available in this case + telegramNotificationsBotUsername = "" // telegram notifications are not available in this case either } imgProxy := &proxy.Image{ @@ -494,28 +500,29 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { } srv := &api.Rest{ - Version: s.Revision, - DataService: dataService, - WebRoot: s.WebRoot, - RemarkURL: s.RemarkURL, - ImageProxy: imgProxy, - CommentFormatter: commentFormatter, - Migrator: migr, - ReadOnlyAge: s.ReadOnlyAge, - SharedSecret: s.SharedSecret, - Authenticator: authenticator, - Cache: loadingCache, - NotifyService: notifyService, - SSLConfig: sslConfig, - UpdateLimiter: s.UpdateLimit, - ImageService: imageService, - EmailNotifications: emailNotifications, - EmojiEnabled: s.EnableEmoji, - AnonVote: s.AnonymousVote && s.RestrictVoteIP, - SimpleView: s.SimpleView, - ProxyCORS: s.ProxyCORS, - AllowedAncestors: s.AllowedHosts, - SendJWTHeader: s.Auth.SendJWTHeader, + Version: s.Revision, + DataService: dataService, + WebRoot: s.WebRoot, + RemarkURL: s.RemarkURL, + ImageProxy: imgProxy, + CommentFormatter: commentFormatter, + Migrator: migr, + ReadOnlyAge: s.ReadOnlyAge, + SharedSecret: s.SharedSecret, + Authenticator: authenticator, + Cache: loadingCache, + NotifyService: notifyService, + SSLConfig: sslConfig, + UpdateLimiter: s.UpdateLimit, + ImageService: imageService, + EmailNotifications: emailNotifications, + TelegramBotUsername: telegramNotificationsBotUsername, + EmojiEnabled: s.EnableEmoji, + AnonVote: s.AnonymousVote && s.RestrictVoteIP, + SimpleView: s.SimpleView, + ProxyCORS: s.ProxyCORS, + AllowedAncestors: s.AllowedHosts, + SendJWTHeader: s.Auth.SendJWTHeader, } srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = s.LowScore, s.CriticalScore @@ -868,46 +875,57 @@ func (s *ServerCommand) loadEmailTemplate() (string, error) { return string(file), nil } -func (s *ServerCommand) makeNotify(dataStore *service.DataStore, authenticator *auth.Service) (*notify.Service, error) { +// aside from notify.Service and error, returns telegram bot name which will be passed to the frontend +func (s *ServerCommand) makeNotify(dataStore *service.DataStore, authenticator *auth.Service) (*notify.Service, string, error) { var notifyService *notify.Service var destinations []notify.Destination + var telegramBotUsername string for _, t := range s.Notify.Admins { switch t { case "slack": slack, err := notify.NewSlack(s.Notify.Slack.Token, s.Notify.Slack.Channel) if err != nil { - return nil, errors.Wrap(err, "failed to create slack notification destination") + return nil, "", errors.Wrap(err, "failed to create slack notification destination") } destinations = append(destinations, slack) case "telegram": - telegramParams := notify.TelegramParams{ - AdminChannelID: s.Notify.Telegram.Channel, - Token: s.Telegram.Token, - Timeout: s.Telegram.Timeout, - } - tg, err := notify.NewTelegram(telegramParams) - if err != nil { - return nil, errors.Wrap(err, "failed to create telegram notification destination") - } - destinations = append(destinations, tg) case "email": case "none": notifyService = notify.NopService default: - return nil, errors.Errorf("unsupported admin notification type %q", s.Notify.Type) + return nil, "", errors.Errorf("unsupported admin notification type %q", s.Notify.Type) } } for _, t := range s.Notify.Users { switch t { + case "telegram": case "email": case "none": notifyService = notify.NopService default: - return nil, errors.Errorf("unsupported user notification type %q", s.Notify.Type) + return nil, "", errors.Errorf("unsupported user notification type %q", s.Notify.Type) } } + if contains("telegram", s.Notify.Users) || contains("telegram", s.Notify.Admins) { + if contains("telegram", s.Notify.Admins) && s.Notify.Telegram.Channel == "" { + return nil, "", errors.New("--notify.telegram.channel must be set for admin notifications to work") + } + telegramParams := notify.TelegramParams{ + AdminChannelID: s.Notify.Telegram.Channel, + UserNotifications: contains("telegram", s.Notify.Users), + Token: s.Telegram.Token, + Timeout: s.Telegram.Timeout, + } + tg, err := notify.NewTelegram(telegramParams) + if err != nil { + return nil, "", errors.Wrap(err, "failed to create telegram notification destination") + } + destinations = append(destinations, tg) + telegramBotUsername = tg.BotUsername + } + // with logic below admin notifications enable notifications for users on the backend even if they // are not enabled explicitly, however they won't be visible to the users in the frontend // because api.Rest.EmailNotifications would be set to false. @@ -949,7 +967,7 @@ func (s *ServerCommand) makeNotify(dataStore *service.DataStore, authenticator * } emailService, err := notify.NewEmail(emailParams, smtpParams) if err != nil { - return nil, errors.Wrap(err, "failed to create email notification destination") + return nil, "", errors.Wrap(err, "failed to create email notification destination") } destinations = append(destinations, emailService) } @@ -958,7 +976,7 @@ func (s *ServerCommand) makeNotify(dataStore *service.DataStore, authenticator * log.Printf("[INFO] make notify, for users: %s, for admins: %s", s.Notify.Users, s.Notify.Admins) notifyService = notify.NewService(dataStore, s.Notify.QueueSize, destinations...) } - return notifyService, nil + return notifyService, telegramBotUsername, nil } func (s *ServerCommand) makeSSLConfig() (config api.SSLConfig, err error) { diff --git a/backend/app/notify/notify.go b/backend/app/notify/notify.go index bdcef9d9..dd10c2b5 100644 --- a/backend/app/notify/notify.go +++ b/backend/app/notify/notify.go @@ -51,10 +51,11 @@ type Request struct { // VerificationRequest notification for user type VerificationRequest struct { - SiteID string - User string - Email string // if set, send email only - Token string + SiteID string + User string + Email string // if set, send email only + Telegram string // if set, send telegram only + Token string } const defaultQueueSize = 100 diff --git a/backend/app/notify/telegram.go b/backend/app/notify/telegram.go index 8a2aebcc..a4536cab 100644 --- a/backend/app/notify/telegram.go +++ b/backend/app/notify/telegram.go @@ -11,6 +11,8 @@ import ( "strings" "time" + "github.com/hashicorp/go-multierror" + log "github.com/go-pkgz/lgr" "github.com/go-pkgz/repeater" "github.com/pkg/errors" @@ -18,9 +20,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 + 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 + BotUsername string // filled with bot username after Telegram creation, used in frontend + UserNotifications bool // flag which enables user notifications apiPrefix string // changed only in tests } @@ -93,6 +97,8 @@ func NewTelegram(params TelegramParams) (*Telegram, error) { if !tgResp.OK || !tgResp.Result.IsBot { return errors.Errorf("unexpected telegram response %+v", tgResp) } + + res.BotUsername = tgResp.Result.UserName return nil }) @@ -102,6 +108,7 @@ func NewTelegram(params TelegramParams) (*Telegram, error) { // Send to telegram recipients func (t *Telegram) Send(ctx context.Context, req Request) error { log.Printf("[DEBUG] send telegram notification for comment ID %s", req.Comment.ID) + result := new(multierror.Error) msg, err := buildTelegramMessage(req) if err != nil { @@ -110,10 +117,20 @@ func (t *Telegram) Send(ctx context.Context, req Request) error { if t.AdminChannelID != "" { err := t.sendMessage(ctx, msg, t.AdminChannelID) - return errors.Wrapf(err, - "problem sending admin telegram notification about comment ID %s to %s", req.Comment.ID, t.AdminChannelID) + result = multierror.Append(errors.Wrapf(err, + "problem sending admin telegram notification about comment ID %s to %s", req.Comment.ID, t.AdminChannelID), + ) } - return nil + + if t.UserNotifications { + for _, user := range req.Telegrams { + err := t.sendMessage(ctx, msg, user) + result = multierror.Append(errors.Wrapf(err, + "problem sending user telegram notification about comment ID %s to %q", req.Comment.ID, user), + ) + } + } + return result.ErrorOrNil() } func (t *Telegram) sendMessage(ctx context.Context, b []byte, chatID string) error { @@ -205,9 +222,48 @@ func escapeText(title string) string { return res } -// SendVerification is not implemented for telegram -func (t *Telegram) SendVerification(_ context.Context, _ VerificationRequest) error { - return nil +func escapeCode(text string) string { + escSymbols := []string{"`", `\`} + res := text + for _, esc := range escSymbols { + res = strings.Replace(res, esc, "\\"+esc, -1) + } + return res +} + +// 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) +} + +// buildVerificationMessage generates verification telegram message based on given input +func (t *Telegram) buildVerificationMessage(user, token, site string) ([]byte, error) { + result := fmt.Sprintf("This is 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```", + escapeText(user), escapeText(site), escapeCode(token)) + body := telegramMsg{Text: result, ParseMode: "MarkdownV2"} + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + return b, nil } func (t *Telegram) String() string { @@ -215,5 +271,8 @@ func (t *Telegram) String() string { if t.AdminChannelID != "" { result += " with admin notifications to " + t.AdminChannelID } + if t.UserNotifications { + result += " with user notifications enabled" + } return result } diff --git a/backend/app/notify/telegram_test.go b/backend/app/notify/telegram_test.go index bf7c2ea2..39c81791 100644 --- a/backend/app/notify/telegram_test.go +++ b/backend/app/notify/telegram_test.go @@ -97,6 +97,7 @@ func TestTelegram_Send(t *testing.T) { tb, err := NewTelegram(TelegramParams{ AdminChannelID: "remark_test", Token: "good-token", + UserNotifications: true, apiPrefix: ts.URL + "/", }) assert.NoError(t, err) @@ -106,7 +107,7 @@ func TestTelegram_Send(t *testing.T) { cp := store.Comment{Text: "some parent text"} cp.User.Name = "to" - err = tb.Send(context.TODO(), Request{Comment: c, parent: cp}) + err = tb.Send(context.TODO(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}}) assert.NoError(t, err) c.PostTitle = "test title" err = tb.Send(context.TODO(), Request{Comment: c, parent: cp}) @@ -119,20 +120,21 @@ func TestTelegram_Send(t *testing.T) { assert.NoError(t, err) tb, err = NewTelegram(TelegramParams{ - AdminChannelID: "remark_test", - Token: "non-json-resp", - apiPrefix: ts.URL + "/", + AdminChannelID: "remark_test", + Token: "non-json-resp", + UserNotifications: true, + apiPrefix: ts.URL + "/", }) assert.Error(t, err, "should fail") - err = tb.Send(context.TODO(), Request{Comment: c, parent: cp}) + err = tb.Send(context.TODO(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}}) require.Error(t, err) assert.Contains(t, err.Error(), "unexpected telegram API status code 404", "send on broken tg") - assert.Equal(t, "telegram with admin notifications to remark_test", tb.String()) + assert.Equal(t, "telegram with admin notifications to remark_test with user notifications enabled", tb.String()) // bad API URL tb.apiPrefix = "http://non-existent" - err = tb.Send(context.TODO(), Request{Comment: c, parent: cp}) + err = tb.Send(context.TODO(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}}) assert.Error(t, err) } @@ -148,8 +150,28 @@ func TestTelegram_SendVerification(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, tb) - err = tb.SendVerification(context.TODO(), VerificationRequest{}) + // proper VerificationRequest without telegram + req := VerificationRequest{ + SiteID: "remark", + User: "test_username", + Token: "secret_", + } + assert.NoError(t, tb.SendVerification(context.TODO(), req)) + + // proper VerificationRequest with telegram + req.Telegram = "test" + assert.NoError(t, tb.SendVerification(context.TODO(), req)) + + // 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) assert.NoError(t, err) + assert.Contains(t, string(res), "This is confirmation for test\\\\_username on site remark") + assert.Contains(t, string(res), `secret_`) } func mockTelegramServer() *httptest.Server { diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index bc3734fa..1be3f8e5 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -55,13 +55,14 @@ type Rest struct { Low int Critical int } - UpdateLimiter float64 - EmailNotifications bool - EmojiEnabled bool - SimpleView bool - ProxyCORS bool - SendJWTHeader bool - AllowedAncestors []string // sets Content-Security-Policy "frame-ancestors ..." + UpdateLimiter float64 + EmailNotifications bool + TelegramBotUsername string + EmojiEnabled bool + SimpleView bool + ProxyCORS bool + SendJWTHeader bool + AllowedAncestors []string // sets Content-Security-Policy "frame-ancestors ..." SSLConfig SSLConfig httpsServer *http.Server @@ -322,6 +323,9 @@ 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).Delete("/telegram", s.privRest.deleteTelegramCtrl) }) // protected routes, anonymous rejected @@ -407,40 +411,42 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) { emails, _ := s.DataService.AdminStore.Email(siteID) cnf := struct { - Version string `json:"version"` - EditDuration int `json:"edit_duration"` - AdminEdit bool `json:"admin_edit"` - MaxCommentSize int `json:"max_comment_size"` - Admins []string `json:"admins"` - AdminEmail string `json:"admin_email"` - Auth []string `json:"auth_providers"` - AnonVote bool `json:"anon_vote"` - LowScore int `json:"low_score"` - CriticalScore int `json:"critical_score"` - PositiveScore bool `json:"positive_score"` - ReadOnlyAge int `json:"readonly_age"` - MaxImageSize int `json:"max_image_size"` - EmailNotifications bool `json:"email_notifications"` - EmojiEnabled bool `json:"emoji_enabled"` - SimpleView bool `json:"simple_view"` - SendJWTHeader bool `json:"send_jwt_header"` + Version string `json:"version"` + EditDuration int `json:"edit_duration"` + AdminEdit bool `json:"admin_edit"` + MaxCommentSize int `json:"max_comment_size"` + Admins []string `json:"admins"` + AdminEmail string `json:"admin_email"` + Auth []string `json:"auth_providers"` + AnonVote bool `json:"anon_vote"` + LowScore int `json:"low_score"` + CriticalScore int `json:"critical_score"` + PositiveScore bool `json:"positive_score"` + ReadOnlyAge int `json:"readonly_age"` + MaxImageSize int `json:"max_image_size"` + EmailNotifications bool `json:"email_notifications"` + TelegramBotUsername string `json:"telegram_bot_username"` + EmojiEnabled bool `json:"emoji_enabled"` + SimpleView bool `json:"simple_view"` + SendJWTHeader bool `json:"send_jwt_header"` }{ - Version: s.Version, - EditDuration: int(s.DataService.EditDuration.Seconds()), - AdminEdit: s.DataService.AdminEdits, - MaxCommentSize: s.DataService.MaxCommentSize, - Admins: admins, - AdminEmail: emails, - LowScore: s.ScoreThresholds.Low, - CriticalScore: s.ScoreThresholds.Critical, - PositiveScore: s.DataService.PositiveScore, - ReadOnlyAge: s.ReadOnlyAge, - MaxImageSize: s.ImageService.MaxSize, - EmailNotifications: s.EmailNotifications, - EmojiEnabled: s.EmojiEnabled, - AnonVote: s.AnonVote, - SimpleView: s.SimpleView, - SendJWTHeader: s.SendJWTHeader, + Version: s.Version, + EditDuration: int(s.DataService.EditDuration.Seconds()), + AdminEdit: s.DataService.AdminEdits, + MaxCommentSize: s.DataService.MaxCommentSize, + Admins: admins, + AdminEmail: emails, + LowScore: s.ScoreThresholds.Low, + CriticalScore: s.ScoreThresholds.Critical, + PositiveScore: s.DataService.PositiveScore, + ReadOnlyAge: s.ReadOnlyAge, + MaxImageSize: s.ImageService.MaxSize, + EmailNotifications: s.EmailNotifications, + TelegramBotUsername: s.TelegramBotUsername, + EmojiEnabled: s.EmojiEnabled, + AnonVote: s.AnonVote, + SimpleView: s.SimpleView, + SendJWTHeader: s.SendJWTHeader, } cnf.Auth = []string{} diff --git a/backend/app/rest/api/rest_private.go b/backend/app/rest/api/rest_private.go index b9dd0015..c2fc850b 100644 --- a/backend/app/rest/api/rest_private.go +++ b/backend/app/rest/api/rest_private.go @@ -52,6 +52,8 @@ type privStore interface { User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error) GetUserEmail(siteID string, userID string) (string, error) SetUserEmail(siteID string, userID string, value string) (string, error) + GetUserTelegram(siteID string, userID string) (string, error) + SetUserTelegram(siteID string, userID string, value string) (string, error) DeleteUserDetail(siteID string, userID string, detail engine.UserDetail) error ValidateComment(c *store.Comment) error IsVerified(siteID string, userID string) bool @@ -268,6 +270,7 @@ func (s *private) getEmailCtrl(w http.ResponseWriter, r *http.Request) { // sendEmailConfirmationCtrl gets address and siteID from query, makes confirmation token and sends it to user. // GET /email/subscribe?site=siteID&address=someone@example.com +//nolint:dupl // too hard to deduplicate that logic, as then it's tricky to use SendErrorJSON func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Request) { user := rest.MustGetUserInfo(r) address := r.URL.Query().Get("address") @@ -314,6 +317,55 @@ 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) { + 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) + return + } + existingAddress, err := s.dataService.GetUserTelegram(siteID, user.ID) + if err != nil { + 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) + 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) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to make verification token", rest.ErrInternal) + 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}) +} + // setConfirmedEmailCtrl uses provided token parameter (generated by sendEmailConfirmationCtrl) to set email and add it to user token // PUT /email/confirm?site=siteID&tkn=jwt func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request) { @@ -366,6 +418,46 @@ 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 + } + + 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") @@ -480,6 +572,20 @@ func (s *private) deleteEmailCtrl(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, R.JSON{"deleted": true}) } +// DELETE /telegram?site=siteID - removes user's telegram +func (s *private) deleteTelegramCtrl(w http.ResponseWriter, r *http.Request) { + user := rest.MustGetUserInfo(r) + siteID := r.URL.Query().Get("site") + log.Printf("[DEBUG] remove telegram for user %s", user.ID) + + if err := s.dataService.DeleteUserDetail(siteID, user.ID, engine.UserTelegram); err != nil { + code := parseError(err, rest.ErrInternal) + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete telegram for user", code) + return + } + render.JSON(w, r, R.JSON{"deleted": true}) +} + // GET /userdata?site=siteID - exports all data about the user as a json with user info and list of all comments func (s *private) userAllDataCtrl(w http.ResponseWriter, r *http.Request) { siteID := r.URL.Query().Get("site") diff --git a/backend/app/rest/api/rest_private_test.go b/backend/app/rest/api/rest_private_test.go index c3382156..daf3bb94 100644 --- a/backend/app/rest/api/rest_private_test.go +++ b/backend/app/rest/api/rest_private_test.go @@ -573,7 +573,7 @@ func (fs *MockFS) ReadFile(path string) ([]byte, error) { return []byte(fmt.Sprintf("template %s", path)), nil } -func TestRest_Email(t *testing.T) { +func TestRest_EmailAndTelegram(t *testing.T) { ts, srv, teardown := startupT(t) defer teardown() @@ -617,6 +617,17 @@ func TestRest_Email(t *testing.T) { {description: "unsubscribe user, wrong token", url: "/email/unsubscribe.html?site=remark42&tkn=jwt", method: http.MethodGet, responseCode: http.StatusForbidden}, {description: "unsubscribe user, good token", url: fmt.Sprintf("/email/unsubscribe.html?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK}, {description: "unsubscribe user second time, good token", url: fmt.Sprintf("/email/unsubscribe.html?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusConflict}, + {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: "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}, } client := http.Client{} for _, x := range testData { @@ -645,7 +656,7 @@ func TestRest_Email(t *testing.T) { } } -func TestRest_EmailNotification(t *testing.T) { +func TestRest_EmailAndTelegramNotification(t *testing.T) { ts, srv, teardown := startupT(t) defer teardown() @@ -675,8 +686,9 @@ func TestRest_EmailNotification(t *testing.T) { time.Sleep(time.Millisecond * 30) require.Equal(t, 1, len(mockDestination.Get())) assert.Empty(t, mockDestination.Get()[0].Emails) + assert.Empty(t, mockDestination.Get()[0].Telegrams) - // create child comment from another user, email notification only to admin expected + // create child comment from another user, email and telegram notification only to admin expected req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf( `{"text": "test 456", "pid": "%s", @@ -695,6 +707,7 @@ func TestRest_EmailNotification(t *testing.T) { time.Sleep(time.Millisecond * 30) require.Equal(t, 2, len(mockDestination.Get())) assert.Empty(t, mockDestination.Get()[1].Emails) + assert.Empty(t, mockDestination.Get()[1].Telegrams) // send confirmation token for email req, err = http.NewRequest(http.MethodPost, ts.URL+"/api/v1/email/subscribe?site=remark42&address=good@example.com", nil) @@ -710,10 +723,37 @@ func TestRest_EmailNotification(t *testing.T) { time.Sleep(time.Millisecond * 30) require.Equal(t, 1, len(mockDestination.GetVerify())) assert.Equal(t, "good@example.com", mockDestination.GetVerify()[0].Email) - verificationToken := mockDestination.GetVerify()[0].Token + emailVerificationToken := mockDestination.GetVerify()[0].Token // verify email - req, err = http.NewRequest(http.MethodPost, ts.URL+fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", verificationToken), nil) + req, err = http.NewRequest(http.MethodPost, ts.URL+fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", emailVerificationToken), 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)) + + // send confirmation token for telegram + req, err = http.NewRequest(http.MethodPost, ts.URL+"/api/v1/telegram/subscribe?site=remark42&address=good_telegram", 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)) + // wait for mock notification Submit to kick off + time.Sleep(time.Millisecond * 30) + require.Equal(t, 2, len(mockDestination.GetVerify())) + assert.Equal(t, "good_telegram", mockDestination.GetVerify()[1].Telegram) + telegramVerificationToken := mockDestination.GetVerify()[1].Token + + // verify telegram + req, err = http.NewRequest(http.MethodPost, ts.URL+fmt.Sprintf("/api/v1/telegram/confirm?site=remark42&tkn=%s", telegramVerificationToken), nil) require.NoError(t, err) req.Header.Add("X-JWT", devToken) resp, err = client.Do(req) @@ -739,7 +779,7 @@ func TestRest_EmailNotification(t *testing.T) { assert.Equal(t, store.User{Name: "developer one", ID: "dev", EmailSubscription: true, Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, user) - // create child comment from another user, email notification expected + // create child comment from another user, email and telegram notification expected req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf( `{"text": "test 789", "pid": "%s", @@ -758,6 +798,7 @@ func TestRest_EmailNotification(t *testing.T) { time.Sleep(time.Millisecond * 30) require.Equal(t, 3, len(mockDestination.Get())) assert.Equal(t, []string{"good@example.com"}, mockDestination.Get()[2].Emails) + assert.Equal(t, []string{"good_telegram"}, mockDestination.Get()[2].Telegrams) // delete user's email req, err = http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/email?site=remark42", nil) @@ -770,7 +811,18 @@ func TestRest_EmailNotification(t *testing.T) { require.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusOK, resp.StatusCode, string(body)) - // create child comment from another user, no email notification + // delete user's telegram + req, err = http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/telegram?site=remark42", 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()) + assert.Equal(t, http.StatusOK, resp.StatusCode, string(body)) + + // create child comment from another user, no email or telegram notification req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader( `{"text": "test 321", "user": {"name": "other_user"}, @@ -788,6 +840,7 @@ func TestRest_EmailNotification(t *testing.T) { time.Sleep(time.Millisecond * 30) require.Equal(t, 4, len(mockDestination.Get())) assert.Empty(t, mockDestination.Get()[3].Emails) + assert.Empty(t, mockDestination.Get()[3].Telegrams) } func TestRest_UserAllData(t *testing.T) { diff --git a/backend/remark.rest b/backend/remark.rest index b47d4d99..db39c9bf 100644 --- a/backend/remark.rest +++ b/backend/remark.rest @@ -134,6 +134,18 @@ 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}} +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}} +X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4 + +### delete current user telegram. auth token for dev user for secret=12345. +DELETE {{host}}/api/v1/telegram?site={{site}} +X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4 + ### get config GET {{host}}/api/v1/config?site={{site}} diff --git a/frontend/app/__stubs__/static-config.ts b/frontend/app/__stubs__/static-config.ts index 3dcb4d2b..63a3a268 100644 --- a/frontend/app/__stubs__/static-config.ts +++ b/frontend/app/__stubs__/static-config.ts @@ -16,6 +16,7 @@ beforeEach(() => { simple_view: false, anon_vote: false, email_notifications: false, + telegram_bot_username: "", emoji_enabled: true, }; }); diff --git a/frontend/app/common/static-store.ts b/frontend/app/common/static-store.ts index 76f1bc79..68254191 100644 --- a/frontend/app/common/static-store.ts +++ b/frontend/app/common/static-store.ts @@ -29,6 +29,7 @@ export const StaticStore: StaticStoreType = { simple_view: false, anon_vote: false, email_notifications: false, + telegram_bot_username: "", emoji_enabled: false, }, query: querySettings as QuerySettingsType, diff --git a/frontend/app/common/types.ts b/frontend/app/common/types.ts index e5c3d193..1f9088ab 100644 --- a/frontend/app/common/types.ts +++ b/frontend/app/common/types.ts @@ -114,6 +114,7 @@ export interface Config { simple_view: boolean; anon_vote: boolean; email_notifications: boolean; + telegram_bot_username: string; emoji_enabled: boolean; }