From 8c5531b1786ff67e4f44a65ca90042c6d2af13f2 Mon Sep 17 00:00:00 2001 From: Umputun Date: Wed, 24 Oct 2018 22:28:56 -0500 Subject: [PATCH] Notify (#211) * add notify service with no destinations * change to request for destinations * add telegram notify and wire to main * lint: missed err checks * fix notify factory * externalize timeout for notify * fix race in notifier mock * lint: check notify send status in tests * lint: write warn * lint: wrong assignment * lint: two more writes in tests * missing notif timeout default --- README.md | 83 ++++++++++--------- backend/app/cmd/server.go | 36 +++++++- backend/app/notify/notify.go | 99 ++++++++++++++++++++++ backend/app/notify/notify_test.go | 111 +++++++++++++++++++++++++ backend/app/notify/telegram.go | 119 +++++++++++++++++++++++++++ backend/app/notify/telegram_test.go | 73 ++++++++++++++++ backend/app/rest/api/rest.go | 2 + backend/app/rest/api/rest_private.go | 4 + 8 files changed, 487 insertions(+), 40 deletions(-) create mode 100644 backend/app/notify/notify.go create mode 100644 backend/app/notify/notify_test.go create mode 100644 backend/app/notify/telegram.go create mode 100644 backend/app/notify/telegram_test.go diff --git a/README.md b/README.md index 8a7d17a9..1db6ef46 100644 --- a/README.md +++ b/README.md @@ -82,45 +82,50 @@ _this is the recommended way to run remark42_ #### Parameters -| Command line | Environment | Default | Description | -| ------------------ | ------------------ | --------------------- | ------------------------------------------------ | -| url | REMARK_URL | | url to remark42 server, _required_ | -| secret | SECRET | | secret key, _required_ | -| site | SITE | `remark` | site name(s), _multi_ | -| store.type | STORE_TYPE | `bolt` | type of storage, `bolt` or `mongo` | -| store.bolt.path | STORE_BOLT_PATH | `./var` | path to data directory | -| store.bolt.timeout | STORE_BOLT_TIMEOUT | `30s` | boltdb access timeout | -| mongo.url | MONGO_URL | | mongo url for all stores using mongodb | -| mongo.db | MONGO_DB | | mongo database | -| admin.shared.id | ADMIN_SHARED_ID | | admin names (list of user ids), _multi_ | -| admin.shared.email | ADMIN_SHARED_EMAIL | `admin@${REMARK_URL}` | admin email | -| backup | BACKUP_PATH | `./var/backup` | backups location | -| max-back | MAX_BACKUP_FILES | `10` | max backup files to keep | -| cache.max.items | CACHE_MAX_ITEMS | `1000` | max number of cached items, `0` - unlimited | -| cache.max.value | CACHE_MAX_VALUE | `65536` | max size of cached value, `0` - unlimited | -| cache.max.size | CACHE_MAX_SIZE | `50000000` | max size of all cached values, `0` - unlimited | -| avatar.type | AVATAR_TYPE | `fs` | type of avatar storage, `fs`, 'bolt`, or `mongo` | -| avatar.fs.path | AVATAR_FS_PATH | `./var/avatars` | avatars location for `fs` store | -| avatar.bolt.file | AVATAR_BOLT_FILE | `./var/avatars.db` | file name for `bolt` store | -| avatar.rsz-lmt | AVATAR_RSZ_LMT | 0 | max image size for resizing avatars on save | -| auth.ttl.jwt | AUTH_TTL_JWT | 5m | jwt TTL | -| auth.ttl.cookie | AUTH_TTL_COOKIE | 200h | cookie TTL | -| auth.google.cid | AUTH_GOOGLE_CID | | Google OAuth client ID | -| auth.google.csec | AUTH_GOOGLE_CSEC | | Google OAuth client secret | -| auth.facebook.cid | AUTH_FACEBOOK_CID | | Facebook OAuth client ID | -| auth.facebook.csec | AUTH_FACEBOOK_CSEC | | Facebook OAuth client secret | -| auth.github.cid | AUTH_GITHUB_CID | | Github OAuth client ID | -| auth.github.csec | AUTH_GITHUB_CSEC | | Github OAuth client secret | -| auth.yandex.cid | AUTH_YANDEX_CID | | Yandex OAuth client ID | -| auth.yandex.csec | AUTH_YANDEX_CSEC | | Yandex OAuth client secret | -| auth.dev | AUTH_DEV | false | local oauth2 server, development mode only | -| max-comment | MAX_COMMENT_SIZE | 2048 | comment's size limit | -| low-score | LOW_SCORE | `-5` | low score threshold | -| critical-score | CRITICAL_SCORE | `-10` | critical score threshold | -| edit-time | EDIT_TIME | `5m` | edit window | -| img-proxy | IMG_PROXY | `false` | enable http->https proxy for images | -| dbg | DEBUG | `false` | debug mode | -| dev-passwd | DEV_PASSWD | | password for `dev` user | +| Command line | Environment | Default | Description | +| ----------------------- | ----------------------- | --------------------- | ------------------------------------------------ | +| url | REMARK_URL | | url to remark42 server, _required_ | +| secret | SECRET | | secret key, _required_ | +| site | SITE | `remark` | site name(s), _multi_ | +| store.type | STORE_TYPE | `bolt` | type of storage, `bolt` or `mongo` | +| store.bolt.path | STORE_BOLT_PATH | `./var` | path to data directory | +| store.bolt.timeout | STORE_BOLT_TIMEOUT | `30s` | boltdb access timeout | +| mongo.url | MONGO_URL | | mongo url for all stores using mongodb | +| mongo.db | MONGO_DB | | mongo database | +| admin.shared.id | ADMIN_SHARED_ID | | admin names (list of user ids), _multi_ | +| admin.shared.email | ADMIN_SHARED_EMAIL | `admin@${REMARK_URL}` | admin email | +| backup | BACKUP_PATH | `./var/backup` | backups location | +| max-back | MAX_BACKUP_FILES | `10` | max backup files to keep | +| cache.max.items | CACHE_MAX_ITEMS | `1000` | max number of cached items, `0` - unlimited | +| cache.max.value | CACHE_MAX_VALUE | `65536` | max size of cached value, `0` - unlimited | +| cache.max.size | CACHE_MAX_SIZE | `50000000` | max size of all cached values, `0` - unlimited | +| avatar.type | AVATAR_TYPE | `fs` | type of avatar storage, `fs`, 'bolt`, or `mongo` | +| avatar.fs.path | AVATAR_FS_PATH | `./var/avatars` | avatars location for `fs` store | +| avatar.bolt.file | AVATAR_BOLT_FILE | `./var/avatars.db` | file name for `bolt` store | +| avatar.rsz-lmt | AVATAR_RSZ_LMT | 0 | max image size for resizing avatars on save | +| auth.ttl.jwt | AUTH_TTL_JWT | 5m | jwt TTL | +| auth.ttl.cookie | AUTH_TTL_COOKIE | 200h | cookie TTL | +| auth.google.cid | AUTH_GOOGLE_CID | | Google OAuth client ID | +| auth.google.csec | AUTH_GOOGLE_CSEC | | Google OAuth client secret | +| auth.facebook.cid | AUTH_FACEBOOK_CID | | Facebook OAuth client ID | +| auth.facebook.csec | AUTH_FACEBOOK_CSEC | | Facebook OAuth client secret | +| auth.github.cid | AUTH_GITHUB_CID | | Github OAuth client ID | +| auth.github.csec | AUTH_GITHUB_CSEC | | Github OAuth client secret | +| auth.yandex.cid | AUTH_YANDEX_CID | | Yandex OAuth client ID | +| auth.yandex.csec | AUTH_YANDEX_CSEC | | Yandex OAuth client secret | +| auth.dev | AUTH_DEV | false | local oauth2 server, development mode only | +| notify.type | NOTIFY_TYPE | none | type of notification (none or telegram) | +| 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 | | telegram timeout | +| max-comment | MAX_COMMENT_SIZE | 2048 | comment's size limit | +| low-score | LOW_SCORE | `-5` | low score threshold | +| critical-score | CRITICAL_SCORE | `-10` | critical score threshold | +| edit-time | EDIT_TIME | `5m` | edit window | +| img-proxy | IMG_PROXY | `false` | enable http->https proxy for images | +| dbg | DEBUG | `false` | debug mode | +| dev-passwd | DEV_PASSWD | | password for `dev` user | * command line parameters are long form `--=value`, i.e. `--site=https://demo.remark42.com` * _multi_ parameters separated by `,` in the environment or repeated with command line key, like `--site=s1 --site=s2 ...` diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index 6ec4e489..f286d569 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -15,6 +15,7 @@ import ( "github.com/coreos/bbolt" "github.com/go-pkgz/mongo" "github.com/pkg/errors" + "github.com/umputun/remark/backend/app/notify" "github.com/umputun/remark/backend/app/migrator" "github.com/umputun/remark/backend/app/rest/api" @@ -35,6 +36,7 @@ type ServerCommand struct { Cache CacheGroup `group:"cache" namespace:"cache" env-namespace:"CACHE"` Mongo MongoGroup `group:"mongo" namespace:"mongo" env-namespace:"MONGO"` Admin AdminGroup `group:"admin" namespace:"admin" env-namespace:"ADMIN"` + Notify NotifyGroup `group:"notify" namespace:"notify" env-namespace:"NOTIFY"` Sites []string `long:"site" env:"SITE" default:"remark" description:"site names" env-delim:","` DevPasswd string `long:"dev-passwd" env:"DEV_PASSWD" default:"" description:"development mode password"` @@ -116,6 +118,17 @@ type AdminGroup struct { } `group:"shared" namespace:"shared" env-namespace:"SHARED"` } +// NotifyGroup defines options for notification +type NotifyGroup struct { + Type string `long:"type" env:"TYPE" description:"type of notification" choice:"none" choice:"telegram" default:"none"` + 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"` + } `group:"telegram" namespace:"telegram" env-namespace:"TELEGRAM"` +} + // serverApp holds all active objects type serverApp struct { *ServerCommand @@ -213,6 +226,11 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { KeyStore: adminStore, } + notifyService, err := s.makeNotify(dataService) + if err != nil { + return nil, errors.Wrap(err, "failed to make notify service") + } + authProviders := s.makeAuthProviders(jwtService, avatarProxy, dataService) imgProxy := &proxy.Image{Enabled: s.ImageProxy, RoutePath: "/api/v1/img", RemarkURL: s.RemarkURL} commentFormatter := store.NewCommentFormatter(imgProxy) @@ -235,7 +253,8 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { DevPasswd: s.DevPasswd, PermissionChecker: dataService, }, - Cache: loadingCache, + Cache: loadingCache, + NotifyService: notifyService, } srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = s.LowScore, s.CriticalScore @@ -443,3 +462,18 @@ func (s *ServerCommand) makeAuthProviders(jwt *auth.JWT, ap *proxy.Avatar, ds *s } return providers } + +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, "") + if err != nil { + return nil, errors.Wrap(err, "failed to create telegram notification destination") + } + return notify.NewService(dataStore, s.Notify.QueueSize, tg), nil + case "none": + return notify.NewService(dataStore, s.Notify.QueueSize), nil + } + return nil, errors.Errorf("unsupported notification type %q", s.Notify.Type) +} diff --git a/backend/app/notify/notify.go b/backend/app/notify/notify.go new file mode 100644 index 00000000..57ab4999 --- /dev/null +++ b/backend/app/notify/notify.go @@ -0,0 +1,99 @@ +// Package notify provides notification functionality. +package notify + +import ( + "context" + "fmt" + "log" + "sync" + + "github.com/umputun/remark/backend/app/store" + "github.com/umputun/remark/backend/app/store/service" +) + +type request struct { + comment store.Comment + parent store.Comment +} + +// Destination defines interface for a given destination service, like telegram, email and so on +type Destination interface { + fmt.Stringer + Send(ctx context.Context, req request) error +} + +// Service delivers notifications to multiple destinations +type Service struct { + dataService *service.DataStore + destinations []Destination + queue chan request + + closed bool + ctx context.Context + cancel context.CancelFunc +} + +const defaultQueueSize = 100 +const uiNav = "#remark42__comment-" + +// NewService makes notification service routing comments to all destinations. +func NewService(dataService *service.DataStore, size int, destinations ...Destination) *Service { + if size <= 0 { + size = defaultQueueSize + } + ctx, cancel := context.WithCancel(context.Background()) + res := Service{ + dataService: dataService, + queue: make(chan request, size), + destinations: destinations, + ctx: ctx, + cancel: cancel, + } + if len(destinations) > 0 { + go res.do() + } + log.Printf("[INFO] create notifier service, queue size=%d", size) + return &res +} + +// Submit comment to internal channel if not busy, drop if can't send +func (s *Service) Submit(comment store.Comment) { + if len(s.destinations) == 0 || s.closed { + return + } + parentComment := store.Comment{} + if s.dataService != nil { + if p, err := s.dataService.Get(comment.Locator, comment.ParentID); err == nil { + parentComment = p + } + } + select { + case s.queue <- request{comment: comment, parent: parentComment}: + default: + log.Printf("[WARN] can't send comment notification to queue, %+v", comment) + } +} + +// Close queue channel and wait for completion +func (s *Service) Close() { + close(s.queue) + s.cancel() + <-s.ctx.Done() + s.closed = true +} + +func (s *Service) do() { + for c := range s.queue { + var wg sync.WaitGroup + for _, dest := range s.destinations { + wg.Add(1) + go func(d Destination) { + if err := d.Send(s.ctx, c); err != nil { + log.Printf("[WARN] failed to send to %s", d) + } + wg.Done() + }(dest) + } + wg.Wait() + } +} diff --git a/backend/app/notify/notify_test.go b/backend/app/notify/notify_test.go new file mode 100644 index 00000000..d52ce793 --- /dev/null +++ b/backend/app/notify/notify_test.go @@ -0,0 +1,111 @@ +package notify + +import ( + "context" + "fmt" + "log" + "math/rand" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/umputun/remark/backend/app/store" +) + +func TestService_NoDestinations(t *testing.T) { + s := NewService(nil, 1) + assert.NotNil(t, s) + s.Submit(store.Comment{ID: "123"}) + s.Submit(store.Comment{ID: "123"}) + s.Submit(store.Comment{ID: "123"}) + s.Close() +} + +func TestService_WithDestinations(t *testing.T) { + d1, d2 := &mockDest{id: 1}, &mockDest{id: 2} + s := NewService(nil, 1, d1, d2) + assert.NotNil(t, s) + + s.Submit(store.Comment{ID: "100"}) + time.Sleep(time.Millisecond * 110) + s.Submit(store.Comment{ID: "101"}) + time.Sleep(time.Millisecond * 110) + s.Submit(store.Comment{ID: "102"}) + 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, "100", d1.get()[0].ID) + assert.Equal(t, "101", d1.get()[1].ID) + assert.Equal(t, "102", d1.get()[2].ID) +} + +func TestService_WithDrops(t *testing.T) { + d1, d2 := &mockDest{id: 1}, &mockDest{id: 2} + s := NewService(nil, 1, d1, d2) + assert.NotNil(t, s) + + s.Submit(store.Comment{ID: "100"}) + s.Submit(store.Comment{ID: "101"}) + time.Sleep(time.Millisecond * 110) + s.Submit(store.Comment{ID: "102"}) + time.Sleep(time.Millisecond * 110) + s.Close() + + s.Submit(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") +} + +func TestService_Many(t *testing.T) { + 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(store.Comment{ID: fmt.Sprintf("%d", 100+i)}) + time.Sleep(time.Millisecond * time.Duration(rand.Int31n(200))) + } + 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.True(t, d1.closed) + assert.True(t, d2.closed) +} + +type mockDest struct { + data []store.Comment + 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.comment) + 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() []store.Comment { + m.lock.Lock() + defer m.lock.Unlock() + res := make([]store.Comment, len(m.data)) + copy(res, m.data) + return res +} +func (m *mockDest) String() string { return fmt.Sprintf("mock id=%d, closed=%v", m.id, m.closed) } diff --git a/backend/app/notify/telegram.go b/backend/app/notify/telegram.go new file mode 100644 index 00000000..54160581 --- /dev/null +++ b/backend/app/notify/telegram.go @@ -0,0 +1,119 @@ +package notify + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/pkg/errors" +) + +// Telegram implements notify.Destination for telegram +type Telegram struct { + channelName string + token string + apiPrefix string + timeout time.Duration +} + +const telegramTimeOut = 2500 * time.Millisecond +const telegramAPIPrefix = "https://api.telegram.org/bot" + +// NewTelegram makes telegram bot for notifications +func NewTelegram(token string, channelName string, timeout time.Duration, api string) (*Telegram, error) { + log.Printf("[DEBUG] create new telegram notifier for cham %s, timeout=%s, api=%s", channelName, timeout, api) + res := Telegram{channelName: channelName, token: token, apiPrefix: api, timeout: timeout} + res.channelName = strings.TrimPrefix(res.channelName, "@") + if res.apiPrefix == "" { + res.apiPrefix = telegramAPIPrefix + } + if res.timeout == 0 { + res.timeout = telegramTimeOut + } + client := http.Client{Timeout: telegramTimeOut} + resp, err := client.Get(fmt.Sprintf("%s%s/getMe", res.apiPrefix, token)) + if err != nil { + return nil, errors.Wrap(err, "can't initialize telegram notifications") + } + defer func() { + if err = resp.Body.Close(); err != nil { + log.Printf("[WARN] can't close request body, %s", err) + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("unexpected telegram status code %d", resp.StatusCode) + } + + tgResp := struct { + OK bool `json:"ok"` + Result struct { + FirstName string `json:"first_name"` + ID uint64 `json:"id"` + IsBot bool `json:"is_bot"` + UserName string `json:"username"` + } + }{} + + if err = json.NewDecoder(resp.Body).Decode(&tgResp); err != nil { + return nil, errors.Wrap(err, "can't authorize to telegram") + } + + if !tgResp.OK || !tgResp.Result.IsBot { + return nil, errors.Errorf("unexpected telegram response %+v", tgResp) + } + + return &res, nil +} + +// Send to telegram channel +func (t *Telegram) Send(ctx context.Context, req request) error { + client := http.Client{Timeout: telegramTimeOut} + + from := req.comment.User.Name + if req.comment.ParentID != "" { + from += " -> " + req.parent.User.Name + } + from = "*" + from + "*" + link := fmt.Sprintf("[comment](%s)", req.comment.Locator.URL+uiNav+req.comment.ID) + msg := fmt.Sprintf("%s\n\n%s\n\n%s", from, req.comment.Text, link) + + r, err := http.NewRequest("GET", fmt.Sprintf("%s%s/sendMessage?chat_id=@%s&text=%s&parse_mode=Markdown", + t.apiPrefix, t.token, t.channelName, url.QueryEscape(msg)), nil) + if err != nil { + return errors.Wrap(err, "failed to make telegram request") + } + + r = r.WithContext(ctx) + resp, err := client.Do(r) + if err != nil { + return errors.Wrap(err, "failed to get telegram response") + } + defer func() { + if err = resp.Body.Close(); err != nil { + log.Printf("[WARN] can't close request body, %s", err) + } + }() + + if resp.StatusCode != http.StatusOK { + return errors.Errorf("unexpected telegram status code %d", resp.StatusCode) + } + + tgResp := struct { + OK bool `json:"ok"` + }{} + + if err = json.NewDecoder(resp.Body).Decode(&tgResp); err != nil { + return errors.Wrap(err, "can't decode telegram response") + } + return nil +} + +func (t *Telegram) String() string { + return "telegram-" + t.channelName +} diff --git a/backend/app/notify/telegram_test.go b/backend/app/notify/telegram_test.go new file mode 100644 index 00000000..f57f1acf --- /dev/null +++ b/backend/app/notify/telegram_test.go @@ -0,0 +1,73 @@ +package notify + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi" + "github.com/stretchr/testify/assert" + "github.com/umputun/remark/backend/app/store" +) + +func TestTelegram_New(t *testing.T) { + + ts := mockTelegramServer() + defer ts.Close() + + tb, err := NewTelegram("good-token", "remark_test", 2*time.Second, ts.URL+"/") + assert.NoError(t, err) + assert.NotNil(t, tb) + + _, err = NewTelegram("bad-resp", "remark_test", 2*time.Second, ts.URL+"/") + assert.NotNil(t, err) + + _, err = NewTelegram("404", "remark_test", 2*time.Second, ts.URL+"/") + assert.NotNil(t, err) +} + +func TestTelegram_Send(t *testing.T) { + ts := mockTelegramServer() + defer ts.Close() + + tb, err := NewTelegram("good-token", "remark_test", 2*time.Second, ts.URL+"/") + assert.NoError(t, err) + assert.NotNil(t, tb) + err = tb.Send(context.TODO(), request{comment: store.Comment{Text: "some text"}}) + assert.NoError(t, err) +} + +func mockTelegramServer() *httptest.Server { + router := chi.NewRouter() + router.Get("/good-token/getMe", func(w http.ResponseWriter, r *http.Request) { + s := `{"ok": true, + "result": { + "first_name": "comments_test", + "id": 707381019, + "is_bot": true, + "username": "remark42_test_bot" + }}` + _, _ = w.Write([]byte(s)) + }) + router.Get("/bad-resp/getMe", func(w http.ResponseWriter, r *http.Request) { + s := `{"ok": false, + "result": { + "first_name": "comments_test", + "id": 707381019, + "is_bot": false, + "username": "remark42_test_bot" + }}` + _, _ = w.Write([]byte(s)) + }) + router.Get("/404/getMe", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + }) + + router.Get("/good-token/sendMessage", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok": true}`)) + }) + + return httptest.NewServer(router) +} diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index ba8738ca..c76ccf7c 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -21,6 +21,7 @@ import ( "github.com/go-chi/render" "github.com/pkg/errors" "github.com/rakyll/statik/fs" + "github.com/umputun/remark/backend/app/notify" "github.com/umputun/remark/backend/app/rest" "github.com/umputun/remark/backend/app/rest/auth" @@ -41,6 +42,7 @@ type Rest struct { ImageProxy *proxy.Image CommentFormatter *store.CommentFormatter Migrator *Migrator + NotifyService *notify.Service WebRoot string RemarkURL string diff --git a/backend/app/rest/api/rest_private.go b/backend/app/rest/api/rest_private.go index 5c147747..25d067be 100644 --- a/backend/app/rest/api/rest_private.go +++ b/backend/app/rest/api/rest_private.go @@ -73,6 +73,10 @@ func (s *Rest) 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)) + if s.NotifyService != nil { + s.NotifyService.Submit(finalComment) + } + render.Status(r, http.StatusCreated) render.JSON(w, r, &finalComment) }