Merge branch 'master' of github.com:umputun/remark into small-improvements

This commit is contained in:
Smolevich
2020-04-09 21:50:44 +03:00
36 changed files with 2466 additions and 4548 deletions
+24 -22
View File
@@ -29,33 +29,35 @@ jobs:
- name: install go
uses: actions/setup-go@v1
with:
go-version: 1.14
- name: test backend
run: |
date
cd backend/app
go test -mod=vendor -timeout=60s -covermode=count -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./...
cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "_mock.go" > $GITHUB_WORKSPACE/profile.cov
cd ../_example/memory_store
go test -race ./...
env:
TZ: "America/Chicago"
go-version: 1.13
- name: install golangci-lint and goveralls
run: |
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $GITHUB_WORKSPACE v1.20.0
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $GITHUB_WORKSPACE v1.23.0
go get -u github.com/mattn/goveralls
- name: run backend linters
run: $GITHUB_WORKSPACE/golangci-lint run --config .golangci.yml ./...
working-directory: backend
- name: test and lint backend
run: |
go test -timeout=60s -covermode=count -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./...
cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "_mock.go" > $GITHUB_WORKSPACE/profile.cov
$GITHUB_WORKSPACE/golangci-lint --config ${GITHUB_WORKSPACE}/backend/.golangci.yml run ./...
working-directory: backend/app
env:
GOFLAGS: "-mod=vendor"
TZ: "America/Chicago"
- name: run linters for examples
run: $GITHUB_WORKSPACE/golangci-lint run --config ${GITHUB_WORKSPACE}/backend/.golangci.yml ./...
- name: test and lint examples
run: |
go version
$GITHUB_WORKSPACE/golangci-lint version
go test -race ./...
$GITHUB_WORKSPACE/golangci-lint --config ${GITHUB_WORKSPACE}/backend/.golangci.yml run ./...
working-directory: backend/_example/memory_store
env:
TZ: "America/Chicago"
- name: submit coverage
uses: shogo82148/actions-goveralls@v1
with:
path-to-profile: ${{ github.workspace }}/profile.cov
working-directory: backend
run: $(go env GOPATH)/bin/goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov
working-directory: backend
env:
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018 Umputun
Copyright (c) 2020 Umputun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+7 -5
View File
@@ -155,6 +155,7 @@ _this is the recommended way to run remark42_
| notify.telegram.timeout | NOTIFY_TELEGRAM_TIMEOUT | `5s` | telegram timeout |
| notify.email.fromAddress | NOTIFY_EMAIL_FROM | | from email address |
| notify.email.verification_subj | NOTIFY_EMAIL_VERIFICATION_SUBJ | `Email verification` | verification message subject |
| notify.email.notify_admin | NOTIFY_EMAIL_ADMIN | `false` | notify admin on new comments via ADMIN_SHARED_EMAIL |
| smtp.host | SMTP_HOST | | SMTP host |
| smtp.port | SMTP_PORT | | SMTP port |
| smtp.username | SMTP_USERNAME | | SMTP user name |
@@ -472,9 +473,10 @@ window.REMARK42.changeTheme('light');
##### Locales
Right now Remark has support three locales en, ru (partial translated), de and fi.
You can pick one using configuration object.
Do you want support other locale? Please create [issue](https://github.com/umputun/remark42/issues).
Right now Remark is translated to en, ru (partially), de, and fi languages.
You can pick one using [configuration object](#setup-on-your-website).
Do you want translate remark42 to other locale? Please see [this documentation](https://github.com/umputun/remark42/blob/master/docs/translation.md) for details.
#### Last comments
@@ -587,8 +589,8 @@ It stars backend service with embedded bolt store on port `8080` with basic auth
#### Build
* install [Node.js 8](https://nodejs.org/en/) or higher;
* install [NPM 6.1.0](https://www.npmjs.com/package/npm);
* install [Node.js 12.11](https://nodejs.org/en/) or higher;
* install [NPM 6.13.4](https://www.npmjs.com/package/npm);
* run `npm install` inside `./frontend`;
* run `npm run build` there;
* result files will be saved in `./frontend/public`.
+6
View File
@@ -200,6 +200,7 @@ type NotifyGroup struct {
Email struct {
From string `long:"fromAddress" env:"FROM" description:"from email address"`
VerificationSubject string `long:"verification_subj" env:"VERIFICATION_SUBJ" description:"verification message subject"`
AdminNotifications bool `long:"notify_admin" env:"ADMIN" description:"notify admin on new comments via ADMIN_SHARED_EMAIL"`
} `group:"email" namespace:"email" env-namespace:"EMAIL"`
}
@@ -437,6 +438,11 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) {
SimpleView: s.SimpleView,
}
// enable admin notifications only if admin email is set
if s.Notify.Email.AdminNotifications && s.Admin.Shared.Email != "" {
srv.AdminEmail = s.Admin.Shared.Email
}
srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = s.LowScore, s.CriticalScore
var devAuth *provider.DevAuthServer
+47 -26
View File
@@ -88,6 +88,7 @@ type msgTmplData struct {
PostTitle string
Email string
UnsubscribeLink string
ForAdmin bool
}
// verifyTmplData store data for verification message template execution
@@ -133,8 +134,13 @@ const (
<body>
<div style="font-family: Helvetica, Arial, sans-serif; font-size: 18px; width: 100%; max-width: 640px; margin: auto;">
<h1 style="text-align: center; position: relative; color: #4fbbd6; margin-top: 10px; margin-bottom: 10px;">Remark42</h1>
{{- if .ForAdmin}}
<div style="font-size: 16px; text-align: center; margin-bottom: 10px; color:#000!important;">New comment from {{.UserName}} on your site {{if .PostTitle}} to «{{.PostTitle}}»{{ end }}</div>
{{- else }}
<div style="font-size: 16px; text-align: center; margin-bottom: 10px; color:#000!important;">New reply from {{.UserName}} on your comment{{if .PostTitle}} to «{{.PostTitle}}»{{ end }}</div>
{{- end }}
<div style="background-color: #eee; padding: 15px 20px 20px 20px; border-radius: 3px;">
{{- if .ParentCommentText}}
<div style="margin-bottom: 12px; line-height: 24px; word-break: break-all;">
<img src="{{.ParentUserPicture}}" style="width: 24px; height: 24px; display: inline; vertical-align: middle; margin: 0 8px 0 0; border-radius: 3px; background-color: #ccc;"/>
<span style="font-size: 14px; font-weight: bold; color: #777">{{.ParentUserName}}</span>
@@ -144,6 +150,7 @@ const (
<div style="font-size: 14px; color:#333!important; padding: 0 14px 0 2px; border-radius: 3px; line-height: 1.4;">
{{.ParentCommentText}}
</div>
{{- end }}
<div style="padding-left: 20px; border-left: 1px dotted rgba(0,0,0,0.15); margin-top: 15px; padding-top: 5px;">
<div style="margin-bottom: 12px;" line-height: 24px;word-break: break-all;>
<img src="{{.UserPicture}}" style="width: 24px; height: 24px; display:inline; vertical-align:middle; margin: 0 8px 0 0; border-radius: 3px; background-color: #ccc;"/>
@@ -155,9 +162,11 @@ const (
</div>
</div>
<div style="text-align: center; font-size: 14px; margin-top: 32px;">
<i style="color: #000!important;">Sent to <a style="color:inherit; text-decoration: none" href="mailto:{{.Email}}">{{.Email}}</a> for {{.ParentUserName}}</i>
<i style="color: #000!important;">Sent to <a style="color:inherit; text-decoration: none" href="mailto:{{.Email}}">{{.Email}}</a>{{if not .ForAdmin}} for {{.ParentUserName}}{{ end }}</i>
<div style="margin: auto; width: 150px; border-top: 1px solid rgba(0, 0, 0, 0.15); padding-top: 15px; margin-top: 15px;"></div>
{{- if .UnsubscribeLink}}
<a style="color: #0aa;" href="{{.UnsubscribeLink}}">Unsubscribe</a>
{{- end }}
<!-- This is hack for remove collapser in Gmail which can collapse end of the message -->
<div style="opacity: 0;font-size: 1;">[{{.CommentDate.Format "02.01.2006 at 15:04"}}]</div>
</div>
@@ -176,10 +185,10 @@ const (
<div style="text-align: center; font-family: Helvetica, 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; color:#000!important;">Confirmation for <b>{{.User}}</b> on site <b>{{.Site}}</b></p>
{{if .SubscribeURL}}
{{- if .SubscribeURL}}
<p style="position: relative; margin: 0 0 0.5em 0;color:#000!important;"><a href="{{.SubscribeURL}}{{.Token}}">Click here to subscribe to email notifications</a></p>
<p style="position: relative; margin: 0 0 0.5em 0;color:#000!important;">Alternatively, you can use code below for subscription.</p>
{{ end }}
{{- end }}
<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;color:#000!important;">TOKEN</p>
<p style="position: relative; font-size: 0.7em; opacity: 0.8;"><i style="color:#000!important;">Copy and paste this text into “token” field on comments page</i></p>
@@ -227,10 +236,9 @@ func NewEmail(emailParams EmailParams, smtpParams SmtpParams) (*Email, error) {
return &res, err
}
// Send email about reply to Request.Email if it's set, otherwise do nothing and return nil, thread safe
// do not returns sending error, only following:
// 1. (likely impossible) template execution error from email message creation from Request
// 2. message dropped without sending in case of closed ctx
// Send email about comment reply to Request.Email if it's set,
// also sends email to site administrator if appropriate option is set.
// Thread safe
func (e *Email) Send(ctx context.Context, req Request) (err error) {
if req.Email == "" {
// this means we can't send this request via Email
@@ -252,12 +260,12 @@ func (e *Email) Send(ctx context.Context, req Request) (err error) {
}
if req.Comment.ID != "" {
if req.parent.User.ID == req.Comment.User.ID {
if req.parent.User.ID == req.Comment.User.ID && !req.ForAdmin {
// 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)
msg, err = e.buildMessageFromRequest(req, req.ForAdmin)
if err != nil {
return err
}
@@ -288,33 +296,46 @@ func (e *Email) buildVerificationMessage(user, email, token, site string) (strin
}
// buildMessageFromRequest generates email message based on Request using e.MsgTemplate
func (e *Email) buildMessageFromRequest(req Request) (string, error) {
func (e *Email) buildMessageFromRequest(req Request, forAdmin bool) (string, error) {
subject := "New reply to your comment"
if forAdmin {
subject = "New comment to your site"
}
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")
}
unsubscribeLink := e.UnsubscribeURL + "?site=" + req.Comment.Locator.SiteID + "&tkn=" + token
if forAdmin {
unsubscribeLink = ""
}
commentUrlPrefix := req.Comment.Locator.URL + uiNav
msg := bytes.Buffer{}
err = e.msgTmpl.Execute(&msg, msgTmplData{
UserName: req.Comment.User.Name,
UserPicture: req.Comment.User.Picture,
CommentText: req.Comment.Text,
CommentLink: commentUrlPrefix + req.Comment.ID,
CommentDate: req.Comment.Timestamp,
ParentUserName: req.parent.User.Name,
ParentUserPicture: req.parent.User.Picture,
ParentCommentText: req.parent.Text,
ParentCommentLink: commentUrlPrefix + req.parent.ID,
ParentCommentDate: req.parent.Timestamp,
PostTitle: req.Comment.PostTitle,
Email: req.Email,
UnsubscribeLink: unsubscribeLink,
})
tmplData := msgTmplData{
UserName: req.Comment.User.Name,
UserPicture: req.Comment.User.Picture,
CommentText: req.Comment.Text,
CommentLink: commentUrlPrefix + req.Comment.ID,
CommentDate: req.Comment.Timestamp,
PostTitle: req.Comment.PostTitle,
Email: req.Email,
UnsubscribeLink: unsubscribeLink,
ForAdmin: forAdmin,
}
// in case of message to admin, parent message might be empty
if req.Comment.ParentID != "" {
tmplData.ParentUserName = req.parent.User.Name
tmplData.ParentUserPicture = req.parent.User.Picture
tmplData.ParentCommentText = req.parent.Text
tmplData.ParentCommentLink = commentUrlPrefix + req.parent.ID
tmplData.ParentCommentDate = req.parent.Timestamp
}
err = e.msgTmpl.Execute(&msg, tmplData)
if err != nil {
return "", errors.Wrapf(err, "error executing template to build comment reply message")
}
+19 -2
View File
@@ -187,7 +187,7 @@ func TestEmail_Send(t *testing.T) {
email.TokenGenFn = TokenGenFn
email.UnsubscribeURL = "https://remark42.com/api/v1/email/unsubscribe"
req := Request{
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, PostTitle: "test_title"},
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, ParentID: "1", PostTitle: "test_title"},
parent: store.Comment{ID: "1", User: store.User{ID: "999", Name: "parent_user"}},
Email: "test@example.org",
}
@@ -196,7 +196,7 @@ func TestEmail_Send(t *testing.T) {
assert.Equal(t, 1, fakeSmtp.readQuitCount())
assert.Equal(t, "test@example.org", fakeSmtp.readRcpt())
// test buildMessageFromRequest separately for message text
res, err := email.buildMessageFromRequest(req)
res, err := email.buildMessageFromRequest(req, req.ForAdmin)
assert.NoError(t, err)
assert.Contains(t, res, `From: from@example.org
To: test@example.org
@@ -206,6 +206,23 @@ 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: `)
// send email to admin without parent set
req = Request{
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, PostTitle: "test_title"},
Email: "admin@example.org",
ForAdmin: true,
}
assert.NoError(t, email.Send(context.TODO(), req))
res, err = email.buildMessageFromRequest(req, req.ForAdmin)
assert.NoError(t, err)
assert.Contains(t, res, `From: from@example.org
To: admin@example.org
Subject: New comment to your site for "test_title"
Content-Transfer-Encoding: quoted-printable
MIME-version: 1.0
Content-Type: text/html; charset="UTF-8"
Date: `)
}
+12 -6
View File
@@ -37,9 +37,11 @@ type Store interface {
// Request notification either about comment or about particular user verification
type Request struct {
Comment store.Comment // if set sent notifications about new comment
parent store.Comment // fetched only in case Comment is set
Email string // if set (also) send email
Comment store.Comment // if set sent notifications about new comment
parent store.Comment // fetched only in case Comment is set
Email string // if set (also) send email
ForAdmin bool // if set, message supposed to be sent to administrator
Verification VerificationMetadata // if set sent verification notification
}
@@ -82,9 +84,13 @@ 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.SiteID, p.User.ID)
if err != nil {
log.Printf("[WARN] can't read email for %s, %v", p.User.ID, err)
// user notification, should fetch email for it.
// administrator notification comes with pre-set email
if req.Email == "" {
req.Email, err = s.dataService.GetUserEmail(req.Comment.Locator.SiteID, p.User.ID)
if err != nil {
log.Printf("[WARN] can't read email for %s, %v", p.User.ID, err)
}
}
}
}
+5
View File
@@ -90,6 +90,11 @@ func (t *Telegram) Send(ctx context.Context, req Request) error {
// verification request received, send nothing
return nil
}
if req.ForAdmin {
// request for administrator received, do nothing with it
// as we already sent message on request without this flag set
return nil
}
client := http.Client{Timeout: telegramTimeOut}
log.Printf("[DEBUG] send telegram notification to %s, comment id %s", t.channelID, req.Comment.ID)
+2
View File
@@ -50,6 +50,7 @@ type Rest struct {
AnonVote bool
WebRoot string
RemarkURL string
AdminEmail string
ReadOnlyAge int
SharedSecret string
ScoreThresholds struct {
@@ -364,6 +365,7 @@ func (s *Rest) controllerGroups() (public, private, admin, rss) {
authenticator: s.Authenticator,
notifyService: s.NotifyService,
remarkURL: s.RemarkURL,
adminEmail: s.AdminEmail,
anonVote: s.AnonVote,
}
+6
View File
@@ -39,6 +39,7 @@ type private struct {
notifyService *notify.Service
authenticator *auth.Service
remarkURL string
adminEmail string
anonVote bool
}
@@ -131,9 +132,14 @@ func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
s.cache.Flush(cache.Flusher(comment.Locator.SiteID).
Scopes(comment.Locator.URL, lastCommentsScope, comment.User.ID, comment.Locator.SiteID))
// user notification
if s.notifyService != nil {
s.notifyService.Submit(notify.Request{Comment: finalComment})
}
// admin notification
if s.notifyService != nil && s.adminEmail != "" {
s.notifyService.Submit(notify.Request{Comment: finalComment, Email: s.adminEmail, ForAdmin: true})
}
log.Printf("[DEBUG] created commend %+v", finalComment)
+20 -18
View File
@@ -596,11 +596,12 @@ func TestRest_EmailNotification(t *testing.T) {
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)
time.Sleep(time.Millisecond * 30)
require.Equal(t, 2, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[0].Email)
assert.Equal(t, "admin@example.org", mockDestination.Get()[1].Email)
// create child comment from another user, no email notification expected
// create child comment from another user, email notification only to admin expected
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
`{"text": "test 456",
"pid": "%s",
@@ -615,9 +616,10 @@ func TestRest_EmailNotification(t *testing.T) {
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)
time.Sleep(time.Millisecond * 30)
require.Equal(t, 4, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[2].Email)
assert.Equal(t, "admin@example.org", mockDestination.Get()[3].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)
@@ -629,10 +631,10 @@ func TestRest_EmailNotification(t *testing.T) {
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
time.Sleep(time.Millisecond * 30)
require.Equal(t, 5, len(mockDestination.Get()))
require.NotEmpty(t, mockDestination.Get()[4].Verification)
verificationToken := mockDestination.Get()[4].Verification.Token
// verify email
req, err = http.NewRequest(http.MethodPost, ts.URL+fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", verificationToken), nil)
@@ -674,9 +676,9 @@ func TestRest_EmailNotification(t *testing.T) {
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)
time.Sleep(time.Millisecond * 30)
require.Equal(t, 7, len(mockDestination.Get()))
assert.Equal(t, "good@example.com", mockDestination.Get()[5].Email)
// delete user's email
req, err = http.NewRequest(http.MethodDelete, ts.URL+"/api/v1/email?site=remark42", nil)
@@ -688,7 +690,7 @@ func TestRest_EmailNotification(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode, string(body))
// create child comment from another user, no email notification expected
// create child comment from another user, no email notification expected except for admin
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
`{"text": "test 321",
"user": {"name": "other_user"},
@@ -702,9 +704,9 @@ func TestRest_EmailNotification(t *testing.T) {
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)
time.Sleep(time.Millisecond * 30)
require.Equal(t, 9, len(mockDestination.Get()))
assert.Empty(t, mockDestination.Get()[7].Email)
}
func TestRest_UserAllData(t *testing.T) {
+4 -3
View File
@@ -368,9 +368,10 @@ func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) {
SecretReader: token.SecretFunc(func() (string, error) { return "secret", nil }),
AvatarStore: avatar.NewLocalFS(tmp + "/ava-remark42"),
}),
Cache: memCache,
WebRoot: tmp,
RemarkURL: "https://demo.remark42.com",
Cache: memCache,
WebRoot: tmp,
RemarkURL: "https://demo.remark42.com",
AdminEmail: "admin@example.org",
ImageService: image.NewService(&image.FileSystem{
Location: tmp + "/pics-remark42",
Partitions: 100,
+2
View File
@@ -45,6 +45,8 @@ services:
- NOTIFY_TELEGRAM_TOKEN
- NOTIFY_TELEGRAM_CHAN
- NOTIFY_EMAIL_FROM
- ADMIN_SHARED_EMAIL
- NOTIFY_EMAIL_ADMIN
- SMTP_HOST
- SMTP_USERNAME
- SMTP_PASSWORD
+1
View File
@@ -4,3 +4,4 @@
- [How to configure remark42 without a subdomain](subdomain.md) with Nginx or Caddy
- [Telegram notifications](telegram.md)
- [Setup email authentication and\or email notifications](email.md)
- [How to add new translation to remark42](translation.md)
+3
View File
@@ -101,6 +101,9 @@ Here is the list of variables which affect email notifications:
NOTIFY_TYPE
NOTIFY_EMAIL_FROM
NOTIFY_EMAIL_VERIFICATION_SUBJ
# for administrator notifications for new comments on their site
ADMIN_SHARED_EMAIL
NOTIFY_EMAIL_ADMIN
```
After `SMTP_` variables are set, you can allow email notifications by setting these two variables:
+49
View File
@@ -0,0 +1,49 @@
## How to add new language translation to Remark42
Translation files are stored in [/frontend/app/locales](https://github.com/umputun/remark42/tree/master/frontend/app/locales)
directory with `.json` extension and content like following:
```json
{
"anonymousLoginForm.length-limit": "Username must be at least 3 characters long",
"anonymousLoginForm.log-in": "Log in",
"anonymousLoginForm.symbol-limit": "Username must start from the letter and contain only latin letters, numbers, underscores, and spaces",
<...>
}
```
### How to add a new translation
We truly appreciate people spending time contributing their translations to remark42. Please go through the steps
below in order to have your translation start being available to all remark42 users and included in the next release.
1. create a fork of [umputun/remark42](https://github.com/umputun/remark42) repo, and if you already have one please
pull the latest changes from the upstream master branch. It could be done like that:
```shell
git remote add upstream https://github.com/umputun/remark42.git
git fetch upstream
git rebase upstream/master
git push
```
1. add new locale with [two-letter code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)
of the language you want to make the translation into to list in
[frontend/tasks/supportedLocales.json](https://github.com/umputun/remark42/blob/master/frontend/tasks/supportedLocales.json)
1. run `npm run generate-langs` in `frontend` folder
1. translate all values in the newly created json file in
[frontend/app/locales/](https://github.com/umputun/remark42/blob/master/frontend/app/locales/)
1. commit all changes above in your fork
1. test your changes in the interface:
1. uncomment `locale: "ru"` line in [frontend/index.ejs](https://github.com/umputun/remark42/blob/master/frontend/index.ejs#L133)
and replace `ru` with your translation language code
1. [run remark42 in Docker](https://github.com/umputun/remark42#development) by issuing following commands
from the root directory of your remark42 fork:
```shell
docker-compose -f compose-dev-frontend.yml build
docker-compose -f compose-dev-frontend.yml up
```
1. open [http://127.0.0.1:8080](http://127.0.0.1:8080), log in, make a comment, make a reply to a comment,
and make sure that your translation looks as you expect it to look
1. make a screenshot from [http://127.0.0.1:8080](http://127.0.0.1:8080) with your translation in place
1. after all previous steps are done, create a [Pull Request](https://github.com/umputun/remark42/pulls) to umputun/remark42
repo with your changes, attaching a screenshot or two from your local test instance to it
+2 -5
View File
@@ -28,12 +28,9 @@
- tests are running on push attempt
- example tests can be found in `./app/store/user/reducers.test.ts`, `./app/components/auth-panel/auth-panel.test.tsx`
### how to add new locale.
### How to add new locale
- add new item to `./tasks/supportedLocales.json`
- run `npm run generate-langs`
- commit all changed files
- translate all string in new generated dictionary `./app/locale/<new-locale>.json`
Please see [this documentation](https://github.com/umputun/remark42/blob/master/docs/translation.md).
### Notes
+40
View File
@@ -0,0 +1,40 @@
import jestFetchMock from 'jest-fetch-mock';
import { emailVerificationForSubscribe } from './api';
jest.mock('@app/common/constants', () => ({
BASE_URL: 'https://example.com',
API_BASE: '/api',
}));
jest.mock('@app/common/settings', () => ({
siteId: 'remark42',
}));
describe('api', () => {
beforeAll(() => {
jestFetchMock.enableMocks();
});
afterAll(() => {
jestFetchMock.disableMocks();
});
beforeEach(() => {
jestFetchMock.resetMocks();
});
it('should send request with encoded email', async () => {
await emailVerificationForSubscribe("address.!#$%&'*+-/=?^_`{|}~(),:;<>[\\]@example.com");
expect(jestFetchMock.mock.calls.length).toEqual(1);
const url = jestFetchMock.mock.calls[0][0] as string;
const match = url.match(/address=(\S+)$/);
expect(match).toBeArray();
expect((match as string[]).length).toBeGreaterThan(1);
expect((match as string[])[1]).toBe(
"address.!%23%24%25%26'*%2B-%2F%3D%3F%5E_%60%7B%7C%7D~()%2C%3A%3B%3C%3E%5B%5C%5D%40example.com"
);
});
});
+1 -1
View File
@@ -277,7 +277,7 @@ export const uploadImage = (image: File): Promise<Image> => {
*/
export const emailVerificationForSubscribe = (emailAddress: string) =>
fetcher.post({
url: `/email/subscribe?site=${siteId}&address=${emailAddress}`,
url: `/email/subscribe?site=${siteId}&address=${encodeURIComponent(emailAddress)}`,
withCredentials: true,
});
@@ -133,7 +133,7 @@ export class AuthPanel extends Component<Props, State> {
href={`${window.location.origin}/web/comments.html${window.location.search}`}
target="_blank"
>
new page
<FormattedMessage id="authPanel.new-page" defaultMessage="new page" />
</a>
</div>
);
@@ -26,8 +26,7 @@ export const messages = defineMessages({
},
symbolLimit: {
id: 'anonymousLoginForm.symbol-limit',
defaultMessage:
'Username must start from the letter and contain only latin letters, numbers, underscores, and spaces',
defaultMessage: 'Username must start with a letter and contain only latin letters, numbers, underscores, or spaces',
},
userName: {
id: 'anonymousLoginForm.user-name',
@@ -26,7 +26,7 @@ function simulateInput(input: ReactWrapper, value: string) {
describe('EmailLoginForm', () => {
const testUser = ({} as any) as User;
const onSuccess = jest.fn(async () => {});
const onSuccess = jest.fn(async () => undefined);
const onSignIn = jest.fn(async () => testUser);
beforeEach(() => {
@@ -105,7 +105,7 @@ const renderTokenPart = (
setEmailStep: () => void
) => (
<Fragment>
<Button kind="link" mix="auth-panel-email-login-form__back-button" {...getHandleClickProps(setEmailStep)}>
<Button kind="link" mix="auth-email-login-form__back-button" {...getHandleClickProps(setEmailStep)}>
<FormattedMessage id="subscribeByEmail.back" defaultMessage="Back" />
</Button>
<TextareaAutosize
@@ -88,7 +88,7 @@ export const TextExpander: FunctionalComponent = ({ children }) => {
expander.removeEventListener('text-expander-value', textExpanderValueListener);
};
}
return () => {};
return () => undefined;
}, [theme]);
if (StaticStore.config.emoji_enabled) {
return <text-expander ref={expanderRef}>{children}</text-expander>;
@@ -146,7 +146,7 @@ describe('<Comment />', () => {
});
it('disabled for already upvoted comment', async () => {
const voteSpy = jest.fn(async () => {});
const voteSpy = jest.fn(async () => undefined);
const element = mount(
<Comment
{...(DefaultProps as Props)}
@@ -180,7 +180,7 @@ describe('<Comment />', () => {
}, 30000);
it('disabled for already downvoted comment', async () => {
const voteSpy = jest.fn(async () => {});
const voteSpy = jest.fn(async () => undefined);
const element = mount(
<Comment
{...(DefaultProps as Props)}
+2 -1
View File
@@ -12,6 +12,7 @@
"authPanel.logged-as": "Du hast dich angemeldet als",
"authPanel.login": "Anmelden:",
"authPanel.logout": "Abmelden?",
"authPanel.new-page": "neue Seite",
"authPanel.or-provider": "oder",
"authPanel.other-provider": "Andere",
"authPanel.read-only": "Nur-Lesen",
@@ -52,7 +53,7 @@
"comment.verified-user": "Bestätigter Benutzer",
"comment.verify-user": "Möchtest du den Benutzer {userName} wirklich bestätigen?",
"comment.vote-error": "Fehler beim Abstimmen: {voteErrorMessage}",
"commentForm.anonymous-uploading-disabled": "Image uploading is disabled for unauthorized users. You should login before uploading.",
"commentForm.anonymous-uploading-disabled": "Der Upload von Bildern ist nur für registrierte Benutzer möglich. Bitte melde dich zuvor an.",
"commentForm.exceeded-size": "Die Datei {fileName} überschreitet das Größenlimit von {maxImageSize}",
"commentForm.input-placeholder": "Gib hier deinen Kommentar ein",
"commentForm.new-comment": "Neuer Kommentar",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"anonymousLoginForm.length-limit": "Username must be at least 3 characters long",
"anonymousLoginForm.log-in": "Log in",
"anonymousLoginForm.symbol-limit": "Username must start from the letter and contain only latin letters, numbers, underscores, and spaces",
"anonymousLoginForm.symbol-limit": "Username must start with a letter and contain only latin letters, numbers, underscores, or spaces",
"anonymousLoginForm.user-name": "Username",
"authPanel.anonymous-provider": "Anonymous",
"authPanel.disable-comments": "Disable comments",
@@ -12,6 +12,7 @@
"authPanel.logged-as": "You logged in as",
"authPanel.login": "Login:",
"authPanel.logout": "Logout?",
"authPanel.new-page": "new page",
"authPanel.or-provider": "or",
"authPanel.other-provider": "Other",
"authPanel.read-only": "Read-only",
+169
View File
@@ -0,0 +1,169 @@
{
"anonymousLoginForm.length-limit": "El nombre de usuario debe ser de al menos 3 caracteres de largo",
"anonymousLoginForm.log-in": "Acceder",
"anonymousLoginForm.symbol-limit": "El nombre de usuario debe comenzar con una letra y contener solamente letras latinas, números, guión bajo o espacio",
"anonymousLoginForm.user-name": "Nombre de usuario",
"authPanel.anonymous-provider": "Anónimo",
"authPanel.disable-comments": "Deshabilitar comentarios",
"authPanel.disabled-cookies": "Deshabilita las cookies de terceros que bloquean el acceso o abre los comentarios en una",
"authPanel.enable-comments": "Habilitar comentarios",
"authPanel.enable-cookies": "Habilitar cookies para acceder y comentar",
"authPanel.hide-settings": "Ocultar opciones",
"authPanel.logged-as": "Accediste como",
"authPanel.login": "Acceder:",
"authPanel.logout": "¿Salir?",
"authPanel.new-page": "nueva página",
"authPanel.or-provider": "o",
"authPanel.other-provider": "Otro",
"authPanel.read-only": "Solo lectura",
"authPanel.request-to-delete-data": "Solicitar la eliminación de mis datos",
"authPanel.show-settings": "Mostrar opciones",
"blockingDuration.day": "Por un día",
"blockingDuration.month": "Por un mes",
"blockingDuration.permanently": "Permanentemente",
"blockingDuration.week": "Por una semana",
"comment.block": "Bloquear",
"comment.block-user": "¿Quieres bloquear a {userName} {duration}?",
"comment.blocked-user": "Bloqueado",
"comment.blocking-period": "Período de bloqueo",
"comment.cancel": "Cancelar",
"comment.controversy": "Controversia: {value}",
"comment.copied": "¡Copiado!",
"comment.copy": "Copiar",
"comment.delete": "Eliminar",
"comment.delete-message": "¿Quieres eliminar este comentario?",
"comment.deleted-comment": "Este comentario fue eliminado",
"comment.deleted-user": "Eliminado",
"comment.edit": "Editar",
"comment.expired-time": "El tiempo de edición ha expirado.",
"comment.go-to-parent": "Ir al comentario padre",
"comment.hide": "Ocultar",
"comment.hide-user-comment": "¿Quieres ocultar los comentarios de {userName}?",
"comment.pin": "Anclar",
"comment.pin-comment": "¿Quieres anclar este comentario?",
"comment.reply": "Responder",
"comment.time": "{day} a las {time}",
"comment.toggle-verification": "Alternar verificación",
"comment.unblock": "Desbloquear",
"comment.unblock-user": "¿Quieres desbloquear a este usuario?",
"comment.unpin": "Desanclar",
"comment.unpin-comment": "¿Quieres desanclar este comentario?",
"comment.unverified-user": "Usuario no verificado",
"comment.unverify-user": "¿Quieres quitar la verificación a {userName}?",
"comment.verified-user": "Usuario verificado",
"comment.verify-user": "¿Quieres verificar a {userName}?",
"comment.vote-error": "Error al votar: {voteErrorMessage}",
"commentForm.anonymous-uploading-disabled": "La subida de imágenes está deshabilitada para usuarios no autenticados. Deberías acceder antes de subir imágenes.",
"commentForm.exceeded-size": "{fileName} excede el tamaño máximo de {maxImageSize}",
"commentForm.input-placeholder": "Tu comentario aquí",
"commentForm.new-comment": "Nuevo comentario",
"commentForm.notice-about-styling": "El formato con <a>Markdown</a> está soportado",
"commentForm.preview": "Pre-visualizar",
"commentForm.reply": "Responder",
"commentForm.save": "Guardar",
"commentForm.send": "Enviar",
"commentForm.subscribe-by": "Suscribirse por",
"commentForm.subscribe-or": "o",
"commentForm.unexpected-error": "Algo salió mal. Por favor vuelve a intentar más tarde.",
"commentForm.upload-file-fail": "la subida de {fileName} falló con \"{errorMessage}\"",
"commentForm.uploading": "Subiendo...",
"commentForm.uploading-file": "subiendo {fileName}...",
"commentSort.sort-by": "Ordernar por",
"commentsSort.best": "Mejor",
"commentsSort.least-controversial": "Menos controversial",
"commentsSort.least-recently-updated": "Actualizado menos recientemente",
"commentsSort.most-controversial": "Más controversial",
"commentsSort.newest": "Más nuevo",
"commentsSort.oldest": "Más antiguo",
"commentsSort.recently-updated": "Actualizado más recientemente",
"commentsSort.worst": "Peor",
"emailLoginForm.back": "Volver",
"emailLoginForm.confirm": "Confirmar",
"emailLoginForm.email-address": "Dirección de correo electrónico",
"emailLoginForm.empty-token": "El campo de token no debe ser vacío",
"emailLoginForm.expired-token": "El token ha expirado",
"emailLoginForm.invalid-email": "La dirección de correo electrónica no es válida",
"emailLoginForm.loading": "Cargando...",
"emailLoginForm.send-verification": "Enviar verificación",
"emailLoginForm.token": "Token",
"emailLoginForm.user-not-found": "No se encontró el usuario",
"errors.0": "Algo salió mal. Por favor vuelve a intentar más tarde.",
"errors.1": "No se ha encontrado el comentario. Por favor refresca la página y vuelve a intentar.",
"errors.10": "Es muy tarde para editar el comentario.",
"errors.11": "El comentario ya tiene una respuesta. No es posible editarlo.",
"errors.12": "No se ha podido guardar el resultado del voto. Por favor vuelve a intentar más tarde.",
"errors.13": "No puedes votar tu propio comentario.",
"errors.14": "Ya has votado el comentario.",
"errors.15": "Demasiados votos para el comentario.",
"errors.16": "Ya se ha alcanzado el puntaje mínimo para el comentario.",
"errors.17": "Acción rechazada. Por favor vuelve a intentar más tarde.",
"errors.18": "No se ha encontrado el archivo solicitado.",
"errors.2": "No se ha podido deserializar la petición entrante.",
"errors.3": "No tienes permisos para esta operación.",
"errors.4": "Datos de comentario inválidos.",
"errors.5": "El comentario no se ha encontrado. Por favor refresca la página y vuelve a intentar.",
"errors.6": "El sitio no se ha encontrado. Por favor refresca la página y vuelve a intentar.",
"errors.7": "El usuario ha sido bloqueado.",
"errors.8": "El usuario ha sido bloqueado.",
"errors.9": "No se ha podido cambiar el comentario. Por favor vuelve a intentar más tarde.",
"errors.failed-fetch": "No se ha podido obtener. Por favor revisa tu conexión a internet o vuelve a intentar más tarde",
"errors.forbidden": "Prohibido.",
"errors.not-authorized": "No autorizado.",
"errors.to-many-request": "Has llegado al límite de peticiones.",
"errors.unexpected-error": "Algo salió mal.",
"root.pinned-comments": "Comentarios anclados",
"root.powered-by": "Powered by <a>Remark42</a>",
"root.show-more": "Mostrar más",
"settings.block": "bloquear",
"settings.block-time": "hasta el {day} a las {time}",
"settings.block-user": "¿Quieres bloquear a {userName}?",
"settings.blocked-users-header": "Usuarios bloqueados:",
"settings.blocked-users-title": "Usuarios bloqueados",
"settings.hidden-user-header": "Usuarios ocultos:",
"settings.hidden-users-title": "Usuarios ocultos",
"settings.hide": "ocultar",
"settings.no-blocked-users": "No hay usuarios bloqueados.",
"settings.no-hidden-users": "No hay usuarios ocultos.",
"settings.permanently": "permanentemente",
"settings.show": "mostrar",
"settings.unblock": "desbloquear",
"settings.unblock-user": "¿Quieres desbloquear a {userName}?",
"settings.unknown": "desconocido",
"subscribeByEmail.back": "Volver",
"subscribeByEmail.close": "Cerrar",
"subscribeByEmail.email": "Correo electrónico",
"subscribeByEmail.expired-token": "Token expirado",
"subscribeByEmail.have-been-subscribed": "Has sido suscripto a actualizaciones por correo electrónico",
"subscribeByEmail.have-been-unsubscribed": "Has sido de-suscripto a actualizaciones por correo electrónico",
"subscribeByEmail.only-registered-users": "Disponible solo para usuarios registrados",
"subscribeByEmail.submit": "Enviar",
"subscribeByEmail.subscribe": "Suscribir",
"subscribeByEmail.subscribe-by-email": "Suscribir por correo electrónico",
"subscribeByEmail.subscribe-to-replies": "Suscribir a respuestas",
"subscribeByEmail.subscribed": "Estás suscripto a actualizaciones por correo electrónico",
"subscribeByEmail.token": "Token",
"subscribeByEmail.unsubscribe": "De-suscribir",
"subscribeByRSS.button-title": "Suscribir por RSS",
"subscribeByRSS.replies": "Respuestas",
"subscribeByRSS.site": "Sitio",
"subscribeByRSS.thread": "Hilo",
"subscribeByRSS.title": "RSS",
"toolbar.attach-image": "Adjunta la imágen, arrastra y suelta, o pega desde el portapapeles",
"toolbar.bold": "Agrega texto en negrita <cmd-b>",
"toolbar.code": "Inserta un código",
"toolbar.header": "Agrega un título",
"toolbar.italic": "Agrega texto en cursiva <cmd-i>",
"toolbar.link": "Agrega un link <cmd-k>",
"toolbar.ordered-list": "Agrega una lista numerada",
"toolbar.quote": "Inserta una cita",
"toolbar.unordered-list": "Agrega una lista sin numerar",
"user-info.last-comments": "Últimos comentarios de {userName}",
"user-info.unexpected-error": "Algo salió mal",
"vote.anonymous": "Los usuarios anónimos no puede votar",
"vote.deleted": "No se puede votar un comentario eliminado",
"vote.guest": "Accede para votar",
"vote.only-positive": "El puntaje debe ser positivo",
"vote.only-post-page": "Solo se puede votar en la página de la publicación",
"vote.own-comment": "No puedes votar tu propio comentario",
"vote.readonly": "No se puede votar en tópicos de solo lectura"
}
+1
View File
@@ -12,6 +12,7 @@
"authPanel.logged-as": "Olet kirjautunut sisään nimellä",
"authPanel.login": "Kirjaudu sisään:",
"authPanel.logout": "Kirjaudu ulos?",
"authPanel.new-page": "uusi sivu",
"authPanel.or-provider": "tai",
"authPanel.other-provider": "Muu",
"authPanel.read-only": "Vain luku",
+1
View File
@@ -12,6 +12,7 @@
"authPanel.logged-as": "Вы вошли как",
"authPanel.login": "Вход:",
"authPanel.logout": "Выйти?",
"authPanel.new-page": "новая страница",
"authPanel.or-provider": "или",
"authPanel.other-provider": "Другой",
"authPanel.read-only": "Только для чтение",
+2 -2
View File
@@ -70,12 +70,12 @@ describe('user', () => {
);
const dispatch = jest.fn();
const getState = jest.fn();
await logIn({ name: 'google' })(dispatch, getState, undefined).catch(() => {});
await logIn({ name: 'google' })(dispatch, getState, undefined).catch(() => undefined);
expect(dispatch).not.toBeCalled();
});
it('should unset user on logOut', async () => {
(api.logOut as any).mockImplementation(async (): Promise<void> => {});
(api.logOut as any).mockImplementation(async (): Promise<void> => undefined);
const dispatch = jest.fn();
const getState = jest.fn();
await logout()(dispatch, getState, undefined);
+5
View File
@@ -18,6 +18,11 @@ export async function loadLocale(locale: string): Promise<Record<string, string>
.then(res => res.default)
.catch(() => enMessages);
}
if (locale === 'es') {
return import(/* webpackChunkName: "es" */ '../locales/es.json')
.then(res => res.default)
.catch(() => enMessages);
}
return enMessages;
}
+2012 -4440
View File
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -5,7 +5,7 @@
"scripts": {
"build": "webpack --mode production && es-check es5 './public/*.js'",
"start": "webpack-dev-server --mode development",
"check": "tsc -p tsconfig.json --noEmit --skipLibCheck",
"check": "tsc -p tsconfig.typecheck.json --noEmit --skipLibCheck",
"check:translation": "npm run extract-messages && node ./tasks/checkTranslation.js",
"lint": "eslint --max-warnings=0 --ext=.ts,.tsx,.js,.jsx .",
"lint:style": "stylelint '**/*.scss' '**/*.pcss' '**/*.css' 'iframe.html'",
@@ -39,10 +39,10 @@
"@types/fetch-mock": "^7.3.1",
"@types/jest": "^24.0.20",
"@types/lodash-es": "^4.17.3",
"@types/node": "^12.11.7",
"@types/node": "^12.12.34",
"@types/react-redux": "^7.1.5",
"@types/redux-mock-store": "^1.0.1",
"@typescript-eslint/eslint-plugin": "^2.5.0",
"@typescript-eslint/eslint-plugin": "^2.27.0",
"@typescript-eslint/parser": "^2.5.0",
"autoprefixer": "^9.7.0",
"babel-eslint": "^10.0.3",
@@ -94,8 +94,8 @@
"stylelint-config-standard": "^20.0.0",
"stylelint-value-no-unknown-custom-properties": "^2.0.0",
"ts-jest": "^25.2.1",
"ts-loader": "^6.2.1",
"typescript": "^3.6.4",
"ts-loader": "^6.2.2",
"typescript": "^3.8.3",
"webpack": "^4.41.2",
"webpack-bundle-analyzer": "^3.6.1",
"webpack-cli": "^3.3.9",
@@ -123,6 +123,7 @@
"redux-thunk": "^2.3.0"
},
"engines": {
"node": ">=12"
"node": ">=12.11",
"npm": ">=6.13.4"
}
}
+1 -1
View File
@@ -1 +1 @@
["en", "ru", "de", "fi"]
["en", "ru", "de", "fi", "es"]
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"incremental": false
}
}