add user telegram notifications

This commit is contained in:
Dmitry Verkhoturov
2021-07-03 14:57:09 -05:00
committed by Umputun
parent 7f081e1d2c
commit 200733ed03
12 changed files with 392 additions and 112 deletions
+2 -2
View File
@@ -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 |
+60 -42
View File
@@ -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) {
+5 -4
View File
@@ -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
+68 -9
View File
@@ -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
}
+30 -8
View File
@@ -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 {
+46 -40
View File
@@ -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{}
+106
View File
@@ -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")
+60 -7
View File
@@ -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) {
+12
View File
@@ -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}}
+1
View File
@@ -16,6 +16,7 @@ beforeEach(() => {
simple_view: false,
anon_vote: false,
email_notifications: false,
telegram_bot_username: "",
emoji_enabled: true,
};
});
+1
View File
@@ -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,
+1
View File
@@ -114,6 +114,7 @@ export interface Config {
simple_view: boolean;
anon_vote: boolean;
email_notifications: boolean;
telegram_bot_username: string;
emoji_enabled: boolean;
}