Add API methods for setting and deleting email (#483)

* add API methods for setting and deleting email

* fix service.SetStringUserDetail signature to return string

* switch table test with description to t.Run()

* remove debug logging

* clarify error handling, functions names

* add email integration test

* add information about email subscription to readme

* change email API calls method from PUT to POST

* typo fix, remove unneeded capturing of range variable

* email test draft

* fix notify mock, email notification test draft

* add MockDestination to startupT return

* fix tests

* add email retrieval for notifications sending

* fix mock for notify

* rearrange mock notify declaration

* add GET /email API handler, fix typos

* revert startupT signature change

* get rid of startupTWithDest workaround

* add rest examples for rest notification

* improve email messages formatting

* fix email send repeater location

* remove unneeded context from sendMessage

* change signatures of buildMessage functions to have same field name

* add missing authenticate call on TLS connection

* add dev user auth token to email requests

* change email verification template

* email code and tests cleanup

* replace fixed spaces with normal ones

* human-readable variables names for new comment reply notification

* rename Comment to CommentText

* add html for comment email notification

* fix comment notification html style

* fix email test

* fix notify email messages rendering

* fix comments on rest examples for email

* explicitly state email notify email template fields

* clarify email API documentation

* change email test not to check quoted-printable part of message

* Fix link color, add unsubscribe link

* fix rest examples tokens

* add UnsubscribeLink support to Email

* add unsubscribe email handler

* fix new reply notification email style
This commit is contained in:
Dmitry Verkhoturov
2019-12-16 16:39:55 -06:00
committed by Umputun
parent 45fa60f5a1
commit d23d119d70
16 changed files with 911 additions and 283 deletions
+21 -2
View File
@@ -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`.
+78 -17
View File
@@ -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 = `
<body>
<div style="text-align: center; font-family: Arial, sans-serif; font-size: 18px;">
<h1 style="position: relative; color: #4fbbd6; margin-top: 0.2em;">Remark42</h1>
<p style="position: relative; max-width: 20em; margin: 0 auto 1em auto; line-height: 1.4em;">Confirmation&nbsp;for <b>{{.User}}</b> on&nbsp;site&nbsp;<b>{{.Site}}</b></p>
<p style="position: relative; max-width: 20em; margin: 0 auto 1em auto; line-height: 1.4em;">Confirmation for <b>{{.User}}</b> on site <b>{{.Site}}</b></p>
<div style="background-color: #eee; max-width: 20em; margin: 0 auto; border-radius: 0.4em; padding: 0.5em;">
<p style="position: relative; margin: 0 0 0.5em 0;">TOKEN</p>
<p style="position: relative; font-size: 0.7em; opacity: 0.8;"><i>Copy and&nbsp;paste this text into “token” field on&nbsp;comments page</i></p>
<p style="position: relative; font-size: 0.7em; opacity: 0.8;"><i>Copy and paste this text into “token” field on comments page</i></p>
<p style="position: relative; font-family: monospace; background-color: #fff; margin: 0; padding: 0.5em; word-break: break-all; text-align: left; border-radius: 0.2em; -webkit-user-select: all; user-select: all;">{{.Token}}</p>
</div>
<p style="position: relative; margin-top: 2em; font-size: 0.8em; opacity: 0.8;"><i>Sent to {{.Address}}</i></p>
@@ -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,
+9 -3
View File
@@ -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)
+168 -120
View File
@@ -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}}
↦ <a href="{{.Link}}">{{if .PostTitle}}{{.PostTitle}}{{else}}original comment{{end}}</a>
defaultEmailTemplate = `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<style>img {max-width: 100%; max-height: 300px;} a {color: #4fbbd6;}</style>
</head>
<body>
<div style="font-family: Arial, sans-serif; font-size: 18px;">
<h1 style="text-align: center; position: relative; color: #4fbbd6; margin-top: 0.2em;">Remark42</h1>
<div style="background-color: #eee; width: 90%; max-width: 800px; margin: 0 auto; border-radius: 0.4em; padding: 0.5em;">
<p style="margin: 0 0 0.5em 0; color: #444444;"><b><a href="{{.CommentLink}}" style="color: #4fbbd6 !important;">New reply</a> from {{.CommentUser}} on your comment{{if .PostTitle}} to "{{.PostTitle}}"{{end}}</b></p>
<div style="background-color: #fff; margin: 0; padding: 0.5em; word-break: break-all; border-radius: 0.2em;">{{.CommentText}}</div>
<p style="text-align: center; position: relative; margin: 0.5em 0 0 0; font-size: 0.8em; opacity: 0.8;"><i>Sent to <a style="color:inherit !important; text-decoration: none !important;" href="mailto:{{.Email}}">{{.Email}}</a> for {{.ParentUser}}</i><br/><br/><a style="color: #4fbbd6 !important;" href="{{.UnsubscribeLink}}">Unsubscribe</a></p>
</div>
</div>
</body>
</html>
`
defaultEmailVerificationTemplate = `Confirmation for {{.User}} {{.Email}}, site {{.Site}}
Token: {{.Token}}
defaultEmailVerificationTemplate = `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<div style="text-align: center; font-family: Arial, sans-serif; font-size: 18px;">
<h1 style="position: relative; color: #4fbbd6; margin-top: 0.2em;">Remark42</h1>
<p style="position: relative; max-width: 20em; margin: 0 auto 1em auto; line-height: 1.4em;">Confirmation for <b>{{.User}}</b> on site <b>{{.Site}}</b></p>
<div style="background-color: #eee; max-width: 20em; margin: 0 auto; border-radius: 0.4em; padding: 0.5em;">
<p style="position: relative; margin: 0 0 0.5em 0;">TOKEN</p>
<p style="position: relative; font-size: 0.7em; opacity: 0.8;"><i>Copy and paste this text into “token” field on comments page</i></p>
<p style="position: relative; font-family: monospace; background-color: #fff; margin: 0; padding: 0.5em; word-break: break-all; text-align: left; border-radius: 0.2em; -webkit-user-select: all; user-select: all;">{{.Token}}</p>
</div>
<p style="position: relative; margin-top: 2em; font-size: 0.8em; opacity: 0.8;"><i>Sent to {{.Email}}</i></p>
</div>
</body>
</html>
`
)
// 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)
}
+82 -70
View File
@@ -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
↦ <a href="#remark42__comment-999">test_title</a>
`
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: <https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token>
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
}
+5
View File
@@ -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 {
+40
View File
@@ -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) }
+22 -51
View File
@@ -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")
}
+6
View File
@@ -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
+207
View File
@@ -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")
+188
View File
@@ -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 := &notify.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()
+3 -1
View File
@@ -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
+42
View File
@@ -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) {
+30 -15
View File
@@ -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
+6
View File
@@ -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
+4 -4
View File
@@ -339,8 +339,8 @@
1. One<br>
2. Two<br>
3. Three<br>
&nbsp;&nbsp;3.1. Item 3.1<br>
&nbsp;&nbsp;3.2. Item 3.2<br>
3.1. Item 3.1<br>
3.2. Item 3.2<br>
</p>
<p>If you want bullet point</p>
@@ -348,8 +348,8 @@
<p>
* Item 1<br>
* Item 2<br>
&nbsp;&nbsp;* Item 2a<br>
&nbsp;&nbsp;* Item 2b<br>
* Item 2a<br>
* Item 2b<br>
</p>
<h2 id="Images">Images</h2>