diff --git a/README.md b/README.md index da8a34bc..76e47956 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engi * Images upload with drag-and-drop * Extractor for recent comments, cross-post * RSS for all comments and each post -* Telegram notifications +* Telegram and email notifications * 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 @@ -154,11 +154,19 @@ _this is the recommended way to run remark42_ | auth.email.passwd | AUTH_EMAIL_PASSWD | | smtp password | | auth.email.timeout | AUTH_EMAIL_TIMEOUT | `10s` | smtp timeout | | auth.email.template | AUTH_EMAIL_TEMPLATE | none (predefined) | custom email message template file | -| notify.type | NOTIFY_TYPE | none | type of notification (none or telegram) | +| notify.type | NOTIFY_TYPE | none | type of notification (telegram and/or email) | | notify.queue | NOTIFY_QUEUE | `100` | size of notification queue | | notify.telegram.token | NOTIFY_TELEGRAM_TOKEN | | telegram token | | notify.telegram.chan | NOTIFY_TELEGRAM_CHAN | | telegram channel | | notify.telegram.timeout | NOTIFY_TELEGRAM_TIMEOUT | `5s` | telegram timeout | +| notify.email.host | NOTIFY_EMAIL_HOST | | SMTP host | +| notify.email.port | NOTIFY_EMAIL_PORT | `587` | SMTP port | +| notify.email.tls | NOTIFY_EMAIL_TLS | | enable TLS for SMTP | +| notify.email.fromAddress | NOTIFY_EMAIL_FROM | | from email address | +| notify.email.username | NOTIFY_EMAIL_USERNAME | | SMTP user name | +| notify.email.password | NOTIFY_EMAIL_PASSWORD | | SMTP password | +| notify.email.timeout | NOTIFY_EMAIL_TIMEOUT | `10s` | SMTP TCP connection timeout | +| notify.email.verification_subj | NOTIFY_EMAIL_VERIFICATION_SUBJ | `Email verification` | verification message subject | | ssl.type | SSL_TYPE | none | `none`-http, `static`-https, `auto`-https + le | | ssl.port | SSL_PORT | `8443` | port for https server | | ssl.cert | SSL_CERT | | path to cert.pem file | @@ -764,6 +772,17 @@ data: {"url":"https://radio-t.com/blah1","count":9,"first_time":"2019-06-18T12:5 _returned id should be appended to load image url on caller side_ +### Email subscription + +* `GET /api/v1/email?site=site-id` - get user's email, _auth required_ +* `POST /api/v1/email/subscribe?site=site-id&address=user@example.org` - makes confirmation token and sends it to user over email, _auth required_ + + Trying to subscribe same email second time will return response code `409 Conflict` and explaining error message. +* `POST /api/v1/email/confirm?site=site-id&tkn=token` - uses provided token parameter to set email for the user, _auth required_ + + Setting email subscribe user for all first-level replies to his messages. +* `DELETE /api/v1/email?site=siteID` - removes user's email, _auth required_ + ### Admin * `DELETE /api/v1/admin/comment/{id}?site=site-id&url=post-url` - delete comment by `id`. diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index 04b465a4..2d7710ce 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -15,6 +15,7 @@ import ( "time" bolt "github.com/coreos/bbolt" + "github.com/dgrijalva/jwt-go" "github.com/go-pkgz/jrpc" log "github.com/go-pkgz/lgr" "github.com/kyokomi/emoji" @@ -168,14 +169,24 @@ type AdminGroup struct { // NotifyGroup defines options for notification type NotifyGroup struct { - Type string `long:"type" env:"TYPE" description:"type of notification" choice:"none" choice:"telegram" default:"none"` //nolint - QueueSize int `long:"queue" env:"QUEUE" description:"size of notification queue" default:"100"` + Type []string `long:"type" env:"TYPE" description:"type of notification" choice:"none" choice:"telegram" choice:"email" default:"none" env-delim:","` //nolint + QueueSize int `long:"queue" env:"QUEUE" description:"size of notification queue" default:"100"` Telegram struct { Token string `long:"token" env:"TOKEN" description:"telegram token"` Channel string `long:"chan" env:"CHAN" description:"telegram channel"` Timeout time.Duration `long:"timeout" env:"TIMEOUT" default:"5s" description:"telegram timeout"` API string `long:"api" env:"API" default:"https://api.telegram.org/bot" description:"telegram api prefix"` } `group:"telegram" namespace:"telegram" env-namespace:"TELEGRAM"` + Email struct { + Host string `long:"host" env:"HOST" description:"SMTP host"` + Port int `long:"port" env:"PORT" default:"587" description:"SMTP port"` + TLS bool `long:"tls" env:"TLS" description:"enable TLS for SMTP"` + From string `long:"fromAddress" env:"FROM" description:"from email address"` + Username string `long:"username" env:"USERNAME" description:"SMTP user name"` + Password string `long:"password" env:"PASSWORD" description:"SMTP password"` + TimeOut time.Duration `long:"timeout" env:"TIMEOUT" default:"10s" description:"SMTP TCP connection timeout"` + VerificationSubject string `long:"verification_subj" env:"VERIFICATION_SUBJ" description:"verification message subject"` + } `group:"email" namespace:"email" env-namespace:"EMAIL"` } // SSLGroup defines options group for server ssl params @@ -316,7 +327,7 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { KeyStore: adminStore, } - notifyService, err := s.makeNotify(dataService) + notifyService, err := s.makeNotify(dataService, authenticator) if err != nil { log.Printf("[WARN] failed to make notify service, %s", err) notifyService = notify.NopService // disable notifier @@ -576,10 +587,10 @@ var msgTemplate = `

Remark42

-

Confirmation for {{.User}} on site {{.Site}}

+

Confirmation for {{.User}} on site {{.Site}}

TOKEN

-

Copy and paste this text into “token” field on comments page

+

Copy and paste this text into “token” field on comments page

{{.Token}}

Sent to {{.Address}}

@@ -673,20 +684,65 @@ func (s *ServerCommand) loadEmailTemplate() string { return tmpl } -func (s *ServerCommand) makeNotify(dataStore *service.DataStore) (*notify.Service, error) { - log.Printf("[INFO] make notify, type=%s", s.Notify.Type) - switch s.Notify.Type { - case "telegram": - tg, err := notify.NewTelegram(s.Notify.Telegram.Token, s.Notify.Telegram.Channel, - s.Notify.Telegram.Timeout, s.Notify.Telegram.API) - if err != nil { - return nil, errors.Wrap(err, "failed to create telegram notification destination") +func (s *ServerCommand) makeNotify(dataStore *service.DataStore, authenticator *auth.Service) (*notify.Service, error) { + var notifyService *notify.Service + var destinations []notify.Destination + for _, t := range s.Notify.Type { + switch t { + case "telegram": + tg, err := notify.NewTelegram(s.Notify.Telegram.Token, s.Notify.Telegram.Channel, + s.Notify.Telegram.Timeout, s.Notify.Telegram.API) + if err != nil { + return nil, errors.Wrap(err, "failed to create telegram notification destination") + } + destinations = append(destinations, tg) + case "email": + emailParams := notify.EmailParams{ + From: s.Notify.Email.From, + VerificationSubject: s.Notify.Email.VerificationSubject, + UnsubscribeURL: s.RemarkURL + "/api/v1/email/unsubscribe", + TokenGenFn: func(userID, email, site string) (string, error) { + claims := token.Claims{ + Handshake: &token.Handshake{ID: userID + "::" + email}, + StandardClaims: jwt.StandardClaims{ + Audience: site, + ExpiresAt: time.Now().Add(100 * 365 * 24 * time.Hour).Unix(), + NotBefore: time.Now().Add(-1 * time.Minute).Unix(), + Issuer: "remark42", + }, + } + tkn, err := authenticator.TokenService().Token(claims) + if err != nil { + return "", errors.Wrapf(err, "failed to make unsubscription token") + } + return tkn, nil + }, + } + smtpParams := notify.SmtpParams{ + Host: s.Notify.Email.Host, + Port: s.Notify.Email.Port, + TLS: s.Notify.Email.TLS, + Username: s.Notify.Email.Username, + Password: s.Notify.Email.Password, + TimeOut: s.Notify.Email.TimeOut, + } + emailService, err := notify.NewEmail(emailParams, smtpParams) + if err != nil { + return nil, errors.Wrap(err, "failed to create email notification destination") + } + destinations = append(destinations, emailService) + case "none": + notifyService = notify.NopService + default: + return nil, errors.Errorf("unsupported notification type %q", s.Notify.Type) } - return notify.NewService(dataStore, s.Notify.QueueSize, tg), nil - case "none": - return notify.NopService, nil } - return nil, errors.Errorf("unsupported notification type %q", s.Notify.Type) + + if len(destinations) != 0 { + log.Printf("[INFO] make notify, types=%s", s.Notify.Type) + notifyService = notify.NewService(dataStore, s.Notify.QueueSize, destinations...) + } + return notifyService, nil } func (s *ServerCommand) makeSSLConfig() (config api.SSLConfig, err error) { @@ -735,6 +791,11 @@ func (s *ServerCommand) makeAuthenticator(ds *service.DataStore, avas avatar.Sto } c.User.SetAdmin(ds.IsAdmin(c.Audience, c.User.ID)) c.User.SetBoolAttr("blocked", ds.IsBlocked(c.Audience, c.User.ID)) + var err error + c.User.Email, err = ds.GetUserEmail(store.Locator{SiteID: c.Audience}, c.User.ID) + if err != nil { + log.Printf("[WARN] can't read email for %s, %v", c.User.ID, err) + } return c }), AdminPasswd: s.AdminPasswd, diff --git a/backend/app/cmd/server_test.go b/backend/app/cmd/server_test.go index f8dae9df..b5dd8e00 100644 --- a/backend/app/cmd/server_test.go +++ b/backend/app/cmd/server_test.go @@ -469,9 +469,15 @@ func prepServerApp(t *testing.T, duration time.Duration, fn func(o ServerCommand cmd.Auth.Email.Enable = true cmd.Auth.Email.MsgTemplate = "testdata/email.tmpl" cmd.BackupLocation = "/tmp" - cmd.Notify.Type = "telegram" - cmd.Notify.Telegram.API = "http://127.0.0.1:12340/" - cmd.Notify.Telegram.Token = "blah" + cmd.Notify.Type = []string{"email"} + cmd.Notify.Email.Host = "127.0.0.1" + cmd.Notify.Email.Port = 25 + cmd.Notify.Email.TLS = false + cmd.Notify.Email.From = "from@example.org" + cmd.Notify.Email.Username = "test_user" + cmd.Notify.Email.Password = "test_password" + cmd.Notify.Email.TimeOut = time.Second + cmd.Notify.Email.VerificationSubject = "test verification email subject" cmd.UpdateLimit = 10 cmd = fn(cmd) diff --git a/backend/app/notify/email.go b/backend/app/notify/email.go index 158b7bdb..9bb8f8cb 100644 --- a/backend/app/notify/email.go +++ b/backend/app/notify/email.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "fmt" "io" + "mime/quotedprintable" "net" "net/smtp" "text/template" @@ -13,18 +14,18 @@ import ( log "github.com/go-pkgz/lgr" "github.com/go-pkgz/repeater" - "github.com/hashicorp/go-multierror" "github.com/pkg/errors" ) // EmailParams contain settings for email notifications type EmailParams struct { - From string // From email field - MsgTemplate string // request message template - VerificationSubject string // verification message subject - VerificationTemplate string // verification message template - BufferSize int // email send buffer size - FlushDuration time.Duration // maximum time after which email will me sent, 30s by default + From string // from email address + MsgTemplate string // request message template + VerificationSubject string // verification message subject + VerificationTemplate string // verification message template + UnsubscribeURL string // full unsubscribe handler URL + + TokenGenFn func(userID, email, site string) (string, error) // Unsubscribe token generation function } // SmtpParams contain settings for smtp server connection @@ -73,49 +74,71 @@ type emailMessage struct { // msgTmplData store data for message from request template execution type msgTmplData struct { - From string - To string - Orig string - Link string - PostTitle string + CommentUser string + ParentUser string + CommentText string + CommentLink string + PostTitle string + Email string + UnsubscribeLink string } // verifyTmplData store data for verification message template execution type verifyTmplData struct { User string - Email string Token string + Email string Site string } const ( defaultVerificationSubject = "Email verification" defaultEmailTimeout = 10 * time.Second - defaultFlushDuration = time.Second * 30 - defaultEmailTemplate = `{{.From}}{{if .To}} → {{.To}}{{end}} - -{{.Orig}} - -↦ {{if .PostTitle}}{{.PostTitle}}{{else}}original comment{{end}} + defaultEmailTemplate = ` + + + + + + + +
+

Remark42

+
+

New reply from {{.CommentUser}} on your comment{{if .PostTitle}} to "{{.PostTitle}}"{{end}}

+
{{.CommentText}}
+

Sent to {{.Email}} for {{.ParentUser}}

Unsubscribe

+
+
+ + ` - defaultEmailVerificationTemplate = `Confirmation for {{.User}} {{.Email}}, site {{.Site}} - -Token: {{.Token}} + defaultEmailVerificationTemplate = ` + + + + + + +
+

Remark42

+

Confirmation for {{.User}} on site {{.Site}}

+
+

TOKEN

+

Copy and paste this text into “token” field on comments page

+

{{.Token}}

+
+

Sent to {{.Email}}

+
+ + ` ) -// NewEmail makes new Email object, returns it even in case of problems -// (e.MsgTemplate parsing error or error while testing smtp connection by credentials provided in emailParams) +// NewEmail makes new Email object, returns error in case of e.MsgTemplate or e.VerificationTemplate parsing error func NewEmail(emailParams EmailParams, smtpParams SmtpParams) (*Email, error) { - var err error // set up Email emailParams res := Email{EmailParams: emailParams} - if res.FlushDuration <= 0 { - res.FlushDuration = defaultFlushDuration - } - if res.BufferSize <= 0 { - res.BufferSize = 1 - } if res.MsgTemplate == "" { res.MsgTemplate = defaultEmailTemplate } @@ -137,25 +160,12 @@ func NewEmail(emailParams EmailParams, smtpParams SmtpParams) (*Email, error) { res.Host, res.Username, res.TimeOut) // initialise templates - res.msgTmpl, err = template.New("messageFromRequest").Parse(res.MsgTemplate) - if err != nil { - return &res, errors.Wrapf(err, "can't parse message template") + var err error + if res.msgTmpl, err = template.New("messageFromRequest").Parse(res.MsgTemplate); err != nil { + return nil, errors.Wrapf(err, "can't parse message template") } - res.verifyTmpl, err = template.New("messageFromRequest").Parse(res.VerificationTemplate) - if err != nil { - return &res, errors.Wrapf(err, "can't parse verification template") - } - - // establish test connection - testSmtpClient, err := res.smtp.Create(res.SmtpParams) - if err != nil { - return &res, errors.Wrapf(err, "can't establish test connection") - } - if err = testSmtpClient.Quit(); err != nil { - log.Printf("[WARN] failed to send quit command to %s:%d, %v", res.Host, res.Port, err) - if err = testSmtpClient.Close(); err != nil { - return &res, errors.Wrapf(err, "can't close test smtp connection") - } + if res.verifyTmpl, err = template.New("messageFromRequest").Parse(res.VerificationTemplate); err != nil { + return nil, errors.Wrapf(err, "can't parse verification template") } return &res, err } @@ -186,65 +196,104 @@ func (e *Email) Send(ctx context.Context, req Request) (err error) { if req.Comment.ID != "" { if req.parent.User == req.Comment.User { - // don't send anything if if user replied to their own Comment + // don't send anything if if user replied to their own comment return nil } log.Printf("[DEBUG] send notification via %s, comment id %s", e, req.Comment.ID) - msg, err = e.buildMessageFromRequest(req, req.Email) + msg, err = e.buildMessageFromRequest(req) if err != nil { return err } } - return e.sendMessage(ctx, emailMessage{from: e.From, to: req.Email, message: msg}) + return repeater.NewDefault(5, time.Millisecond*250).Do( + ctx, + func() error { + return e.sendMessage(emailMessage{from: e.From, to: req.Email, message: msg}) + }) } // buildVerificationMessage generates verification email message based on given input -func (e *Email) buildVerificationMessage(user, address, token, site string) (string, error) { +func (e *Email) buildVerificationMessage(user, email, token, site string) (string, error) { subject := e.VerificationSubject msg := bytes.Buffer{} - err := e.verifyTmpl.Execute(&msg, verifyTmplData{user, address, token, site}) + err := e.verifyTmpl.Execute(&msg, verifyTmplData{ + User: user, + Token: token, + Email: email, + Site: site, + }) if err != nil { - return "", errors.Wrapf(err, "error executing template to build verifying message from request") + return "", errors.Wrapf(err, "error executing template to build verification message") } - return e.buildMessage(subject, msg.String(), address, "text/html"), nil -} - -// buildMessage generates email message to send using net/smtp.Data() -func (e *Email) buildMessage(subject, body, to, contentType string) (message string) { - message += fmt.Sprintf("From: %s\n", e.From) - message += fmt.Sprintf("To: %s\n", to) - message += fmt.Sprintf("Subject: %s\n", subject) - if contentType != "" { - message += fmt.Sprintf("MIME-version: 1.0;\nContent-Type: %s; charset=\"UTF-8\";\n", contentType) - } - message += "\n" + body - return message + return e.buildMessage(subject, msg.String(), email, "text/html", "") } // buildMessageFromRequest generates email message based on Request using e.MsgTemplate -func (e *Email) buildMessageFromRequest(req Request, to string) (string, error) { - subject := "New comment" +func (e *Email) buildMessageFromRequest(req Request) (string, error) { + subject := "New reply to your comment" if req.Comment.PostTitle != "" { subject += fmt.Sprintf(" for \"%s\"", req.Comment.PostTitle) } + token, err := e.TokenGenFn(req.parent.User.ID, req.Email, req.Comment.Locator.SiteID) + unsubscribeLink := e.UnsubscribeURL + "?site=" + req.Comment.Locator.SiteID + "&tkn=" + token + if err != nil { + return "", errors.Wrapf(err, "error creating token for unsubscribe link") + } msg := bytes.Buffer{} - err := e.msgTmpl.Execute(&msg, msgTmplData{ - req.Comment.User.Name, - req.parent.User.Name, - req.Comment.Orig, - req.Comment.Locator.URL + uiNav + req.Comment.ID, - req.Comment.PostTitle, + err = e.msgTmpl.Execute(&msg, msgTmplData{ + CommentUser: req.Comment.User.Name, + ParentUser: req.parent.User.Name, + CommentText: req.Comment.Text, + CommentLink: req.Comment.Locator.URL + uiNav + req.Comment.ID, + PostTitle: req.Comment.PostTitle, + Email: req.Email, + UnsubscribeLink: unsubscribeLink, }) if err != nil { - return "", errors.Wrapf(err, "error executing template to build message from request") + return "", errors.Wrapf(err, "error executing template to build comment reply message") } - return e.buildMessage(subject, msg.String(), to, "text/html"), nil + return e.buildMessage(subject, msg.String(), req.Email, "text/html", unsubscribeLink) +} + +// buildMessage generates email message to send using net/smtp.Data() +func (e *Email) buildMessage(subject, body, to, contentType, unsubscribeLink string) (message string, err error) { + addHeader := func(msg, h, v string) string { + msg += fmt.Sprintf("%s: %s\n", h, v) + return msg + } + message = addHeader(message, "From", e.From) + message = addHeader(message, "To", to) + message = addHeader(message, "Subject", subject) + message = addHeader(message, "Content-Transfer-Encoding", "quoted-printable") + + if contentType != "" { + message = addHeader(message, "MIME-version", "1.0") + message = addHeader(message, "Content-Type", contentType+`; charset="UTF-8"`) + } + + if unsubscribeLink != "" { + // https://support.google.com/mail/answer/81126 -> "Include option to unsubscribe" + message = addHeader(message, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click") + message = addHeader(message, "List-Unsubscribe", "<"+unsubscribeLink+">") + } + + message = addHeader(message, "Date", time.Now().Format(time.RFC1123Z)) + + buff := &bytes.Buffer{} + qp := quotedprintable.NewWriter(buff) + if _, err := qp.Write([]byte(body)); err != nil { + return "", err + } + defer qp.Close() + m := buff.String() + message += "\n" + m + return message, nil } // sendMessage sends messages to server in a new connection, closing the connection after finishing. // Thread safe. -func (e *Email) sendMessage(ctx context.Context, m emailMessage) error { +func (e *Email) sendMessage(m emailMessage) error { if e.smtp == nil { return errors.New("sendMessage called without smtpClient set") } @@ -253,54 +302,60 @@ func (e *Email) sendMessage(ctx context.Context, m emailMessage) error { return errors.Wrap(err, "failed to make smtp Create") } - errs := new(multierror.Error) - - err = repeater.NewDefault(5, time.Millisecond*250).Do(ctx, func() error { - if err := smtpClient.Mail(m.from); err != nil { - return errors.Wrapf(err, "bad from address %q", m.from) - } - if err := smtpClient.Rcpt(m.to); err != nil { - return errors.Wrapf(err, "bad to address %q", m.to) - } - - writer, err := smtpClient.Data() - if err != nil { - return errors.Wrap(err, "can't make email writer") - } - defer func() { - if err = writer.Close(); err != nil { - log.Printf("[WARN] can't close smtp body writer, %v", err) + defer func() { + if err := smtpClient.Quit(); err != nil { + log.Printf("[WARN] failed to send quit command to %s:%d, %v", e.Host, e.Port, err) + if err := smtpClient.Close(); err != nil { + log.Printf("[WARN] can't close smtp connection, %v", err) } - }() - - buf := bytes.NewBufferString(m.message) - if _, err = buf.WriteTo(writer); err != nil { - return errors.Wrapf(err, "failed to send email body to %q", m.to) } - return nil - }) + }() + + if err := smtpClient.Mail(m.from); err != nil { + return errors.Wrapf(err, "bad from address %q", m.from) + } + if err := smtpClient.Rcpt(m.to); err != nil { + return errors.Wrapf(err, "bad to address %q", m.to) + } + + writer, err := smtpClient.Data() if err != nil { - errs = multierror.Append(errs, errors.Wrapf(err, "can't send message to %s", m.to)) + return errors.Wrap(err, "can't make email writer") } - if err := smtpClient.Quit(); err != nil { - log.Printf("[WARN] failed to send quit command to %s:%d, %v", e.Host, e.Port, err) - if err := smtpClient.Close(); err != nil { - log.Printf("[WARN] can't close smtp connection, %v", err) - errs = multierror.Append(errs, err) + defer func() { + if err = writer.Close(); err != nil { + log.Printf("[WARN] can't close smtp body writer, %v", err) } + }() + + buf := bytes.NewBufferString(m.message) + if _, err = buf.WriteTo(writer); err != nil { + return errors.Wrapf(err, "failed to send email body to %q", m.to) } - return errors.Wrapf(errs.ErrorOrNil(), "problems with sending message") + + return nil } // String representation of Email object func (e *Email) String() string { - return fmt.Sprintf("email: from %q using '%s'@'%s':%d", e.From, e.Username, e.Host, e.Port) + return fmt.Sprintf("email: from %q with username '%s' at server %s:%d", e.From, e.Username, e.Host, e.Port) } // Create establish SMTP connection with server using credentials in smtpClientWithCreator.SmtpParams // and returns pointer to it. Thread safe. func (s *emailClient) Create(params SmtpParams) (smtpClient, error) { + authenticate := func(c *smtp.Client) error { + if params.Username == "" || params.Password == "" { + return nil + } + auth := smtp.PlainAuth("", params.Username, params.Password, params.Host) + if err := c.Auth(auth); err != nil { + return errors.Wrapf(err, "failed to auth to smtp %s:%d", params.Host, params.Port) + } + return nil + } + var c *smtp.Client srvAddress := fmt.Sprintf("%s:%d", params.Host, params.Port) if params.TLS { @@ -315,7 +370,7 @@ func (s *emailClient) Create(params SmtpParams) (smtpClient, error) { if c, err = smtp.NewClient(conn, params.Host); err != nil { return nil, errors.Wrapf(err, "failed to make smtp client for %s", srvAddress) } - return c, nil + return c, authenticate(c) } conn, err := net.DialTimeout("tcp", srvAddress, params.TimeOut) @@ -328,12 +383,5 @@ func (s *emailClient) Create(params SmtpParams) (smtpClient, error) { return nil, errors.Wrap(err, "failed to dial") } - if params.Username != "" && params.Password != "" { - auth := smtp.PlainAuth("", params.Username, params.Password, params.Host) - if err := c.Auth(auth); err != nil { - return nil, errors.Wrapf(err, "failed to auth to smtp %s:%d", params.Host, params.Port) - } - } - - return c, nil + return c, authenticate(c) } diff --git a/backend/app/notify/email_test.go b/backend/app/notify/email_test.go index 7c977a79..f137b914 100644 --- a/backend/app/notify/email_test.go +++ b/backend/app/notify/email_test.go @@ -19,25 +19,21 @@ import ( func TestEmailNew(t *testing.T) { var testSet = []struct { name string - template bool err bool errText string emailParams EmailParams smtpParams SmtpParams }{ - {name: "with connection error", template: true, err: true}, + {name: "empty"}, {name: "with template parse error", err: true, errText: "can't parse message template: template: messageFromRequest:1: unexpected unclosed action in command", emailParams: EmailParams{ - From: "test@from", - MsgTemplate: "{{", - BufferSize: 10, - FlushDuration: time.Second, + MsgTemplate: "{{", }}, {name: "with verification template parse error", err: true, errText: "can't parse verification template: template: messageFromRequest:1: unexpected unclosed action in command", - template: true, emailParams: EmailParams{ + From: "test@from", VerificationTemplate: "{{", }, smtpParams: SmtpParams{ @@ -49,71 +45,70 @@ func TestEmailNew(t *testing.T) { TimeOut: time.Second, }, }, + {name: "normal creation", + err: false, errText: "can't parse verification template: template: messageFromRequest:1: unexpected unclosed action in command", + emailParams: EmailParams{ + From: "test@from", + }, + smtpParams: SmtpParams{ + Host: "test@host", + Port: 1000, + TLS: true, + Username: "test@username", + Password: "test@password", + TimeOut: time.Second, + }, + }, } for _, d := range testSet { - d := d // capture range variable t.Run(d.name, func(t *testing.T) { email, err := NewEmail(d.emailParams, d.smtpParams) if d.err && d.errText == "" { assert.Error(t, err) + assert.Nil(t, email) } else if d.err && d.errText != "" { assert.EqualError(t, err, d.errText) + assert.Nil(t, email) } else { assert.NoError(t, err) - } + assert.NotNil(t, email, "email returned") - assert.NotNil(t, email, "email returned") - if d.template { assert.NotNil(t, email.msgTmpl, "e.template is set") - } else { - assert.Nil(t, email.msgTmpl, "e.template is not set") - } - if d.emailParams.MsgTemplate == "" { assert.Equal(t, defaultEmailTemplate, email.EmailParams.MsgTemplate, "empty emailParams.MsgTemplate changed to default") - } else { - assert.Equal(t, d.emailParams.MsgTemplate, email.EmailParams.MsgTemplate, "emailParams.MsgTemplate unchanged after creation") + assert.Equal(t, defaultEmailVerificationTemplate, email.EmailParams.VerificationTemplate, "empty emailParams.VerificationTemplate changed to default") + assert.Equal(t, d.emailParams.From, email.EmailParams.From, "emailParams.From unchanged after creation") + if d.smtpParams.TimeOut == 0 { + assert.Equal(t, defaultEmailTimeout, email.TimeOut, "empty emailParams.TimeOut changed to default") + } else { + assert.Equal(t, d.smtpParams.TimeOut, email.TimeOut, "emailParams.TimOut unchanged after creation") + } + assert.Equal(t, d.smtpParams.Host, email.Host, "emailParams.Host unchanged after creation") + assert.Equal(t, d.smtpParams.Username, email.Username, "emailParams.Username unchanged after creation") + assert.Equal(t, d.smtpParams.Password, email.Password, "emailParams.Password unchanged after creation") + assert.Equal(t, d.smtpParams.Port, email.Port, "emailParams.Port unchanged after creation") + assert.Equal(t, d.smtpParams.TLS, email.TLS, "emailParams.TLS unchanged after creation") } - if d.emailParams.FlushDuration == 0 { - assert.Equal(t, defaultFlushDuration, email.EmailParams.FlushDuration, "empty emailParams.FlushDuration changed to default") - } else { - assert.Equal(t, d.emailParams.FlushDuration, email.EmailParams.FlushDuration, "emailParams.FlushDuration unchanged after creation") - } - if d.emailParams.BufferSize == 0 { - assert.Equal(t, 1, email.EmailParams.BufferSize, "empty emailParams.BufferSize changed to default") - } else { - assert.Equal(t, d.emailParams.BufferSize, email.EmailParams.BufferSize, "emailParams.BufferSize unchanged after creation") - } - assert.Equal(t, d.emailParams.From, email.EmailParams.From, "emailParams.From unchanged after creation") - if d.smtpParams.TimeOut == 0 { - assert.Equal(t, defaultEmailTimeout, email.TimeOut, "empty emailParams.TimeOut changed to default") - } else { - assert.Equal(t, d.smtpParams.TimeOut, email.TimeOut, "emailParams.TimOut unchanged after creation") - } - assert.Equal(t, d.smtpParams.Host, email.Host, "emailParams.Host unchanged after creation") - assert.Equal(t, d.smtpParams.Username, email.Username, "emailParams.Username unchanged after creation") - assert.Equal(t, d.smtpParams.Password, email.Password, "emailParams.Password unchanged after creation") - assert.Equal(t, d.smtpParams.Port, email.Port, "emailParams.Port unchanged after creation") - assert.Equal(t, d.smtpParams.TLS, email.TLS, "emailParams.TLS unchanged after creation") }) } } func TestEmailSendErrors(t *testing.T) { var err error - e := Email{EmailParams: EmailParams{FlushDuration: time.Second}} + e := Email{} + e.TokenGenFn = TokenGenFn e.verifyTmpl, err = template.New("test").Parse("{{.Test}}") assert.NoError(t, err) assert.EqualError(t, e.Send(context.Background(), Request{Email: "bad@example.org", Verification: VerificationMetadata{Token: "some"}}), - "error executing template to build verifying message from request: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.verifyTmplData") + "error executing template to build verification message: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.verifyTmplData") e.verifyTmpl, err = template.New("test").Parse(defaultEmailVerificationTemplate) assert.NoError(t, err) e.msgTmpl, err = template.New("test").Parse("{{.Test}}") assert.NoError(t, err) assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Email: "bad@example.org"}), - "error executing template to build message from request: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.msgTmplData") + "error executing template to build comment reply message: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.msgTmplData") e.msgTmpl, err = template.New("test").Parse(defaultEmailTemplate) assert.NoError(t, err) @@ -121,11 +116,17 @@ func TestEmailSendErrors(t *testing.T) { cancel() assert.EqualError(t, e.Send(ctx, Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Email: "bad@example.org"}), "sending message to \"bad@example.org\" aborted due to canceled context") + + e.smtp = &fakeTestSMTP{} + assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Email: "bad@example.org"}), + "error creating token for unsubscribe link: token generation error") + e.msgTmpl, err = template.New("test").Parse(defaultEmailTemplate) + assert.NoError(t, err) } func TestEmailSend_ExitConditions(t *testing.T) { email, err := NewEmail(EmailParams{}, SmtpParams{}) - assert.Error(t, err, "error match expected") + assert.NoError(t, err) assert.NotNil(t, email, "expecting email returned") // prevent triggering e.autoFlush creation emptyRequest := Request{Comment: store.Comment{ID: "999"}} @@ -143,64 +144,68 @@ func TestEmailSendClientError(t *testing.T) { err string }{ {name: "failed to verify receiver", smtp: &fakeTestSMTP{fail: map[string]bool{"mail": true}}, - err: "problems with sending message: 1 error occurred:\n\t* can't send message to : bad from address \"\": failed to verify sender\n\n"}, + err: "bad from address \"\": failed to verify sender"}, {name: "failed to verify sender", smtp: &fakeTestSMTP{fail: map[string]bool{"rcpt": true}}, - err: "problems with sending message: 1 error occurred:\n\t* can't send message to : bad to address \"\": failed to verify receiver\n\n"}, - {name: "failed to close connection", smtp: &fakeTestSMTP{fail: map[string]bool{"quit": true, "close": true}}, - err: "problems with sending message: 1 error occurred:\n\t* failed to close\n\n"}, + err: "bad to address \"\": failed to verify receiver"}, + {name: "failed to close connection", smtp: &fakeTestSMTP{fail: map[string]bool{"quit": true, "close": true}}}, {name: "failed to make email writer", smtp: &fakeTestSMTP{fail: map[string]bool{"data": true}}, - err: "problems with sending message: 1 error occurred:\n\t* can't send message to : can't make email writer: failed to send\n\n"}, + err: "can't make email writer: failed to send"}, } for _, d := range testSet { - d := d // capture range variable t.Run(d.name, func(t *testing.T) { e := Email{smtp: d.smtp} - assert.EqualError(t, e.sendMessage(context.Background(), emailMessage{}), d.err, - "expected error for e.sendMessage") + if d.err != "" { + assert.EqualError(t, e.sendMessage(emailMessage{}), d.err, + "expected error for e.sendMessage") + } else { + assert.NoError(t, e.sendMessage(emailMessage{}), + "expected no error for e.sendMessage") + } }) } e := Email{} e.smtp = nil - assert.Error(t, e.sendMessage(context.Background(), emailMessage{}), + assert.Error(t, e.sendMessage(emailMessage{}), "nil e.smtp should return error") e.smtp = &fakeTestSMTP{} - assert.NoError(t, e.sendMessage(context.Background(), emailMessage{}), "", + assert.NoError(t, e.sendMessage(emailMessage{}), "", "no error expected for e.sendMessage in normal flow") e.smtp = &fakeTestSMTP{fail: map[string]bool{"quit": true}} - assert.NoError(t, e.sendMessage(context.Background(), emailMessage{}), "", + assert.NoError(t, e.sendMessage(emailMessage{}), "", "no error expected for e.sendMessage with failed smtpClient.Quit but successful smtpClient.Close") e.smtp = &fakeTestSMTP{fail: map[string]bool{"create": true}} - assert.EqualError(t, e.sendMessage(context.Background(), emailMessage{}), "failed to make smtp Create: failed to create client", + assert.EqualError(t, e.sendMessage(emailMessage{}), "failed to make smtp Create: failed to create client", "e.send called without smtpClient set returns error") } func TestEmail_Send(t *testing.T) { - const expectedAnswer = `From: from@example.org -To: test@example.org -Subject: New comment for "test_title" -MIME-version: 1.0; -Content-Type: text/html; charset="UTF-8"; - -test_user - - - -↦ test_title -` - req := Request{Comment: store.Comment{ID: "999", User: store.User{Name: "test_user"}, PostTitle: "test_title"}, Email: "test@example.org"} e, err := NewEmail(EmailParams{From: "from@example.org"}, SmtpParams{}) - assert.Error(t, err, "connection error expected") + assert.NoError(t, err) assert.NotNil(t, e) fakeSmtp := fakeTestSMTP{} e.smtp = &fakeSmtp + e.TokenGenFn = TokenGenFn + e.UnsubscribeURL = "https://remark42.com/api/v1/email/unsubscribe" + req := Request{ + Comment: store.Comment{ID: "999", User: store.User{Name: "test_user"}, PostTitle: "test_title"}, + parent: store.Comment{ID: "1", User: store.User{Name: "parent_user"}}, + Email: "test@example.org"} assert.NoError(t, e.Send(context.TODO(), req)) assert.Equal(t, "from@example.org", fakeSmtp.readMail()) assert.Equal(t, 1, fakeSmtp.readQuitCount()) assert.Equal(t, "test@example.org", fakeSmtp.readRcpt()) // test buildMessageFromRequest separately for message text - res, err := e.buildMessageFromRequest(req, "test@example.org") + res, err := e.buildMessageFromRequest(req) assert.NoError(t, err) - assert.Equal(t, expectedAnswer, res) + assert.Contains(t, res, `From: from@example.org +To: test@example.org +Subject: New reply to your comment for "test_title" +Content-Transfer-Encoding: quoted-printable +MIME-version: 1.0 +Content-Type: text/html; charset="UTF-8" +List-Unsubscribe-Post: List-Unsubscribe=One-Click +List-Unsubscribe: +Date: `) } type fakeTestSMTP struct { @@ -286,6 +291,13 @@ func (f *fakeTestSMTP) readQuitCount() int { return f.quitCount } +func TokenGenFn(user, _, _ string) (string, error) { + if user == "error" { + return "", errors.New("token generation error") + } + return "token", nil +} + type nopCloser struct { io.Writer } diff --git a/backend/app/notify/notify.go b/backend/app/notify/notify.go index 7d07da8c..e7f80a80 100644 --- a/backend/app/notify/notify.go +++ b/backend/app/notify/notify.go @@ -32,6 +32,7 @@ type Destination interface { // Store defines the minimal interface accessing stored comments used by notifier type Store interface { Get(locator store.Locator, id string, user store.User) (store.Comment, error) + GetUserEmail(locator store.Locator, userID string) (string, error) } // Request notification either about comment or about particular user verification @@ -81,6 +82,10 @@ func (s *Service) Submit(req Request) { if s.dataService != nil && req.Comment.ParentID != "" { if p, err := s.dataService.Get(req.Comment.Locator, req.Comment.ParentID, store.User{}); err == nil { req.parent = p + req.Email, err = s.dataService.GetUserEmail(req.Comment.Locator, p.User.ID) + if err != nil { + log.Printf("[WARN] can't read email for %s, %v", p.User.ID, err) + } } } select { diff --git a/backend/app/notify/notify_mock.go b/backend/app/notify/notify_mock.go new file mode 100644 index 00000000..43588d67 --- /dev/null +++ b/backend/app/notify/notify_mock.go @@ -0,0 +1,40 @@ +package notify + +import ( + "context" + "fmt" + "sync" + "time" + + log "github.com/go-pkgz/lgr" +) + +type MockDest struct { + data []Request + id int + closed bool + lock sync.Mutex +} + +func (m *MockDest) Send(ctx context.Context, r Request) error { + m.lock.Lock() + defer m.lock.Unlock() + select { + case <-time.After(10 * time.Millisecond): + m.data = append(m.data, r) + log.Printf("sent %s -> %d", r.Comment.ID, m.id) + case <-ctx.Done(): + log.Printf("ctx closed %d", m.id) + m.closed = true + } + return nil +} + +func (m *MockDest) Get() []Request { + m.lock.Lock() + defer m.lock.Unlock() + res := make([]Request, len(m.data)) + copy(res, m.data) + return res +} +func (m *MockDest) String() string { return fmt.Sprintf("mock id=%d, closed=%v", m.id, m.closed) } diff --git a/backend/app/notify/notify_test.go b/backend/app/notify/notify_test.go index 3927d3a7..0f53afbc 100644 --- a/backend/app/notify/notify_test.go +++ b/backend/app/notify/notify_test.go @@ -1,16 +1,13 @@ package notify import ( - "context" "errors" "fmt" "math/rand" - "sync" "sync/atomic" "testing" "time" - log "github.com/go-pkgz/lgr" "github.com/stretchr/testify/assert" "github.com/umputun/remark/backend/app/store" @@ -27,7 +24,7 @@ func TestService_NoDestinations(t *testing.T) { } func TestService_WithDestinations(t *testing.T) { - d1, d2 := &mockDest{id: 1}, &mockDest{id: 2} + d1, d2 := &MockDest{id: 1}, &MockDest{id: 2} s := NewService(nil, 1, d1, d2) assert.NotNil(t, s) @@ -39,53 +36,53 @@ func TestService_WithDestinations(t *testing.T) { time.Sleep(time.Millisecond * 110) s.Close() - assert.Equal(t, 3, len(d1.get()), "got all comments to d1") - assert.Equal(t, 3, len(d2.get()), "got all comments to d2") + assert.Equal(t, 3, len(d1.Get()), "got all comments to d1") + assert.Equal(t, 3, len(d2.Get()), "got all comments to d2") - assert.Equal(t, "100", d1.get()[0].Comment.ID) - assert.Equal(t, "101", d1.get()[1].Comment.ID) - assert.Equal(t, "102", d1.get()[2].Comment.ID) + assert.Equal(t, "100", d1.Get()[0].Comment.ID) + assert.Equal(t, "101", d1.Get()[1].Comment.ID) + assert.Equal(t, "102", d1.Get()[2].Comment.ID) } func TestService_WithDrops(t *testing.T) { - d1, d2 := &mockDest{id: 1}, &mockDest{id: 2} + d1, d2 := &MockDest{id: 1}, &MockDest{id: 2} s := NewService(nil, 1, d1, d2) assert.NotNil(t, s) s.Submit(Request{Comment: store.Comment{ID: "100"}}) s.Submit(Request{Comment: store.Comment{ID: "101"}}) - time.Sleep(time.Millisecond * 110) + time.Sleep(time.Millisecond * 11) s.Submit(Request{Comment: store.Comment{ID: "102"}}) - time.Sleep(time.Millisecond * 110) + time.Sleep(time.Millisecond * 11) s.Close() s.Submit(Request{Comment: store.Comment{ID: "111"}}) // safe to send after close - assert.Equal(t, 2, len(d1.get()), "one comment dropped from d1") - assert.Equal(t, 2, len(d2.get()), "one comment dropped from d2") + assert.Equal(t, 2, len(d1.Get()), "one comment dropped from d1") + assert.Equal(t, 2, len(d2.Get()), "one comment dropped from d2") } func TestService_Many(t *testing.T) { - d1, d2 := &mockDest{id: 1}, &mockDest{id: 2} + d1, d2 := &MockDest{id: 1}, &MockDest{id: 2} s := NewService(nil, 5, d1, d2) assert.NotNil(t, s) for i := 0; i < 10; i++ { s.Submit(Request{Comment: store.Comment{ID: fmt.Sprintf("%d", 100+i)}}) - time.Sleep(time.Millisecond * time.Duration(rand.Int31n(200))) + time.Sleep(time.Millisecond * time.Duration(rand.Int31n(20))) } s.Close() time.Sleep(time.Millisecond * 10) - assert.NotEqual(t, 10, len(d1.get()), "some comments dropped from d1") - assert.NotEqual(t, 10, len(d2.get()), "some comments dropped from d2") + assert.NotEqual(t, 10, len(d1.Get()), "some comments dropped from d1") + assert.NotEqual(t, 10, len(d2.Get()), "some comments dropped from d2") assert.True(t, d1.closed) assert.True(t, d2.closed) } func TestService_WithParent(t *testing.T) { - dest := &mockDest{id: 1} + dest := &MockDest{id: 1} dataStore := &mockStore{data: map[string]store.Comment{}} dataStore.data["p1"] = store.Comment{ID: "p1"} @@ -100,7 +97,7 @@ func TestService_WithParent(t *testing.T) { time.Sleep(time.Millisecond * 110) s.Close() - destRes := dest.get() + destRes := dest.Get() assert.Equal(t, 2, len(destRes), "two comment notified") assert.Equal(t, "p1", destRes[0].Comment.ParentID) assert.Equal(t, "p1", destRes[0].parent.ID) @@ -115,42 +112,16 @@ func TestService_Nop(t *testing.T) { assert.Equal(t, uint32(1), atomic.LoadUint32(&s.closed)) } -type mockDest struct { - data []Request - id int - closed bool - lock sync.Mutex -} - -func (m *mockDest) Send(ctx context.Context, r Request) error { - m.lock.Lock() - defer m.lock.Unlock() - select { - case <-time.After(100 * time.Millisecond): - m.data = append(m.data, r) - log.Printf("sent %s -> %d", r.Comment.ID, m.id) - case <-ctx.Done(): - log.Printf("ctx closed %d", m.id) - m.closed = true - } - return nil -} - -func (m *mockDest) get() []Request { - m.lock.Lock() - defer m.lock.Unlock() - res := make([]Request, len(m.data)) - copy(res, m.data) - return res -} -func (m *mockDest) String() string { return fmt.Sprintf("mock id=%d, closed=%v", m.id, m.closed) } - type mockStore struct{ data map[string]store.Comment } -func (m *mockStore) Get(_ store.Locator, id string, user store.User) (store.Comment, error) { +func (m mockStore) Get(_ store.Locator, id string, _ store.User) (store.Comment, error) { res, ok := m.data[id] if !ok { return store.Comment{}, errors.New("no such id") } return res, nil } + +func (m mockStore) GetUserEmail(_ store.Locator, _ string) (string, error) { + return "", errors.New("no such user") +} diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index 20d622e9..55072b4b 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -235,6 +235,8 @@ func (s *Rest) routes() chi.Router { ropen.Get("/list", s.pubRest.listCtrl) ropen.Post("/preview", s.pubRest.previewCommentCtrl) ropen.Get("/info", s.pubRest.infoCtrl) + ropen.Get("/email/unsubscribe", s.privRest.emailUnsubscribeCtrl) + ropen.Post("/email/unsubscribe", s.privRest.emailUnsubscribeCtrl) ropen.Get("/img", s.ImageProxy.Handler) ropen.Route("/rss", func(rrss chi.Router) { @@ -308,6 +310,10 @@ func (s *Rest) routes() chi.Router { rauth.Post("/comment", s.privRest.createCommentCtrl) rauth.With(rejectAnonUser).Put("/vote/{id}", s.privRest.voteCtrl) rauth.With(rejectAnonUser).Post("/deleteme", s.privRest.deleteMeCtrl) + rauth.With(rejectAnonUser).Get("/email", s.privRest.getEmailCtrl) + 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) }) // protected routes, anonymous rejected diff --git a/backend/app/rest/api/rest_private.go b/backend/app/rest/api/rest_private.go index 2362139a..782b241f 100644 --- a/backend/app/rest/api/rest_private.go +++ b/backend/app/rest/api/rest_private.go @@ -22,6 +22,7 @@ import ( "github.com/umputun/remark/backend/app/notify" "github.com/umputun/remark/backend/app/rest" "github.com/umputun/remark/backend/app/store" + "github.com/umputun/remark/backend/app/store/engine" "github.com/umputun/remark/backend/app/store/image" "github.com/umputun/remark/backend/app/store/service" ) @@ -43,6 +44,9 @@ type privStore interface { Vote(req service.VoteReq) (comment store.Comment, err error) Get(locator store.Locator, commentID string, user store.User) (store.Comment, error) User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error) + GetUserEmail(locator store.Locator, userID string) (string, error) + SetUserEmail(locator store.Locator, userID string, value string) (string, error) + DeleteUserDetail(locator store.Locator, userID string, detail engine.UserDetail) error ValidateComment(c *store.Comment) error IsVerified(siteID string, userID string) bool IsReadOnly(locator store.Locator) bool @@ -221,6 +225,209 @@ func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, R.JSON{"id": comment.ID, "score": comment.Score}) } +// getEmailCtrl gets email address for authenticated user. +// GET /email?site=siteID +func (s *private) getEmailCtrl(w http.ResponseWriter, r *http.Request) { + user := rest.MustGetUserInfo(r) + siteID := r.URL.Query().Get("site") + address, err := s.dataService.GetUserEmail(store.Locator{SiteID: siteID}, user.ID) + if err != nil { + log.Printf("[WARN] can't read email for %s, %v", user.ID, err) + } + + render.JSON(w, r, R.JSON{"user": user, "address": address}) +} + +// sendEmailConfirmationCtrl gets address and siteID from query, makes confirmation token and sends it to user. +// GET /email/subscribe?site=siteID&address=someone@example.com +func (s *private) sendEmailConfirmationCtrl(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.GetUserEmail(store.Locator{SiteID: siteID}, user.ID) + if err != nil { + log.Printf("[WARN] can't read email for %s, %v", user.ID, err) + } + if address == existingAddress { + rest.SendErrorJSON(w, r, http.StatusConflict, errors.New("already verified"), "email 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.Submit( + notify.Request{ + Email: address, + Verification: notify.VerificationMetadata{ + Locator: store.Locator{SiteID: siteID}, + User: user.Name, + 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) { + 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) + locator := store.Locator{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 email for user %s", user.ID) + + val, err := s.dataService.SetUserEmail(locator, user.ID, address) + if err != nil { + code := parseError(err, rest.ErrInternal) + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set email for user", code) + return + } + + // update User.Email from the token + claims, _, err := s.authenticator.TokenService().Get(r) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal) + return + } + claims.User.Email = address + if _, err = s.authenticator.TokenService().Set(w, claims); err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal) + return + } + render.JSON(w, r, R.JSON{"updated": true, "address": val}) +} + +// POST/GET /email/unsubscribe?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") + if tkn == "" { + rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New("missing parameter"), "token parameter is required", rest.ErrInternal) + return + } + locator := store.Locator{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 { + rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New(confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal) + return + } + userID := elems[0] + address := elems[1] + + existingAddress, err := s.dataService.GetUserEmail(locator, userID) + if err != nil { + log.Printf("[WARN] can't read email for %s, %v", userID, err) + } + if existingAddress == "" { + rest.SendErrorJSON(w, r, http.StatusConflict, errors.New("user is not subscribed"), "user does not have active email subscription", rest.ErrInternal) + return + } + if address != existingAddress { + rest.SendErrorJSON(w, r, http.StatusBadRequest, errors.New("wrong email unsubscription"), "email address in request does not match known for this user", rest.ErrInternal) + return + } + + log.Printf("[DEBUG] unsubscribe user %s", userID) + + if err := s.dataService.DeleteUserDetail(locator, userID, engine.UserEmail); err != nil { + code := parseError(err, rest.ErrInternal) + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete email for user", code) + return + } + // clean User.Email from the token, if user has the token + claims, _, err := s.authenticator.TokenService().Get(r) + if err != nil { + log.Printf("[DEBUG] unsubscribed user doesn't have valid JWT token to update %s, %v", userID, err) + } + if claims.User != nil && claims.User.Email != "" { + claims.User.Email = "" + if _, err = s.authenticator.TokenService().Set(w, claims); err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal) + return + } + } + render.JSON(w, r, R.JSON{"unsubscribed": true}) +} + +// DELETE /email?site=siteID - removes user's email +func (s *private) deleteEmailCtrl(w http.ResponseWriter, r *http.Request) { + user := rest.MustGetUserInfo(r) + locator := store.Locator{SiteID: r.URL.Query().Get("site")} + log.Printf("[DEBUG] remove email for user %s", user.ID) + + if err := s.dataService.DeleteUserDetail(locator, user.ID, engine.UserEmail); err != nil { + code := parseError(err, rest.ErrInternal) + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete email for user", code) + return + } + // clean User.Email from the token + claims, _, err := s.authenticator.TokenService().Get(r) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal) + return + } + if claims.User.Email != "" { + claims.User.Email = "" + if _, err = s.authenticator.TokenService().Set(w, claims); err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal) + 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 cb16a42b..e309e31e 100644 --- a/backend/app/rest/api/rest_private_test.go +++ b/backend/app/rest/api/rest_private_test.go @@ -15,11 +15,15 @@ import ( "testing" "time" + "github.com/dgrijalva/jwt-go" + "github.com/go-chi/render" + "github.com/go-pkgz/auth/token" "github.com/go-pkgz/lgr" R "github.com/go-pkgz/rest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/umputun/remark/backend/app/notify" "github.com/umputun/remark/backend/app/store" "github.com/umputun/remark/backend/app/store/image" ) @@ -450,6 +454,190 @@ func TestRest_Vote(t *testing.T) { assert.Equal(t, map[string]bool(nil), cr.Votes) } +func TestRest_Email(t *testing.T) { + ts, srv, teardown := startupT(t) + defer teardown() + + // issue good token + claims := token.Claims{ + Handshake: &token.Handshake{ID: "dev::good@example.com"}, + StandardClaims: jwt.StandardClaims{ + Audience: "remark42", + ExpiresAt: time.Now().Add(10 * time.Minute).Unix(), + NotBefore: time.Now().Add(-1 * time.Minute).Unix(), + Issuer: "remark42", + }, + } + tkn, err := srv.Authenticator.TokenService().Token(claims) + require.NoError(t, err) + goodToken := tkn + + var testData = []struct { + description string + url string + method string + responseCode int + noAuth bool + cookieEmail string + }{ + {description: "issue delete request without auth", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusUnauthorized, noAuth: true}, + {description: "issue delete request without site_id", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusBadRequest}, + {description: "delete non-existent user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK}, + {description: "set user email, token not set", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest}, + {description: "send confirmation without address", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest}, + {description: "send confirmation", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK}, + {description: "set user email, token is good", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"}, + {description: "send confirmation with same address", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusConflict}, + {description: "get user email", url: "/api/v1/email?site=remark42", method: http.MethodGet, responseCode: http.StatusOK}, + {description: "delete user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK}, + {description: "send another confirmation", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK}, + } + client := http.Client{} + for _, x := range testData { + t.Run(x.description, func(t *testing.T) { + req, err := http.NewRequest(x.method, ts.URL+x.url, nil) + require.NoError(t, err) + if !x.noAuth { + 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) + // read User.Email from the token in the cookie + for _, c := range resp.Cookies() { + if c.Name == "JWT" { + claims, err := srv.Authenticator.TokenService().Parse(c.Value) + require.NoError(t, err) + assert.Equal(t, x.cookieEmail, claims.User.Email, "cookie email check failed") + } + } + assert.Equal(t, x.responseCode, resp.StatusCode, string(body)) + }) + } +} + +func TestRest_EmailNotification(t *testing.T) { + ts, srv, teardown := startupT(t) + defer teardown() + + mockDestination := ¬ify.MockDest{} + srv.privRest.notifyService = notify.NewService(srv.DataService, 1, mockDestination) + + client := http.Client{} + + // create new comment from dev user + req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader( + `{"text": "test 123", +"user": {"name": "dev::good@example.com"}, +"locator":{"url": "https://radio-t.com/blah1", +"site": "remark42"}}`)) + assert.Nil(t, err) + req.Header.Add("X-JWT", devToken) + resp, err := client.Do(req) + assert.Nil(t, err) + body, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode, string(body)) + parentComment := store.Comment{} + require.NoError(t, render.DecodeJSON(strings.NewReader(string(body)), &parentComment)) + // wait for mock notification Submit to kick off + time.Sleep(time.Millisecond * 5) + require.Equal(t, 1, len(mockDestination.Get())) + assert.Equal(t, "", mockDestination.Get()[0].Email) + + // create child comment from another user, no email notification expected + req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf( + `{"text": "test 456", + "pid": "%s", + "user": {"name": "other_user"}, + "locator":{"url": "https://radio-t.com/blah1", + "site": "remark42"}}`, parentComment.ID))) + assert.Nil(t, err) + req.Header.Add("X-JWT", devToken) + resp, err = client.Do(req) + assert.Nil(t, err) + body, err = ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode, string(body)) + // wait for mock notification Submit to kick off + time.Sleep(time.Millisecond * 5) + require.Equal(t, 2, len(mockDestination.Get())) + assert.Empty(t, mockDestination.Get()[1].Email) + + // send confirmation token for email + req, err = http.NewRequest(http.MethodPost, ts.URL+"/api/v1/email/subscribe?site=remark42&address=good@example.com", 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.Equal(t, http.StatusOK, resp.StatusCode, string(body)) + // wait for mock notification Submit to kick off + time.Sleep(time.Millisecond * 5) + require.Equal(t, 3, len(mockDestination.Get())) + require.NotEmpty(t, mockDestination.Get()[2].Verification) + verificationToken := mockDestination.Get()[2].Verification.Token + + // verify email + req, err = http.NewRequest(http.MethodPost, ts.URL+fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", verificationToken), 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.Equal(t, http.StatusOK, resp.StatusCode, string(body)) + + // create child comment from another user, email notification expected + req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf( + `{"text": "test 789", + "pid": "%s", + "user": {"name": "other_user"}, + "locator":{"url": "https://radio-t.com/blah1", + "site": "remark42"}}`, parentComment.ID))) + assert.Nil(t, err) + req.Header.Add("X-JWT", devToken) + resp, err = client.Do(req) + assert.Nil(t, err) + body, err = ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode, string(body)) + // wait for mock notification Submit to kick off + time.Sleep(time.Millisecond * 5) + require.Equal(t, 4, len(mockDestination.Get())) + assert.Equal(t, "good@example.com", mockDestination.Get()[3].Email) + + // delete user's email + req, err = http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/email?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) + assert.Equal(t, http.StatusOK, resp.StatusCode, string(body)) + + // create child comment from another user, no email notification expected + req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader( + `{"text": "test 321", + "user": {"name": "other_user"}, + "locator":{"url": "https://radio-t.com/blah1", + "site": "remark42"}}`)) + assert.Nil(t, err) + req.Header.Add("X-JWT", devToken) + resp, err = client.Do(req) + assert.Nil(t, err) + body, err = ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode, string(body)) + // wait for mock notification Submit to kick off + time.Sleep(time.Millisecond * 5) + require.Equal(t, 5, len(mockDestination.Get())) + assert.Empty(t, mockDestination.Get()[4].Email) +} + func TestRest_UserAllData(t *testing.T) { ts, srv, teardown := startupT(t) defer teardown() diff --git a/backend/app/rest/api/rest_test.go b/backend/app/rest/api/rest_test.go index 99d531e8..ee2dc6ac 100644 --- a/backend/app/rest/api/rest_test.go +++ b/backend/app/rest/api/rest_test.go @@ -27,6 +27,7 @@ import ( "github.com/stretchr/testify/require" "github.com/umputun/remark/backend/app/migrator" + "github.com/umputun/remark/backend/app/notify" "github.com/umputun/remark/backend/app/rest" "github.com/umputun/remark/backend/app/rest/proxy" "github.com/umputun/remark/backend/app/store" @@ -346,7 +347,8 @@ func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) { TimeOut: 5 * time.Second, MaxActive: 100, }, - EmojiEnabled: true, + NotifyService: notify.NopService, + EmojiEnabled: true, } srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = -5, -10 diff --git a/backend/app/store/service/service.go b/backend/app/store/service/service.go index 2defb9bb..afe815c9 100644 --- a/backend/app/store/service/service.go +++ b/backend/app/store/service/service.go @@ -159,6 +159,48 @@ func (s *DataStore) Put(locator store.Locator, comment store.Comment) error { return s.Engine.Update(comment) } +// GetUserEmail gets user email +func (s *DataStore) GetUserEmail(locator store.Locator, userID string) (string, error) { + res, err := s.Engine.UserDetail(engine.UserDetailRequest{ + Detail: engine.UserEmail, + Locator: locator, + UserID: userID, + }) + if err != nil { + return "", err + } + if len(res) == 1 { + return res[0].Email, nil + } + return "", nil +} + +// SetUserEmail sets user email +func (s *DataStore) SetUserEmail(locator store.Locator, userID string, value string) (string, error) { + res, err := s.Engine.UserDetail(engine.UserDetailRequest{ + Detail: engine.UserEmail, + Locator: locator, + UserID: userID, + Update: value, + }) + if err != nil { + return "", err + } + if len(res) == 1 { + return res[0].Email, nil + } + return "", nil +} + +// DeleteUserDetail deletes user detail +func (s *DataStore) DeleteUserDetail(locator store.Locator, userID string, detail engine.UserDetail) error { + return s.Engine.Delete(engine.DeleteRequest{ + Locator: locator, + UserID: userID, + UserDetail: detail, + }) +} + // submitImages initiated delayed commit of all images from the comment uploaded to remark42 func (s *DataStore) submitImages(comment store.Comment) { diff --git a/backend/remark.rest b/backend/remark.rest index 5136091f..1a34ee08 100644 --- a/backend/remark.rest +++ b/backend/remark.rest @@ -5,9 +5,9 @@ GET {{host}}/api/v1/find?site={{site}}&sort=-time&format=tree&url={{url}} ### find request with plain GET {{host}}/api/v1/find?site={{site}}&sort=-controversy&format=plain&url={{url}} -### find request with plain. dev token for secret=secret, not admin -GET http://127.0.0.1:8080/api/v1/find?site={{site}}&sort=-controversy&format=plain&url={{url}} -X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6ImRldiIsInBpY3R1cmUiOiJodHRwOi8vZXhhbXBsZS5jb20vcGljLnBuZyIsImlwIjoiMTI3LjAuMC4xIiwiZW1haWwiOiJtZUBleGFtcGxlLmNvbSJ9fQ.aKUAXiZxXypgV7m1wEOgUcyPOvUDXHDi3A06YWKbcLg +### find request with plain. dev token for secret=12345, not admin +GET {{host}}/api/v1/find?site={{site}}&sort=-controversy&format=plain&url={{url}} +X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4 ### last 50 comments GET {{host}}/api/v1/last/50?site={{site}} @@ -50,15 +50,15 @@ Content-Type: application/json { "text": "edit comment blah http://radio-t.com 12345", - "summary": "fix blah" + "summary": "fix blah" } ### pin comment PUT {{host}}/api/v1/admin/pin/3665976683?site={{site}}&url={{url}}&pin=1 ### vote for comment -PUT http://127.0.0.1:8080/api/v1/vote/8a8c0b80-0d0a-41c3-84ad-f4034704e827?site={{site}}&url={{url}}&vote=-1 -X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6ImRldiIsInBpY3R1cmUiOiJodHRwOi8vZXhhbXBsZS5jb20vcGljLnBuZyIsImlwIjoiMTI3LjAuMC4xIiwiZW1haWwiOiJtZUBleGFtcGxlLmNvbSJ9fQ.aKUAXiZxXypgV7m1wEOgUcyPOvUDXHDi3A06YWKbcLg +PUT {{host}}/api/v1/vote/8a8c0b80-0d0a-41c3-84ad-f4034704e827?site={{site}}&url={{url}}&vote=-1 +X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4 ### get user info GET {{host}}/api/v1/user @@ -83,9 +83,9 @@ POST {{host}}/api/v1/counts?site={{site}} Content-Type: application/json [ - "https://radio-t.com/p/2017/12/02/podcast-574/", - "https://radio-t.com/p/2017/12/09/podcast-575/", - "{{url}}" + "https://radio-t.com/p/2017/12/02/podcast-574/", + "https://radio-t.com/p/2017/12/09/podcast-575/", + "{{url}}" ] ### list commented posts @@ -104,7 +104,7 @@ GET {{host}}/api/v1/admin/blocked?site={{site}} DELETE {{host}}/api/v1/admin/comment/3665976683?site={{site}}&url={{url}} ### get post info -GET {{host}}/api/v1/info?site={{site}}&url={{url} +GET {{host}}/api/v1/info?site={{site}}&url={{url}} ### post rss GET {{host}}/api/v1/rss/post?site={{site}}&url={{url}} @@ -118,17 +118,32 @@ GET {{host}}/api/v1/rss/reply?site={{site}}&user={{user}} ### get default avatar GET {{host}}/api/v1/avatar/blah -### get config -GET {{host}}/api/v1/config?site={{site}} +### send confirmation token for current user to specified email. auth token for dev user for secret=12345. +POST {{host}}/api/v1/email/subscribe?site={{site}}&address={{email}} +X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4 -### deleteme (user's request). dev token for secret=secret, not admin +### add email for notifications for current user via token from email. auth token for dev user for secret=12345. +POST {{host}}/api/v1/email/confirm?site={{site}}&tkn={{token}} +X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4 + +### get current user email. auth token for dev user for secret=12345. +GET {{host}}/api/v1/email?site={{site}} +X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4 + +### delete current user email. auth token for dev user for secret=12345. +DELETE {{host}}/api/v1/email?site={{site}} +X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4 + +### get config +GET {{host}}/api/v1/config?site={{site}} + +### deleteme (user's request). dev token for secret=12345, not admin POST {{host}}/api/v1/deleteme?site_id={{site}} -X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6ImRldiIsInBpY3R1cmUiOiJodHRwOi8vZXhhbXBsZS5jb20vcGljLnBuZyIsImlwIjoiMTI3LjAuMC4xIiwiZW1haWwiOiJtZUBleGFtcGxlLmNvbSJ9fQ.aKUAXiZxXypgV7m1wEOgUcyPOvUDXHDi3A06YWKbcLg +X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE5NzYwNTY3NTYsImp0aSI6IjJlOGJmMTE5OTI0MjQxMDRjYjFhZGRlODllMWYwNGFiMTg4YWZjMzQiLCJpYXQiOjE1NzYwNTY0NTYsImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.6Qt5s2enBMRC-Jmsua01yViVYI95Dx6BPBMaNjj36d4 ### deletme, admin. admin token for secret=secret GET {{host}}/api/v1/admin/deleteme?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NjU2NjI4MDYsImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTU3NzEzOTQ2LCJ1c2VyIjp7Im5hbWUiOiJkZXZlbG9wZXIgb25lIiwiaWQiOiJkZXYiLCJwaWN0dXJlIjoiIiwiYXR0cnMiOnsiYWRtaW4iOmZhbHNlLCJibG9ja2VkIjpmYWxzZSwiZGVsZXRlX21lIjp0cnVlfX19.qmnsQt_jilHzoauA9D7t1m3w69qvAJsZkuKVPWhpdik X-JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcmsiLCJleHAiOjE2NTc3MDQ5NzksImp0aSI6Ijk4ZDNhOGFkMGNjZWU5M2Q1MWYwYjJiOTY1ZjU2YmE2NmJkNmZiNzYiLCJpYXQiOjE1NTc3MDQ2NzksImlzcyI6InJlbWFyazQyIiwidXNlciI6eyJuYW1lIjoiZGV2X3VzZXIiLCJpZCI6ImRldl91c2VyIiwicGljdHVyZSI6Imh0dHA6Ly8xMjcuMC4wLjE6ODA4MC9hcGkvdjEvYXZhdGFyL2NjZmEyYWJkMDE2Njc2MDViNGUxZmM0ZmNiOTFiMWUxYWYzMjMyNDAuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.YIt9Zq3n0O8PMkR78pGAqyHI0exCU0vLKjcHgcXfLFw - ### ping GET {{host}}/ping diff --git a/compose-dev-backend.yml b/compose-dev-backend.yml index 4c2e806a..3397fc98 100644 --- a/compose-dev-backend.yml +++ b/compose-dev-backend.yml @@ -49,6 +49,12 @@ services: - NOTIFY_TYPE - NOTIFY_TELEGRAM_TOKEN - NOTIFY_TELEGRAM_CHAN + - NOTIFY_EMAIL_HOST + - NOTIFY_EMAIL_USERNAME + - NOTIFY_EMAIL_PASSWORD + - NOTIFY_EMAIL_FROM + - NOTIFY_EMAIL_PORT + - NOTIFY_EMAIL_TLS - EMOJI=true - VOTES_IP=true - AUTH_EMAIL_ENABLE=true diff --git a/frontend/markdown-help.html b/frontend/markdown-help.html index 34a6903f..affd981c 100644 --- a/frontend/markdown-help.html +++ b/frontend/markdown-help.html @@ -339,8 +339,8 @@ 1. One
2. Two
3. Three
-   3.1. Item 3.1
-   3.2. Item 3.2
+ 3.1. Item 3.1
+ 3.2. Item 3.2

If you want bullet point

@@ -348,8 +348,8 @@

* Item 1
* Item 2
-   * Item 2a
-   * Item 2b
+ * Item 2a
+ * Item 2b

Images