From 05902461122d78897feef14c92126c1af597f02c Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 16 May 2019 20:49:07 -0500 Subject: [PATCH 1/6] move comment alter functionality from rest and admin to store service --- backend/app/migrator/disqus_test.go | 2 +- backend/app/migrator/migrator.go | 4 +- backend/app/migrator/migrator_test.go | 7 +- backend/app/migrator/native.go | 2 +- backend/app/migrator/native_test.go | 13 +- backend/app/migrator/wordpress_test.go | 2 +- backend/app/notify/notify.go | 4 +- backend/app/notify/notify_test.go | 2 +- backend/app/rest/api/admin.go | 37 +---- backend/app/rest/api/admin_test.go | 4 +- backend/app/rest/api/rest.go | 35 ----- backend/app/rest/api/rest_private.go | 6 +- backend/app/rest/api/rest_private_test.go | 2 +- backend/app/rest/api/rest_public.go | 22 ++- backend/app/rest/api/rss.go | 13 +- backend/app/rest/user.go | 8 ++ backend/app/store/engine/engine.go | 2 +- backend/app/store/service/service.go | 132 ++++++++++++++---- backend/app/store/service/service_test.go | 74 ++++++---- backend/go.mod | 3 +- backend/go.sum | 3 + .../vendor/github.com/go-pkgz/mongo/go.sum | 1 + .../github.com/hashicorp/golang-lru/go.mod | 2 + 23 files changed, 213 insertions(+), 167 deletions(-) diff --git a/backend/app/migrator/disqus_test.go b/backend/app/migrator/disqus_test.go index 3f3db451..5388f9b3 100644 --- a/backend/app/migrator/disqus_test.go +++ b/backend/app/migrator/disqus_test.go @@ -26,7 +26,7 @@ func TestDisqus_Import(t *testing.T) { assert.Nil(t, err) assert.Equal(t, 4, size) - last, err := dataStore.Last("test", 10, time.Time{}) + last, err := dataStore.Last("test", 10, time.Time{}, adminUser) assert.Nil(t, err) assert.Equal(t, 4, len(last), "4 comments imported") diff --git a/backend/app/migrator/migrator.go b/backend/app/migrator/migrator.go index a63d56c3..93ba4b53 100644 --- a/backend/app/migrator/migrator.go +++ b/backend/app/migrator/migrator.go @@ -27,7 +27,7 @@ type Exporter interface { // Store defines minimal interface needed to export and import comments type Store interface { Create(comment store.Comment) (commentID string, err error) - Find(locator store.Locator, sort string) ([]store.Comment, error) + Find(locator store.Locator, sort string, user store.User) ([]store.Comment, error) List(siteID string, limit int, skip int) ([]store.PostInfo, error) DeleteAll(siteID string) error Metas(siteID string) (umetas []service.UserMetaData, pmetas []service.PostMetaData, err error) @@ -42,6 +42,8 @@ type ImportParams struct { SiteID string } +var adminUser = store.User{Admin: true} + // ImportComments imports from given provider format and saves to store func ImportComments(p ImportParams) (int, error) { log.Printf("[INFO] import from %s (%s) to %s", p.InputFile, p.Provider, p.SiteID) diff --git a/backend/app/migrator/migrator_test.go b/backend/app/migrator/migrator_test.go index c004eb1c..4b1dae2e 100644 --- a/backend/app/migrator/migrator_test.go +++ b/backend/app/migrator/migrator_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/umputun/remark/backend/app/store" "github.com/umputun/remark/backend/app/store/admin" "github.com/umputun/remark/backend/app/store/engine" "github.com/umputun/remark/backend/app/store/service" @@ -36,7 +37,7 @@ func TestMigrator_ImportDisqus(t *testing.T) { assert.Nil(t, err) assert.Equal(t, 4, size) - last, err := dataStore.Last("test", 10, time.Time{}) + last, err := dataStore.Last("test", 10, time.Time{}, store.User{}) assert.Nil(t, err) assert.Equal(t, 4, len(last), "4 comments imported") } @@ -62,7 +63,7 @@ func TestMigrator_ImportWordPress(t *testing.T) { assert.Nil(t, err) assert.Equal(t, 3, size) - last, err := dataStore.Last("test", 10, time.Time{}) + last, err := dataStore.Last("test", 10, time.Time{}, store.User{}) assert.Nil(t, err) assert.Equal(t, 3, len(last), "3 comments imported") } @@ -92,7 +93,7 @@ func TestMigrator_ImportNative(t *testing.T) { assert.Nil(t, err) assert.Equal(t, 2, size) - last, err := dataStore.Last("radio-t", 10, time.Time{}) + last, err := dataStore.Last("radio-t", 10, time.Time{}, store.User{}) assert.Nil(t, err) assert.Equal(t, 2, len(last), "2 comments imported") } diff --git a/backend/app/migrator/native.go b/backend/app/migrator/native.go index 40db2a17..6084dd24 100644 --- a/backend/app/migrator/native.go +++ b/backend/app/migrator/native.go @@ -49,7 +49,7 @@ func (n *Native) Export(w io.Writer, siteID string) (size int, err error) { commentsCount := 0 for i := len(topics) - 1; i >= 0; i-- { // topics from List sorted in opposite direction topic := topics[i] - comments, e := n.DataStore.Find(store.Locator{SiteID: siteID, URL: topic.URL}, "time") + comments, e := n.DataStore.Find(store.Locator{SiteID: siteID, URL: topic.URL}, "time", adminUser) if e != nil { return commentsCount, e } diff --git a/backend/app/migrator/native_test.go b/backend/app/migrator/native_test.go index 7a0acbd4..ed0b0c97 100644 --- a/backend/app/migrator/native_test.go +++ b/backend/app/migrator/native_test.go @@ -77,12 +77,13 @@ func TestNative_Import(t *testing.T) { {"id":"f863bd79-fec6-4a75-b308-61fe5dd02aa1","pid":"1234","text":"some text2","user":{"name":"user name","id":"user2","picture":"","ip":"293ec5b0cf154855258824ec7fac5dc63d176915","admin":false},"locator":{"site":"radio-t","url":"https://radio-t.com/2"},"score":0,"votes":{},"time":"2017-12-20T15:18:23-06:00"}` b := prep(t) // write some recs - r := Native{DataStore: &service.DataStore{Interface: b, AdminStore: admin.NewStaticStore("12345", []string{}, "")}} + b.AdminStore = admin.NewStaticStore("12345", []string{}, "") + r := Native{DataStore: b} size, err := r.Import(strings.NewReader(inp), "radio-t") assert.Nil(t, err) assert.Equal(t, 2, size) - comments, err := b.Last("radio-t", 10, time.Time{}) + comments, err := b.Last("radio-t", 10, time.Time{}, store.User{}) assert.Nil(t, err) assert.Equal(t, 2, len(comments)) assert.Equal(t, "f863bd79-fec6-4a75-b308-61fe5dd02aa1", comments[0].ID) @@ -106,7 +107,8 @@ func TestNative_ImportWrongVersion(t *testing.T) { {"id":"f863bd79-fec6-4a75-b308-61fe5dd02aa1","pid":"1234","text":"some text2","user":{"name":"user name","id":"user2","picture":"","ip":"293ec5b0cf154855258824ec7fac5dc63d176915","admin":false},"locator":{"site":"radio-t","url":"https://radio-t.com/2"},"score":0,"votes":{},"time":"2017-12-20T15:18:23-06:00"}` b := prep(t) // write some recs - r := Native{DataStore: &service.DataStore{Interface: b, AdminStore: admin.NewStaticStore("12345", []string{}, "")}} + b.AdminStore = admin.NewStaticStore("12345", []string{}, "") + r := Native{DataStore: b} size, err := r.Import(strings.NewReader(inp), "radio-t") assert.EqualError(t, err, "unexpected import file version 2") assert.Equal(t, 0, size) @@ -126,11 +128,12 @@ func TestNative_ImportManyWithError(t *testing.T) { buf.WriteString("{}\n") b := prep(t) // write some recs - r := Native{DataStore: &service.DataStore{Interface: b, AdminStore: admin.NewStaticStore("12345", []string{}, "")}} + b.AdminStore = admin.NewStaticStore("12345", []string{}, "") + r := Native{DataStore: b} n, err := r.Import(buf, "radio-t") assert.EqualError(t, err, "failed to save 2 comments") assert.Equal(t, 1200, n) - comments, err := b.Find(store.Locator{SiteID: "radio-t", URL: "https://radio-t.com"}, "time") + comments, err := b.Find(store.Locator{SiteID: "radio-t", URL: "https://radio-t.com"}, "time", store.User{}) assert.Nil(t, err) assert.Equal(t, 1200, len(comments)) } diff --git a/backend/app/migrator/wordpress_test.go b/backend/app/migrator/wordpress_test.go index 001fbffd..86a99b93 100644 --- a/backend/app/migrator/wordpress_test.go +++ b/backend/app/migrator/wordpress_test.go @@ -27,7 +27,7 @@ func TestWordPress_Import(t *testing.T) { assert.Nil(t, err) assert.Equal(t, 3, size) - last, err := dataStore.Last(siteID, 10, time.Time{}) + last, err := dataStore.Last(siteID, 10, time.Time{}, adminUser) assert.Nil(t, err) assert.Equal(t, 3, len(last), "3 comments imported") diff --git a/backend/app/notify/notify.go b/backend/app/notify/notify.go index 886fabad..7157bdb6 100644 --- a/backend/app/notify/notify.go +++ b/backend/app/notify/notify.go @@ -31,7 +31,7 @@ type Destination interface { // Store defines the minimal interface accessing stored comments used by notifier type Store interface { - Get(locator store.Locator, id string) (store.Comment, error) + Get(locator store.Locator, id string, user store.User) (store.Comment, error) } type request struct { @@ -69,7 +69,7 @@ func (s *Service) Submit(comment store.Comment) { } parentComment := store.Comment{} if s.dataService != nil { - if p, err := s.dataService.Get(comment.Locator, comment.ParentID); err == nil { + if p, err := s.dataService.Get(comment.Locator, comment.ParentID, store.User{}); err == nil { parentComment = p } } diff --git a/backend/app/notify/notify_test.go b/backend/app/notify/notify_test.go index b3ca33c5..2dfbc632 100644 --- a/backend/app/notify/notify_test.go +++ b/backend/app/notify/notify_test.go @@ -146,7 +146,7 @@ func (m *mockDest) String() string { return fmt.Sprintf("mock id=%d, closed=%v", type mockStore struct{ data map[string]store.Comment } -func (m *mockStore) Get(_ store.Locator, id string) (store.Comment, error) { +func (m *mockStore) Get(_ store.Locator, id string, user store.User) (store.Comment, error) { res, ok := m.data[id] if !ok { return store.Comment{}, errors.New("no such id") diff --git a/backend/app/rest/api/admin.go b/backend/app/rest/api/admin.go index 2bd96113..59b3b979 100644 --- a/backend/app/rest/api/admin.go +++ b/backend/app/rest/api/admin.go @@ -67,7 +67,7 @@ func (a *admin) getUserInfoCtrl(w http.ResponseWriter, r *http.Request) { siteID := r.URL.Query().Get("site") log.Printf("[INFO] get user info for %s, site %s", userID, siteID) - ucomments, err := a.dataService.User(siteID, userID, 1, 0) + ucomments, err := a.dataService.User(siteID, userID, 1, 0, rest.GetUserOrEmpty(r)) if err != nil || len(ucomments) == 0 { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get user info", rest.ErrInternal) return @@ -222,38 +222,3 @@ func (a *admin) setPinCtrl(w http.ResponseWriter, r *http.Request) { func (a *admin) checkBlocked(siteID string, user store.User) bool { return a.dataService.IsBlocked(siteID, user.ID) } - -// post-processes comments, hides text of all comments for blocked users, -// resets score and votes too. Also hides sensitive info for non-admin users -func (a *admin) alterComments(comments []store.Comment, r *http.Request) (res []store.Comment) { - res = make([]store.Comment, len(comments)) - - user, err := rest.GetUserInfo(r) - isAdmin := err == nil && user.Admin - - for i, c := range comments { - - blocked := a.dataService.IsBlocked(c.Locator.SiteID, c.User.ID) - // process blocked users - if blocked { - if !isAdmin { // reset comment to deleted for non-admins - c.SetDeleted(store.SoftDelete) - } - c.User.Blocked = true - c.Deleted = true - } - - // set verified status retroactively - if !blocked { - c.User.Verified = a.dataService.IsVerified(c.Locator.SiteID, c.User.ID) - } - - // hide info from non-admins - if !isAdmin { - c.User.IP = "" - } - - res[i] = c - } - return res -} diff --git a/backend/app/rest/api/admin_test.go b/backend/app/rest/api/admin_test.go index d5295de1..f3bb2ab6 100644 --- a/backend/app/rest/api/admin_test.go +++ b/backend/app/rest/api/admin_test.go @@ -618,7 +618,7 @@ func TestAdmin_DeleteMeRequest(t *testing.T) { _, err = srv.DataService.Create(c2) assert.Nil(t, err) - comments, err := srv.DataService.User("radio-t", "user1", 0, 0) + comments, err := srv.DataService.User("radio-t", "user1", 0, 0, store.User{}) assert.Nil(t, err) assert.Equal(t, 1, len(comments), "a comment for user1") @@ -655,7 +655,7 @@ func TestAdmin_DeleteMeRequest(t *testing.T) { require.NoError(t, err) assert.Equal(t, 200, resp.StatusCode) - _, err = srv.DataService.User("radio-t", "user1", 0, 0) + _, err = srv.DataService.User("radio-t", "user1", 0, 0, store.User{}) assert.EqualError(t, err, "no comments for user user1 in store") } diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index 946cb6bd..58c2243c 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -307,41 +307,6 @@ func (s *Rest) routes() chi.Router { return router } -func (s *Rest) alterComments(comments []store.Comment, r *http.Request) (res []store.Comment) { - - res = s.adminService.alterComments(comments, r) // apply admin's alteration - - // prepare vote info for client view - vote := func(c store.Comment, r *http.Request) store.Comment { - - c.Vote = 0 // default is "none" (not voted) - - user, err := rest.GetUserInfo(r) - if err != nil { - c.Votes = nil // hide voters list and don't set Vote for non-authed user - return c - } - - if v, ok := c.Votes[user.ID]; ok { - if v { - c.Vote = 1 - } else { - c.Vote = -1 - } - } - - c.Votes = nil // hide voters list - return c - } - - for i, c := range res { - c = vote(c, r) - res[i] = c - } - - return res -} - // updateLimiter returns UpdateLimiter if set, or 10 if not func (s *Rest) updateLimiter() float64 { lmt := 10.0 diff --git a/backend/app/rest/api/rest_private.go b/backend/app/rest/api/rest_private.go index 787ffbd5..58045611 100644 --- a/backend/app/rest/api/rest_private.go +++ b/backend/app/rest/api/rest_private.go @@ -67,7 +67,7 @@ func (s *Rest) createCommentCtrl(w http.ResponseWriter, r *http.Request) { } // DataService modifies comment - finalComment, err := s.DataService.Get(comment.Locator, id) + finalComment, err := s.DataService.Get(comment.Locator, id, rest.GetUserOrEmpty(r)) if err != nil { rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't load created comment", rest.ErrInternal) return @@ -107,7 +107,7 @@ func (s *Rest) updateCommentCtrl(w http.ResponseWriter, r *http.Request) { var currComment store.Comment var err error - if currComment, err = s.DataService.Get(locator, id); err != nil { + if currComment, err = s.DataService.Get(locator, id, rest.GetUserOrEmpty(r)); err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't find comment", rest.ErrCommentNotFound) return } @@ -213,7 +213,7 @@ func (s *Rest) userAllDataCtrl(w http.ResponseWriter, r *http.Request) { // get comments in 100 in each paginated request for i := 0; i < 100; i++ { - comments, errUser := s.DataService.User(siteID, user.ID, 100, i*100) + comments, errUser := s.DataService.User(siteID, user.ID, 100, i*100, rest.GetUserOrEmpty(r)) if errUser != nil { rest.SendErrorJSON(w, r, http.StatusInternalServerError, errUser, "can't get user comments", rest.ErrInternal) return diff --git a/backend/app/rest/api/rest_private_test.go b/backend/app/rest/api/rest_private_test.go index 2f71abb9..20be01e3 100644 --- a/backend/app/rest/api/rest_private_test.go +++ b/backend/app/rest/api/rest_private_test.go @@ -63,7 +63,7 @@ func TestRest_CreateOldPost(t *testing.T) { _, err := srv.DataService.Create(old) assert.Nil(t, err) - comments, err := srv.DataService.Find(store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}, "time") + comments, err := srv.DataService.Find(store.Locator{SiteID: "radio-t", URL: "https://radio-t.com/blah1"}, "time", store.User{}) assert.Nil(t, err) assert.Equal(t, 1, len(comments)) diff --git a/backend/app/rest/api/rest_public.go b/backend/app/rest/api/rest_public.go index 2a795514..5fd1f003 100644 --- a/backend/app/rest/api/rest_public.go +++ b/backend/app/rest/api/rest_public.go @@ -34,15 +34,14 @@ func (s *Rest) findCommentsCtrl(w http.ResponseWriter, r *http.Request) { key := cache.NewKey(locator.SiteID).ID(URLKeyWithUser(r)).Scopes(locator.SiteID, locator.URL) data, err := s.Cache.Get(key, func() ([]byte, error) { - comments, e := s.DataService.Find(locator, sort) + comments, e := s.DataService.Find(locator, sort, rest.GetUserOrEmpty(r)) if e != nil { comments = []store.Comment{} // error should clear comments and continue for post info } - maskedComments := s.alterComments(comments, r) var b []byte switch r.URL.Query().Get("format") { case "tree": - tree := service.MakeTree(maskedComments, sort, s.ReadOnlyAge) + tree := service.MakeTree(comments, sort, s.ReadOnlyAge) if tree.Nodes == nil { // eliminate json nil serialization tree.Nodes = []*service.Node{} } @@ -51,7 +50,7 @@ func (s *Rest) findCommentsCtrl(w http.ResponseWriter, r *http.Request) { } b, e = encodeJSONWithHTML(tree) default: - withInfo := commentsWithInfo{Comments: maskedComments} + withInfo := commentsWithInfo{Comments: comments} if info, ee := s.DataService.Info(locator, s.ReadOnlyAge); ee == nil { withInfo.Info = info } @@ -132,9 +131,9 @@ func (s *Rest) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) { sinceTime := time.Time{} since := r.URL.Query().Get("since") if since != "" { - unixTS, err := strconv.ParseInt(since, 10, 64) - if err != nil { - rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't translate since parameter", rest.ErrDecode) + unixTS, e := strconv.ParseInt(since, 10, 64) + if e != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, e, "can't translate since parameter", rest.ErrDecode) return } sinceTime = time.Unix(unixTS/1000, 1000000*(unixTS%1000)) // since param in msec timestamp @@ -142,11 +141,10 @@ func (s *Rest) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) { key := cache.NewKey(siteID).ID(URLKey(r)).Scopes(lastCommentsScope) data, err := s.Cache.Get(key, func() ([]byte, error) { - comments, e := s.DataService.Last(siteID, limit, sinceTime) + comments, e := s.DataService.Last(siteID, limit, sinceTime, rest.GetUserOrEmpty(r)) if e != nil { return nil, e } - comments = s.alterComments(comments, r) // filter deleted from last comments view. Blocked marked as deleted and will sneak in without filterDeleted := filterComments(comments, func(c store.Comment) bool { return !c.Deleted }) return encodeJSONWithHTML(filterDeleted) @@ -171,12 +169,11 @@ func (s *Rest) commentByIDCtrl(w http.ResponseWriter, r *http.Request) { log.Printf("[DEBUG] get comments by id %s, %s %s", id, siteID, url) - comment, err := s.DataService.Get(store.Locator{SiteID: siteID, URL: url}, id) + comment, err := s.DataService.Get(store.Locator{SiteID: siteID, URL: url}, id, rest.GetUserOrEmpty(r)) if err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get comment by id", rest.ErrCommentNotFound) return } - comment = s.alterComments([]store.Comment{comment}, r)[0] render.Status(r, http.StatusOK) if err = R.RenderJSONWithHTML(w, r, comment); err != nil { @@ -204,11 +201,10 @@ func (s *Rest) findUserCommentsCtrl(w http.ResponseWriter, r *http.Request) { key := cache.NewKey(siteID).ID(URLKeyWithUser(r)).Scopes(userID, siteID) data, err := s.Cache.Get(key, func() ([]byte, error) { - comments, e := s.DataService.User(siteID, userID, limit, 0) + comments, e := s.DataService.User(siteID, userID, limit, 0, rest.GetUserOrEmpty(r)) if e != nil { return nil, e } - comments = s.alterComments(comments, r) comments = filterComments(comments, func(c store.Comment) bool { return !c.Deleted }) count, e := s.DataService.UserCount(siteID, userID) if e != nil { diff --git a/backend/app/rest/api/rss.go b/backend/app/rest/api/rss.go index 62d51b67..47ec8edf 100644 --- a/backend/app/rest/api/rss.go +++ b/backend/app/rest/api/rss.go @@ -28,11 +28,10 @@ func (s *Rest) rssPostCommentsCtrl(w http.ResponseWriter, r *http.Request) { key := cache.NewKey(locator.SiteID).ID(URLKey(r)).Scopes(locator.SiteID, locator.URL) data, err := s.Cache.Get(key, func() ([]byte, error) { - comments, e := s.DataService.Find(locator, "-time") + comments, e := s.DataService.Find(locator, "-time", rest.GetUserOrEmpty(r)) if e != nil { return nil, e } - comments = s.alterComments(comments, r) rss, e := s.toRssFeed(locator.URL, comments, "post comments for "+r.URL.Query().Get("url")) if e != nil { return nil, e @@ -60,11 +59,10 @@ func (s *Rest) rssSiteCommentsCtrl(w http.ResponseWriter, r *http.Request) { key := cache.NewKey(siteID).ID(URLKey(r)).Scopes(siteID, lastCommentsScope) data, err := s.Cache.Get(key, func() ([]byte, error) { - comments, e := s.DataService.Last(siteID, maxRssItems, time.Time{}) + comments, e := s.DataService.Last(siteID, maxRssItems, time.Time{}, rest.GetUserOrEmpty(r)) if e != nil { return nil, e } - comments = s.alterComments(comments, r) rss, e := s.toRssFeed(r.URL.Query().Get("site"), comments, "site comment for "+siteID) if e != nil { @@ -94,11 +92,10 @@ func (s *Rest) rssRepliesCtrl(w http.ResponseWriter, r *http.Request) { userName := "" key := cache.NewKey(siteID).ID(URLKey(r)).Scopes(siteID, lastCommentsScope) data, err := s.Cache.Get(key, func() (res []byte, e error) { - comments, e := s.DataService.Last(siteID, maxLastCommentsReply, time.Time{}) + comments, e := s.DataService.Last(siteID, maxLastCommentsReply, time.Time{}, rest.GetUserOrEmpty(r)) if e != nil { return nil, errors.Wrap(e, "can't get last comments") } - comments = s.alterComments(comments, r) replies := []store.Comment{} for _, c := range comments { if len(replies) > maxRssItems || c.Timestamp.Add(maxReplyDuration).Before(time.Now()) { @@ -109,7 +106,7 @@ func (s *Rest) rssRepliesCtrl(w http.ResponseWriter, r *http.Request) { } if c.ParentID != "" && !c.Deleted && c.User.ID != userID { // not interested in replies to yourself var pc store.Comment - if pc, e = s.DataService.Get(c.Locator, c.ParentID); e != nil { + if pc, e = s.DataService.Get(c.Locator, c.ParentID, rest.GetUserOrEmpty(r)); e != nil { return nil, errors.Wrap(e, "can't get parent comment") } if pc.User.ID == userID { @@ -166,7 +163,7 @@ func (s *Rest) toRssFeed(url string, comments []store.Comment, description strin } if c.ParentID != "" { // add indication to parent comment - parentComment, err := s.DataService.Get(c.Locator, c.ParentID) + parentComment, err := s.DataService.Get(c.Locator, c.ParentID, store.User{}) if err == nil { f.Title = fmt.Sprintf("%s > %s", c.User.Name, parentComment.User.Name) } else { diff --git a/backend/app/rest/user.go b/backend/app/rest/user.go index 7727e137..6ae05fd0 100644 --- a/backend/app/rest/user.go +++ b/backend/app/rest/user.go @@ -36,7 +36,15 @@ func GetUserInfo(r *http.Request) (user store.User, err error) { Verified: u.BoolAttr("verified"), Blocked: u.BoolAttr("blocked"), }, nil +} +// GetUserOrEmpty attempts to get user info from request and returns empty object if failed +func GetUserOrEmpty(r *http.Request) store.User { + user, err := GetUserInfo(r) + if err != nil { + return store.User{} + } + return user } // SetUserInfo sets user into request context diff --git a/backend/app/store/engine/engine.go b/backend/app/store/engine/engine.go index dbbdd654..3fcecb8f 100644 --- a/backend/app/store/engine/engine.go +++ b/backend/app/store/engine/engine.go @@ -10,7 +10,7 @@ import ( "github.com/umputun/remark/backend/app/store" ) -//go:generate sh -c "mockery -inpkg -name Interface -print > file.tmp && mv file.tmp engine_mock.go" +//go:generate sh -c "mockery -inpkg -name Interface -print > /tmp/engine-mock.tmp && mv /tmp/engine-mock.tmp engine_mock.go" // Interface combines all store interfaces type Interface interface { diff --git a/backend/app/store/service/service.go b/backend/app/store/service/service.go index 0d6ee63f..9999ffd8 100644 --- a/backend/app/store/service/service.go +++ b/backend/app/store/service/service.go @@ -1,3 +1,6 @@ +// Package service wraps engine interfaces with common logic unrelated to any particular engine implementation. +// All consumers should be using service.DataStore and not the naked engine! + package service import ( @@ -96,12 +99,48 @@ func (s *DataStore) Create(comment store.Comment) (commentID string, err error) return s.Interface.Create(comment) } +// Find wraps engine's Find call and alter results if needed +func (s *DataStore) Find(locator store.Locator, sort string, user store.User) ([]store.Comment, error) { + comments, err := s.Interface.Find(locator, sort) + if err != nil { + return comments, err + } + + changedSort := false + // set votes controversy for comments added prior to #274 + for i, c := range comments { + if c.Controversy == 0 && len(c.Votes) > 0 { + c.Controversy = s.controversy(s.upsAndDowns(c)) + if !changedSort && strings.Contains(sort, "controversy") { // trigger sort change + changedSort = true + } + } + comments[i] = s.alterComment(c, user) + } + + // resort commits if altered + if changedSort { + comments = engine.SortComments(comments, sort) + } + + return comments, nil +} + +// Get comment by ID +func (s *DataStore) Get(locator store.Locator, commentID string, user store.User) (store.Comment, error) { + c, err := s.Interface.Get(locator, commentID) + if err != nil { + return store.Comment{}, err + } + return s.alterComment(c, user), nil +} + // submitImages initiated delayed commit of all images from the comment uploaded to remark42 func (s *DataStore) submitImages(comment store.Comment) { s.ImageService.Submit(func() []string { c := comment - cc, err := s.Get(c.Locator, c.ID) // this can be called after last edit, we have to retrieve fresh comment + cc, err := s.Interface.Get(c.Locator, c.ID) // this can be called after last edit, we have to retrieve fresh comment if err != nil { log.Printf("[WARN] can't get comment's %s text for image extraction, %v", c.ID, err) return nil @@ -143,7 +182,7 @@ func (s *DataStore) prepareNewComment(comment store.Comment) (store.Comment, err // SetPin pin/un-pin comment as special func (s *DataStore) SetPin(locator store.Locator, commentID string, status bool) error { - comment, err := s.Get(locator, commentID) + comment, err := s.Interface.Get(locator, commentID) if err != nil { return err } @@ -158,7 +197,7 @@ func (s *DataStore) Vote(locator store.Locator, commentID string, userID string, cLock.Lock() // prevents race on voting defer cLock.Unlock() - comment, err = s.Get(locator, commentID) + comment, err = s.Interface.Get(locator, commentID) if err != nil { return comment, err } @@ -246,7 +285,7 @@ type EditRequest struct { // EditComment to edit text and update Edit info func (s *DataStore) EditComment(locator store.Locator, commentID string, req EditRequest) (comment store.Comment, err error) { - comment, err = s.Get(locator, commentID) + comment, err = s.Interface.Get(locator, commentID) if err != nil { return comment, err } @@ -295,7 +334,7 @@ func (s *DataStore) HasReplies(comment store.Comment) bool { return true } - comments, err := s.Last(comment.Locator.SiteID, maxLastCommentsReply, time.Time{}) + comments, err := s.Interface.Last(comment.Locator.SiteID, maxLastCommentsReply, time.Time{}) if err != nil { log.Printf("[WARN] can't get last comments for reply check, %v", err) return false @@ -318,7 +357,7 @@ func (s *DataStore) SetTitle(locator store.Locator, commentID string) (comment s return comment, errors.New("no title extractor") } - comment, err = s.Get(locator, commentID) + comment, err = s.Interface.Get(locator, commentID) if err != nil { return comment, err } @@ -452,30 +491,22 @@ func (s *DataStore) SetMetas(siteID string, umetas []UserMetaData, pmetas []Post return errs.ErrorOrNil() } -// Find wraps engine's Find call and alter results if needed -func (s *DataStore) Find(locator store.Locator, sort string) ([]store.Comment, error) { - comments, err := s.Interface.Find(locator, sort) +// User gets comment for given userID on siteID +func (s *DataStore) User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error) { + comments, err := s.Interface.User(siteID, userID, limit, skip) if err != nil { return comments, err } + return s.alterComments(comments, user), nil +} - changedSort := false - // set votes controversy for comments added prior to #274 - for i, c := range comments { - if c.Controversy == 0 && len(c.Votes) > 0 { - comments[i].Controversy = s.controversy(s.upsAndDowns(c)) - if !changedSort && strings.Contains(sort, "controversy") { // trigger sort change - changedSort = true - } - } +// Last gets last comments for site, cross-post. Limited by count and optional since ts +func (s *DataStore) Last(siteID string, limit int, since time.Time, user store.User) ([]store.Comment, error) { + comments, err := s.Interface.Last(siteID, limit, since) + if err != nil { + return comments, err } - - // resort commits if altered - if changedSort { - comments = engine.SortComments(comments, sort) - } - - return comments, nil + return s.alterComments(comments, user), nil } func (s *DataStore) upsAndDowns(c store.Comment) (ups, downs int) { @@ -503,3 +534,54 @@ func (s *DataStore) getScopedLocks(id string) (lock sync.Locker) { return lock } + +func (s *DataStore) alterComments(cc []store.Comment, user store.User) (res []store.Comment) { + res = make([]store.Comment, len(cc)) + for i, c := range cc { + res[i] = s.alterComment(c, user) + } + return res +} + +func (s *DataStore) alterComment(c store.Comment, user store.User) (res store.Comment) { + + blocked := s.IsBlocked(c.Locator.SiteID, c.User.ID) + // process blocked users + if blocked { + if !user.Admin { // reset comment to deleted for non-admins + c.SetDeleted(store.SoftDelete) + } + c.User.Blocked = true + c.Deleted = true + } + + // set verified status retroactively + if !blocked { + c.User.Verified = s.IsVerified(c.Locator.SiteID, c.User.ID) + } + + // hide info from non-admins + if !user.Admin { + c.User.IP = "" + } + + c = s.prepVotes(c, user) + return c +} + +// prepare vote info for client view +func (s *DataStore) prepVotes(c store.Comment, user store.User) store.Comment { + + c.Vote = 0 // default is "none" (not voted) + + if v, ok := c.Votes[user.ID]; ok { + if v { + c.Vote = 1 + } else { + c.Vote = -1 + } + } + + c.Votes = nil // hide voters list + return c +} diff --git a/backend/app/store/service/service_test.go b/backend/app/store/service/service_test.go index 52f8d041..d6948df0 100644 --- a/backend/app/store/service/service_test.go +++ b/backend/app/store/service/service_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os" + "strconv" "strings" "sync" "sync/atomic" @@ -18,11 +19,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "github.com/umputun/remark/backend/app/store/image" "github.com/umputun/remark/backend/app/store" "github.com/umputun/remark/backend/app/store/admin" "github.com/umputun/remark/backend/app/store/engine" + "github.com/umputun/remark/backend/app/store/image" ) var testDb = "/tmp/test-remark.db" @@ -40,7 +41,7 @@ func TestService_CreateFromEmpty(t *testing.T) { assert.NoError(t, err) assert.True(t, id != "", id) - res, err := b.Get(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, id) + res, err := b.Interface.Get(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, id) assert.NoError(t, err) t.Logf("%+v", res) assert.Equal(t, "text", res.Text) @@ -66,7 +67,7 @@ func TestService_CreateFromPartial(t *testing.T) { assert.NoError(t, err) assert.True(t, id != "", id) - res, err := b.Get(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, id) + res, err := b.Interface.Get(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, id) assert.NoError(t, err) t.Logf("%+v", res) assert.Equal(t, "text", res.Text) @@ -94,7 +95,7 @@ func TestService_CreateFromPartialWithTitle(t *testing.T) { assert.NoError(t, err) assert.True(t, id != "", id) - res, err := b.Get(store.Locator{URL: "https://radio-t.com/p/2018/12/29/podcast-630/", SiteID: "radio-t"}, id) + res, err := b.Interface.Get(store.Locator{URL: "https://radio-t.com/p/2018/12/29/podcast-630/", SiteID: "radio-t"}, id) assert.NoError(t, err) t.Logf("%+v", res) assert.Equal(t, "Радио-Т 630 — Радио-Т Подкаст", res.PostTitle) @@ -102,7 +103,7 @@ func TestService_CreateFromPartialWithTitle(t *testing.T) { comment.PostTitle = "post blah" id, err = b.Create(comment) assert.NoError(t, err) - res, err = b.Get(store.Locator{URL: "https://radio-t.com/p/2018/12/29/podcast-630/", SiteID: "radio-t"}, id) + res, err = b.Interface.Get(store.Locator{URL: "https://radio-t.com/p/2018/12/29/podcast-630/", SiteID: "radio-t"}, id) assert.NoError(t, err) t.Logf("%+v", res) assert.Equal(t, "post blah", res.PostTitle, "keep comment title") @@ -145,7 +146,7 @@ func TestService_SetTitle(t *testing.T) { assert.NoError(t, err) assert.True(t, id != "", id) - res, err := b.Get(store.Locator{URL: tss.URL + "/post1", SiteID: "radio-t"}, id) + res, err := b.Interface.Get(store.Locator{URL: tss.URL + "/post1", SiteID: "radio-t"}, id) assert.NoError(t, err) t.Logf("%+v", res) assert.Equal(t, "", res.PostTitle) @@ -174,7 +175,7 @@ func TestService_Vote(t *testing.T) { _, err := b.Create(comment) assert.NoError(t, err) - res, err := b.Last("radio-t", 0, time.Time{}) + res, err := b.Interface.Last("radio-t", 0, time.Time{}) t.Logf("%+v", res[0]) assert.Nil(t, err) assert.Equal(t, 3, len(res)) @@ -195,7 +196,7 @@ func TestService_Vote(t *testing.T) { assert.NotNil(t, err, "double-voting rejected") assert.True(t, strings.HasPrefix(err.Error(), "user user1 already voted")) - res, err = b.Last("radio-t", 0, time.Time{}) + res, err = b.Interface.Last("radio-t", 0, time.Time{}) assert.Nil(t, err) assert.Equal(t, 3, len(res)) assert.Equal(t, 1, res[0].Score) @@ -204,7 +205,7 @@ func TestService_Vote(t *testing.T) { _, err = b.Vote(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, "user1", false) assert.Nil(t, err, "vote reset") - res, err = b.Last("radio-t", 0, time.Time{}) + res, err = b.Interface.Last("radio-t", 0, time.Time{}) assert.Nil(t, err) assert.Equal(t, 3, len(res)) assert.Equal(t, 0, res[0].Score) @@ -250,7 +251,7 @@ func TestService_VoteAggressive(t *testing.T) { _, err := b.Create(comment) assert.NoError(t, err) - res, err := b.Last("radio-t", 0, time.Time{}) + res, err := b.Interface.Last("radio-t", 0, time.Time{}) require.Nil(t, err) t.Logf("%+v", res[0]) assert.Equal(t, 3, len(res)) @@ -271,7 +272,7 @@ func TestService_VoteAggressive(t *testing.T) { }() } wg.Wait() - res, err = b.Last("radio-t", 0, time.Time{}) + res, err = b.Interface.Last("radio-t", 0, time.Time{}) require.NoError(t, err) t.Logf("%+v", res[0]) @@ -290,7 +291,7 @@ func TestService_VoteAggressive(t *testing.T) { }() } wg.Wait() - res, err = b.Last("radio-t", 0, time.Time{}) + res, err = b.Interface.Last("radio-t", 0, time.Time{}) require.NoError(t, err) assert.Equal(t, 3, len(res)) t.Logf("%+v %d", res[0], res[0].Score) @@ -309,7 +310,7 @@ func TestService_VoteConcurrent(t *testing.T) { } _, err := b.Create(comment) assert.NoError(t, err) - res, err := b.Last("radio-t", 0, time.Time{}) + res, err := b.Interface.Last("radio-t", 0, time.Time{}) require.Nil(t, err) // concurrent vote +1 as multiple users for the same comment @@ -324,7 +325,7 @@ func TestService_VoteConcurrent(t *testing.T) { }() } wg.Wait() - res, err = b.Last("radio-t", 0, time.Time{}) + res, err = b.Interface.Last("radio-t", 0, time.Time{}) require.NoError(t, err) assert.Equal(t, 100, res[0].Score, "should have 100 score") assert.Equal(t, 100, len(res[0].Votes), "should have 100 votes") @@ -370,7 +371,7 @@ func TestService_VoteControversy(t *testing.T) { assert.InDelta(t, 1.73, c.Controversy, 0.01) // check if stored - res, err := b.Last("radio-t", 0, time.Time{}) + res, err := b.Interface.Last("radio-t", 0, time.Time{}) require.NoError(t, err) assert.Equal(t, 1, res[0].Score, "should have 1 score") assert.InDelta(t, 1.73, res[0].Controversy, 0.01) @@ -404,7 +405,7 @@ func TestService_Pin(t *testing.T) { defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123")} - res, err := b.Last("radio-t", 0, time.Time{}) + res, err := b.Interface.Last("radio-t", 0, time.Time{}) t.Logf("%+v", res[0]) assert.Nil(t, err) assert.Equal(t, 2, len(res)) @@ -413,13 +414,13 @@ func TestService_Pin(t *testing.T) { err = b.SetPin(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, true) assert.Nil(t, err) - c, err := b.Get(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID) + c, err := b.Interface.Get(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID) assert.Nil(t, err) assert.Equal(t, true, c.Pin) err = b.SetPin(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, false) assert.Nil(t, err) - c, err = b.Get(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID) + c, err = b.Interface.Get(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID) assert.Nil(t, err) assert.Equal(t, false, c.Pin) } @@ -428,7 +429,7 @@ func TestService_EditComment(t *testing.T) { defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123")} - res, err := b.Last("radio-t", 0, time.Time{}) + res, err := b.Interface.Last("radio-t", 0, time.Time{}) t.Logf("%+v", res[0]) assert.Nil(t, err) assert.Equal(t, 2, len(res)) @@ -441,7 +442,7 @@ func TestService_EditComment(t *testing.T) { assert.Equal(t, "xxx", comment.Text) assert.Equal(t, "yyy", comment.Orig) - c, err := b.Get(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID) + c, err := b.Interface.Get(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID) assert.Nil(t, err) assert.Equal(t, "my edit", c.Edit.Summary) assert.Equal(t, "xxx", c.Text) @@ -455,7 +456,7 @@ func TestService_DeleteComment(t *testing.T) { defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123")} - res, err := b.Last("radio-t", 0, time.Time{}) + res, err := b.Interface.Last("radio-t", 0, time.Time{}) t.Logf("%+v", res[0]) assert.Nil(t, err) assert.Equal(t, 2, len(res)) @@ -464,7 +465,7 @@ func TestService_DeleteComment(t *testing.T) { _, err = b.EditComment(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID, EditRequest{Delete: true}) assert.Nil(t, err) - c, err := b.Get(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID) + c, err := b.Interface.Get(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID) assert.Nil(t, err) assert.True(t, c.Deleted) t.Logf("%+v", c) @@ -474,7 +475,7 @@ func TestService_EditCommentDurationFailed(t *testing.T) { defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), EditDuration: 100 * time.Millisecond, AdminStore: admin.NewStaticKeyStore("secret 123")} - res, err := b.Last("radio-t", 0, time.Time{}) + res, err := b.Interface.Last("radio-t", 0, time.Time{}) t.Logf("%+v", res[0]) assert.Nil(t, err) assert.Equal(t, 2, len(res)) @@ -491,7 +492,7 @@ func TestService_EditCommentReplyFailed(t *testing.T) { defer teardown(t) b := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123")} - res, err := b.Last("radio-t", 0, time.Time{}) + res, err := b.Interface.Last("radio-t", 0, time.Time{}) t.Logf("%+v", res[1]) assert.Nil(t, err) assert.Equal(t, 2, len(res)) @@ -534,6 +535,7 @@ func TestService_ValidateComment(t *testing.T) { assert.Nil(t, e, "check #%d", n) continue } + require.NotNil(t, e) assert.EqualError(t, tt.err, e.Error(), "check #%d", n) } } @@ -669,7 +671,7 @@ func TestService_Find(t *testing.T) { b := DataStore{Interface: prepStoreEngine(t), EditDuration: 100 * time.Millisecond, AdminStore: admin.NewStaticStore("secret 123", []string{"user2"}, "user@email.com")} - res, err := b.Find(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "time") + res, err := b.Find(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "time", store.User{}) require.NoError(t, err) assert.Equal(t, 2, len(res)) @@ -687,7 +689,7 @@ func TestService_Find(t *testing.T) { assert.Nil(t, err) // make sure Controversy altered - res, err = b.Find(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "-controversy") + res, err = b.Find(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "-controversy", store.User{}) require.NoError(t, err) assert.Equal(t, 3, len(res)) assert.Equal(t, "123456", res[0].ID) @@ -722,6 +724,26 @@ func TestService_submitImages(t *testing.T) { time.Sleep(250 * time.Millisecond) } +func TestService_alterComment(t *testing.T) { + defer teardown(t) + svc := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123")} + + tbl := []struct { + c store.Comment + u store.User + r store.Comment + }{ + {store.Comment{}, store.User{}, store.Comment{}}, + {store.Comment{}, store.User{}, store.Comment{}}, + } + for i, tt := range tbl { + t.Run(strconv.Itoa(i), func(t *testing.T) { + r := svc.alterComment(tt.c, tt.u) + assert.Equal(t, tt.r, r) + }) + } +} + // makes new boltdb, put two records func prepStoreEngine(t *testing.T) engine.Interface { _ = os.Remove(testDb) diff --git a/backend/go.mod b/backend/go.mod index 7b424bc5..5d3d8cbc 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -24,8 +24,7 @@ require ( github.com/golang/protobuf v1.3.1 // indirect github.com/google/uuid v0.0.0-20161128191214-064e2069ce9c github.com/gorilla/feeds v1.1.0 - github.com/hashicorp/errwrap v1.0.0 // indirect - github.com/hashicorp/go-multierror v0.0.0-20171204182908-b7773ae21874 + github.com/hashicorp/go-multierror v1.0.0 github.com/hashicorp/golang-lru v0.5.1 // indirect github.com/jessevdk/go-flags v0.0.0-20180331124232-1c38ed7ad0cc github.com/microcosm-cc/bluemonday v0.0.0-20171222152607-542fd4642604 diff --git a/backend/go.sum b/backend/go.sum index 72602d2e..20045e07 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -71,6 +71,8 @@ github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/U github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v0.0.0-20171204182908-b7773ae21874 h1:em+tTnzgU7N22woTBMcSJAOW7tRHAkK597W+MD/CpK8= github.com/hashicorp/go-multierror v0.0.0-20171204182908-b7773ae21874/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I= +github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= @@ -107,6 +109,7 @@ github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/umputun/remark v1.3.2 h1:m0PvvY7GEWSIjfQeqddvglJVswxUoSBPi4RrsI3sJz8= go.etcd.io/bbolt v1.3.2 h1:Z/90sZLPOeCy2PwprqkFa25PdkusRzaj9P8zm/KNyvk= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= diff --git a/backend/vendor/github.com/go-pkgz/mongo/go.sum b/backend/vendor/github.com/go-pkgz/mongo/go.sum index 66ac0631..21efc324 100644 --- a/backend/vendor/github.com/go-pkgz/mongo/go.sum +++ b/backend/vendor/github.com/go-pkgz/mongo/go.sum @@ -1,5 +1,6 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/globalsign/mgo v0.0.0-20180615134936-113d3961e731 h1:y7wyeiA6T+TT+HGC9DYypvLkUeg99N4rqHMzn2MmjYk= github.com/globalsign/mgo v0.0.0-20180615134936-113d3961e731/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= diff --git a/backend/vendor/github.com/hashicorp/golang-lru/go.mod b/backend/vendor/github.com/hashicorp/golang-lru/go.mod index 824cb97e..8ad8826b 100644 --- a/backend/vendor/github.com/hashicorp/golang-lru/go.mod +++ b/backend/vendor/github.com/hashicorp/golang-lru/go.mod @@ -1 +1,3 @@ module github.com/hashicorp/golang-lru + +go 1.12 From b9c80e64d02206db9c30aae4af752db76a784849 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 16 May 2019 21:06:48 -0500 Subject: [PATCH 2/6] add engine mock --- backend/app/store/engine/engine.go | 1 + backend/app/store/engine/engine_mock.go | 408 ++++++++++++++++++++++++ 2 files changed, 409 insertions(+) create mode 100644 backend/app/store/engine/engine_mock.go diff --git a/backend/app/store/engine/engine.go b/backend/app/store/engine/engine.go index 3fcecb8f..9eccce65 100644 --- a/backend/app/store/engine/engine.go +++ b/backend/app/store/engine/engine.go @@ -10,6 +10,7 @@ import ( "github.com/umputun/remark/backend/app/store" ) +// NOTE: mockery works from linked to go-path and with GOFLAGS='-mod=vendor' go generate //go:generate sh -c "mockery -inpkg -name Interface -print > /tmp/engine-mock.tmp && mv /tmp/engine-mock.tmp engine_mock.go" // Interface combines all store interfaces diff --git a/backend/app/store/engine/engine_mock.go b/backend/app/store/engine/engine_mock.go new file mode 100644 index 00000000..e274d538 --- /dev/null +++ b/backend/app/store/engine/engine_mock.go @@ -0,0 +1,408 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. +package engine + +import mock "github.com/stretchr/testify/mock" +import store "github.com/umputun/remark/backend/app/store" +import time "time" + +// MockInterface is an autogenerated mock type for the Interface type +type MockInterface struct { + mock.Mock +} + +// Blocked provides a mock function with given fields: siteID +func (_m *MockInterface) Blocked(siteID string) ([]store.BlockedUser, error) { + ret := _m.Called(siteID) + + var r0 []store.BlockedUser + if rf, ok := ret.Get(0).(func(string) []store.BlockedUser); ok { + r0 = rf(siteID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]store.BlockedUser) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(siteID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Close provides a mock function with given fields: +func (_m *MockInterface) Close() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Count provides a mock function with given fields: locator +func (_m *MockInterface) Count(locator store.Locator) (int, error) { + ret := _m.Called(locator) + + var r0 int + if rf, ok := ret.Get(0).(func(store.Locator) int); ok { + r0 = rf(locator) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(store.Locator) error); ok { + r1 = rf(locator) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Create provides a mock function with given fields: comment +func (_m *MockInterface) Create(comment store.Comment) (string, error) { + ret := _m.Called(comment) + + var r0 string + if rf, ok := ret.Get(0).(func(store.Comment) string); ok { + r0 = rf(comment) + } else { + r0 = ret.Get(0).(string) + } + + var r1 error + if rf, ok := ret.Get(1).(func(store.Comment) error); ok { + r1 = rf(comment) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: locator, commentID, mode +func (_m *MockInterface) Delete(locator store.Locator, commentID string, mode store.DeleteMode) error { + ret := _m.Called(locator, commentID, mode) + + var r0 error + if rf, ok := ret.Get(0).(func(store.Locator, string, store.DeleteMode) error); ok { + r0 = rf(locator, commentID, mode) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteAll provides a mock function with given fields: siteID +func (_m *MockInterface) DeleteAll(siteID string) error { + ret := _m.Called(siteID) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(siteID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteUser provides a mock function with given fields: siteID, userID +func (_m *MockInterface) DeleteUser(siteID string, userID string) error { + ret := _m.Called(siteID, userID) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(siteID, userID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Find provides a mock function with given fields: locator, sort +func (_m *MockInterface) Find(locator store.Locator, sort string) ([]store.Comment, error) { + ret := _m.Called(locator, sort) + + var r0 []store.Comment + if rf, ok := ret.Get(0).(func(store.Locator, string) []store.Comment); ok { + r0 = rf(locator, sort) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]store.Comment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(store.Locator, string) error); ok { + r1 = rf(locator, sort) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Get provides a mock function with given fields: locator, commentID +func (_m *MockInterface) Get(locator store.Locator, commentID string) (store.Comment, error) { + ret := _m.Called(locator, commentID) + + var r0 store.Comment + if rf, ok := ret.Get(0).(func(store.Locator, string) store.Comment); ok { + r0 = rf(locator, commentID) + } else { + r0 = ret.Get(0).(store.Comment) + } + + var r1 error + if rf, ok := ret.Get(1).(func(store.Locator, string) error); ok { + r1 = rf(locator, commentID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Info provides a mock function with given fields: locator, readonlyAge +func (_m *MockInterface) Info(locator store.Locator, readonlyAge int) (store.PostInfo, error) { + ret := _m.Called(locator, readonlyAge) + + var r0 store.PostInfo + if rf, ok := ret.Get(0).(func(store.Locator, int) store.PostInfo); ok { + r0 = rf(locator, readonlyAge) + } else { + r0 = ret.Get(0).(store.PostInfo) + } + + var r1 error + if rf, ok := ret.Get(1).(func(store.Locator, int) error); ok { + r1 = rf(locator, readonlyAge) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// IsBlocked provides a mock function with given fields: siteID, userID +func (_m *MockInterface) IsBlocked(siteID string, userID string) bool { + ret := _m.Called(siteID, userID) + + var r0 bool + if rf, ok := ret.Get(0).(func(string, string) bool); ok { + r0 = rf(siteID, userID) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// IsReadOnly provides a mock function with given fields: locator +func (_m *MockInterface) IsReadOnly(locator store.Locator) bool { + ret := _m.Called(locator) + + var r0 bool + if rf, ok := ret.Get(0).(func(store.Locator) bool); ok { + r0 = rf(locator) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// IsVerified provides a mock function with given fields: siteID, userID +func (_m *MockInterface) IsVerified(siteID string, userID string) bool { + ret := _m.Called(siteID, userID) + + var r0 bool + if rf, ok := ret.Get(0).(func(string, string) bool); ok { + r0 = rf(siteID, userID) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// Last provides a mock function with given fields: siteID, limit, since +func (_m *MockInterface) Last(siteID string, limit int, since time.Time) ([]store.Comment, error) { + ret := _m.Called(siteID, limit, since) + + var r0 []store.Comment + if rf, ok := ret.Get(0).(func(string, int, time.Time) []store.Comment); ok { + r0 = rf(siteID, limit, since) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]store.Comment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, int, time.Time) error); ok { + r1 = rf(siteID, limit, since) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// List provides a mock function with given fields: siteID, limit, skip +func (_m *MockInterface) List(siteID string, limit int, skip int) ([]store.PostInfo, error) { + ret := _m.Called(siteID, limit, skip) + + var r0 []store.PostInfo + if rf, ok := ret.Get(0).(func(string, int, int) []store.PostInfo); ok { + r0 = rf(siteID, limit, skip) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]store.PostInfo) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, int, int) error); ok { + r1 = rf(siteID, limit, skip) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Put provides a mock function with given fields: locator, comment +func (_m *MockInterface) Put(locator store.Locator, comment store.Comment) error { + ret := _m.Called(locator, comment) + + var r0 error + if rf, ok := ret.Get(0).(func(store.Locator, store.Comment) error); ok { + r0 = rf(locator, comment) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SetBlock provides a mock function with given fields: siteID, userID, status, ttl +func (_m *MockInterface) SetBlock(siteID string, userID string, status bool, ttl time.Duration) error { + ret := _m.Called(siteID, userID, status, ttl) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, bool, time.Duration) error); ok { + r0 = rf(siteID, userID, status, ttl) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SetReadOnly provides a mock function with given fields: locator, status +func (_m *MockInterface) SetReadOnly(locator store.Locator, status bool) error { + ret := _m.Called(locator, status) + + var r0 error + if rf, ok := ret.Get(0).(func(store.Locator, bool) error); ok { + r0 = rf(locator, status) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SetVerified provides a mock function with given fields: siteID, userID, status +func (_m *MockInterface) SetVerified(siteID string, userID string, status bool) error { + ret := _m.Called(siteID, userID, status) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string, bool) error); ok { + r0 = rf(siteID, userID, status) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// User provides a mock function with given fields: siteID, userID, limit, skip +func (_m *MockInterface) User(siteID string, userID string, limit int, skip int) ([]store.Comment, error) { + ret := _m.Called(siteID, userID, limit, skip) + + var r0 []store.Comment + if rf, ok := ret.Get(0).(func(string, string, int, int) []store.Comment); ok { + r0 = rf(siteID, userID, limit, skip) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]store.Comment) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, int, int) error); ok { + r1 = rf(siteID, userID, limit, skip) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// UserCount provides a mock function with given fields: siteID, userID +func (_m *MockInterface) UserCount(siteID string, userID string) (int, error) { + ret := _m.Called(siteID, userID) + + var r0 int + if rf, ok := ret.Get(0).(func(string, string) int); ok { + r0 = rf(siteID, userID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(siteID, userID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Verified provides a mock function with given fields: siteID +func (_m *MockInterface) Verified(siteID string) ([]string, error) { + ret := _m.Called(siteID) + + var r0 []string + if rf, ok := ret.Get(0).(func(string) []string); ok { + r0 = rf(siteID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(siteID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} From a80c62517f9e239d05b55ef3ed9ff63426a84f97 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 16 May 2019 21:41:25 -0500 Subject: [PATCH 3/6] wrap public rest into struct with safe/read-only interface as store --- backend/app/rest/api/admin_test.go | 2 +- backend/app/rest/api/rest.go | 63 ++++++++-- backend/app/rest/api/rest_public.go | 148 ++++++++++++----------- backend/app/rest/api/rest_public_test.go | 2 +- 4 files changed, 129 insertions(+), 86 deletions(-) diff --git a/backend/app/rest/api/admin_test.go b/backend/app/rest/api/admin_test.go index f3bb2ab6..541a6c77 100644 --- a/backend/app/rest/api/admin_test.go +++ b/backend/app/rest/api/admin_test.go @@ -313,7 +313,7 @@ func TestAdmin_Block(t *testing.T) { assert.Equal(t, "", comments.Comments[0].Text) assert.True(t, comments.Comments[0].Deleted) - srv.Cache = &cache.Nop{} // TODO: with lru cache it won't be refreshed and invalidated for long time + srv.pubRest.cache = &cache.Nop{} // TODO: with lru cache it won't be refreshed and invalidated for long time time.Sleep(50 * time.Millisecond) res, code = get(t, ts.URL+"/api/v1/find?site=radio-t&url=https://radio-t.com/blah&sort=+time") assert.Equal(t, 200, code) diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index 58c2243c..5cb3a097 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -60,6 +60,7 @@ type Rest struct { lock sync.Mutex adminService admin + pubRest public } const hardBodyLimit = 1024 * 64 // limit size of body @@ -167,6 +168,16 @@ func (s *Rest) routes() chi.Router { router.Use(middleware.Throttle(1000), middleware.Timeout(60*time.Second)) router.Use(R.AppInfo("remark42", "umputun", s.Version), R.Ping) + s.pubRest = public{ + confFn: s.config, + dataService: s.DataService, + cache: s.Cache, + imageService: s.ImageService, + commentFormatter: s.CommentFormatter, + readOnlyAge: s.ReadOnlyAge, + webRoot: s.WebRoot, + } + s.adminService = admin{ dataService: s.DataService, migrator: s.Migrator, @@ -215,16 +226,16 @@ func (s *Rest) routes() chi.Router { rapi.Group(func(ropen chi.Router) { ropen.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil))) ropen.Use(authMiddleware.Trace, middleware.NoCache, logInfoWithBody) - ropen.Get("/find", s.findCommentsCtrl) - ropen.Get("/id/{id}", s.commentByIDCtrl) - ropen.Get("/comments", s.findUserCommentsCtrl) - ropen.Get("/last/{limit}", s.lastCommentsCtrl) - ropen.Get("/count", s.countCtrl) - ropen.Post("/counts", s.countMultiCtrl) - ropen.Get("/list", s.listCtrl) - ropen.Get("/config", s.configCtrl) - ropen.Post("/preview", s.previewCommentCtrl) - ropen.Get("/info", s.infoCtrl) + ropen.Get("/find", s.pubRest.findCommentsCtrl) + ropen.Get("/id/{id}", s.pubRest.commentByIDCtrl) + ropen.Get("/comments", s.pubRest.findUserCommentsCtrl) + ropen.Get("/last/{limit}", s.pubRest.lastCommentsCtrl) + ropen.Get("/count", s.pubRest.countCtrl) + ropen.Post("/counts", s.pubRest.countMultiCtrl) + ropen.Get("/list", s.pubRest.listCtrl) + ropen.Get("/config", s.pubRest.configCtrl) + ropen.Post("/preview", s.pubRest.previewCommentCtrl) + ropen.Get("/info", s.pubRest.infoCtrl) ropen.Get("/img", s.ImageProxy.Handler) ropen.Route("/rss", func(rrss chi.Router) { @@ -238,7 +249,7 @@ func (s *Rest) routes() chi.Router { rapi.Group(func(ropen chi.Router) { ropen.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil))) ropen.Use(authMiddleware.Trace, logInfoWithBody) - ropen.Get("/picture/{user}/{id}", s.loadPictureCtrl) + ropen.Get("/picture/{user}/{id}", s.pubRest.loadPictureCtrl) }) // protected routes, require auth @@ -298,8 +309,8 @@ func (s *Rest) routes() chi.Router { // open routes on root level router.Group(func(rroot chi.Router) { tollbooth_chi.LimitHandler(tollbooth.NewLimiter(50, nil)) - rroot.Get("/index.html", s.getStartedCtrl) - rroot.Get("/robots.txt", s.getRobotsCtrl) + rroot.Get("/index.html", s.pubRest.getStartedCtrl) + rroot.Get("/robots.txt", s.pubRest.getRobotsCtrl) }) // file server for static content from /web @@ -413,3 +424,29 @@ func rejectAnonUser(next http.Handler) http.Handler { } return http.HandlerFunc(fn) } + +func (s *Rest) config(siteID string) config { + + cnf := config{ + Version: s.Version, + EditDuration: int(s.DataService.EditDuration.Seconds()), + MaxCommentSize: s.DataService.MaxCommentSize, + Admins: s.DataService.AdminStore.Admins(siteID), + AdminEmail: s.DataService.AdminStore.Email(siteID), + LowScore: s.ScoreThresholds.Low, + CriticalScore: s.ScoreThresholds.Critical, + PositiveScore: s.DataService.PositiveScore, + ReadOnlyAge: s.ReadOnlyAge, + MaxImageSize: s.ImageService.Store.SizeLimit(), + } + + cnf.Auth = []string{} + for _, ap := range s.Authenticator.Providers() { + cnf.Auth = append(cnf.Auth, ap.Name()) + } + + if cnf.Admins == nil { // prevent json serialization to nil + cnf.Admins = []string{} + } + return cnf +} diff --git a/backend/app/rest/api/rest_public.go b/backend/app/rest/api/rest_public.go index 5fd1f003..f9c280b6 100644 --- a/backend/app/rest/api/rest_public.go +++ b/backend/app/rest/api/rest_public.go @@ -19,12 +19,53 @@ import ( "github.com/umputun/remark/backend/app/rest" "github.com/umputun/remark/backend/app/store" + "github.com/umputun/remark/backend/app/store/image" "github.com/umputun/remark/backend/app/store/service" ) +type public struct { + dataService pubStore + cache cache.LoadingCache + readOnlyAge int + commentFormatter *store.CommentFormatter + imageService *image.Service + webRoot string + confFn func(siteID string) config +} + +type pubStore interface { + Create(comment store.Comment) (commentID string, err error) + Get(locator store.Locator, commentID string, user store.User) (store.Comment, error) + Find(locator store.Locator, sort string, user store.User) ([]store.Comment, error) + Last(siteID string, limit int, since time.Time, user store.User) ([]store.Comment, error) + User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error) + UserCount(siteID, userID string) (int, error) + Count(locator store.Locator) (int, error) + List(siteID string, limit int, skip int) ([]store.PostInfo, error) + Info(locator store.Locator, readonlyAge int) (store.PostInfo, error) + + ValidateComment(c *store.Comment) error + IsReadOnly(locator store.Locator) bool + Counts(siteID string, postIDs []string) ([]store.PostInfo, error) +} + +type config struct { + Version string `json:"version"` + EditDuration int `json:"edit_duration"` + MaxCommentSize int `json:"max_comment_size"` + Admins []string `json:"admins"` + AdminEmail string `json:"admin_email"` + Auth []string `json:"auth_providers"` + LowScore int `json:"low_score"` + CriticalScore int `json:"critical_score"` + PositiveScore bool `json:"positive_score"` + ReadOnlyAge int `json:"readonly_age"` + MaxImageSize int `json:"max_image_size"` +} + // GET /find?site=siteID&url=post-url&format=[tree|plain]&sort=[+/-time|+/-score|+/-controversy ] // find comments for given post. Returns in tree or plain formats, sorted -func (s *Rest) findCommentsCtrl(w http.ResponseWriter, r *http.Request) { +func (s *public) findCommentsCtrl(w http.ResponseWriter, r *http.Request) { locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} sort := r.URL.Query().Get("sort") if strings.HasPrefix(sort, " ") { // restore + replaced by " " @@ -33,25 +74,25 @@ func (s *Rest) findCommentsCtrl(w http.ResponseWriter, r *http.Request) { log.Printf("[DEBUG] get comments for %+v, sort %s, format %s", locator, sort, r.URL.Query().Get("format")) key := cache.NewKey(locator.SiteID).ID(URLKeyWithUser(r)).Scopes(locator.SiteID, locator.URL) - data, err := s.Cache.Get(key, func() ([]byte, error) { - comments, e := s.DataService.Find(locator, sort, rest.GetUserOrEmpty(r)) + data, err := s.cache.Get(key, func() ([]byte, error) { + comments, e := s.dataService.Find(locator, sort, rest.GetUserOrEmpty(r)) if e != nil { comments = []store.Comment{} // error should clear comments and continue for post info } var b []byte switch r.URL.Query().Get("format") { case "tree": - tree := service.MakeTree(comments, sort, s.ReadOnlyAge) + tree := service.MakeTree(comments, sort, s.readOnlyAge) if tree.Nodes == nil { // eliminate json nil serialization tree.Nodes = []*service.Node{} } - if s.DataService.IsReadOnly(locator) { + if s.dataService.IsReadOnly(locator) { tree.Info.ReadOnly = true } b, e = encodeJSONWithHTML(tree) default: withInfo := commentsWithInfo{Comments: comments} - if info, ee := s.DataService.Info(locator, s.ReadOnlyAge); ee == nil { + if info, ee := s.dataService.Info(locator, s.readOnlyAge); ee == nil { withInfo.Info = info } b, e = encodeJSONWithHTML(withInfo) @@ -70,7 +111,7 @@ func (s *Rest) findCommentsCtrl(w http.ResponseWriter, r *http.Request) { } // POST /preview, body is a comment, returns rendered html -func (s *Rest) previewCommentCtrl(w http.ResponseWriter, r *http.Request) { +func (s *public) previewCommentCtrl(w http.ResponseWriter, r *http.Request) { comment := store.Comment{} if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &comment); err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment", rest.ErrDecode) @@ -84,23 +125,23 @@ func (s *Rest) previewCommentCtrl(w http.ResponseWriter, r *http.Request) { } comment.User = user comment.Orig = comment.Text - if err = s.DataService.ValidateComment(&comment); err != nil { + if err = s.dataService.ValidateComment(&comment); err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation) return } - comment = s.CommentFormatter.Format(comment) + comment = s.commentFormatter.Format(comment) comment.Sanitize() render.HTML(w, r, comment.Text) } // GET /info?site=siteID&url=post-url - get info about the post -func (s *Rest) infoCtrl(w http.ResponseWriter, r *http.Request) { +func (s *public) infoCtrl(w http.ResponseWriter, r *http.Request) { locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} key := cache.NewKey(locator.SiteID).ID(URLKey(r)).Scopes(locator.SiteID, locator.URL) - data, err := s.Cache.Get(key, func() ([]byte, error) { - info, e := s.DataService.Info(locator, s.ReadOnlyAge) + data, err := s.cache.Get(key, func() ([]byte, error) { + info, e := s.dataService.Info(locator, s.readOnlyAge) if e != nil { return nil, e } @@ -119,7 +160,7 @@ func (s *Rest) infoCtrl(w http.ResponseWriter, r *http.Request) { // GET /last/{limit}?site=siteID&since=unix_ts_msec - last comments for the siteID, across all posts, sorted by time, optionally // limited with "since" param -func (s *Rest) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) { +func (s *public) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) { siteID := r.URL.Query().Get("site") log.Printf("[DEBUG] get last comments for %s", siteID) @@ -140,8 +181,8 @@ func (s *Rest) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) { } key := cache.NewKey(siteID).ID(URLKey(r)).Scopes(lastCommentsScope) - data, err := s.Cache.Get(key, func() ([]byte, error) { - comments, e := s.DataService.Last(siteID, limit, sinceTime, rest.GetUserOrEmpty(r)) + data, err := s.cache.Get(key, func() ([]byte, error) { + comments, e := s.dataService.Last(siteID, limit, sinceTime, rest.GetUserOrEmpty(r)) if e != nil { return nil, e } @@ -161,7 +202,7 @@ func (s *Rest) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) { } // GET /id/{id}?site=siteID&url=post-url - gets a comment by id -func (s *Rest) commentByIDCtrl(w http.ResponseWriter, r *http.Request) { +func (s *public) commentByIDCtrl(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") siteID := r.URL.Query().Get("site") @@ -169,7 +210,7 @@ func (s *Rest) commentByIDCtrl(w http.ResponseWriter, r *http.Request) { log.Printf("[DEBUG] get comments by id %s, %s %s", id, siteID, url) - comment, err := s.DataService.Get(store.Locator{SiteID: siteID, URL: url}, id, rest.GetUserOrEmpty(r)) + comment, err := s.dataService.Get(store.Locator{SiteID: siteID, URL: url}, id, rest.GetUserOrEmpty(r)) if err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get comment by id", rest.ErrCommentNotFound) return @@ -182,7 +223,7 @@ func (s *Rest) commentByIDCtrl(w http.ResponseWriter, r *http.Request) { } // GET /comments?site=siteID&user=id - returns comments for given userID -func (s *Rest) findUserCommentsCtrl(w http.ResponseWriter, r *http.Request) { +func (s *public) findUserCommentsCtrl(w http.ResponseWriter, r *http.Request) { userID := r.URL.Query().Get("user") siteID := r.URL.Query().Get("site") @@ -200,13 +241,13 @@ func (s *Rest) findUserCommentsCtrl(w http.ResponseWriter, r *http.Request) { log.Printf("[DEBUG] get comments for userID %s, %s", userID, siteID) key := cache.NewKey(siteID).ID(URLKeyWithUser(r)).Scopes(userID, siteID) - data, err := s.Cache.Get(key, func() ([]byte, error) { - comments, e := s.DataService.User(siteID, userID, limit, 0, rest.GetUserOrEmpty(r)) + data, err := s.cache.Get(key, func() ([]byte, error) { + comments, e := s.dataService.User(siteID, userID, limit, 0, rest.GetUserOrEmpty(r)) if e != nil { return nil, e } comments = filterComments(comments, func(c store.Comment) bool { return !c.Deleted }) - count, e := s.DataService.UserCount(siteID, userID) + count, e := s.dataService.UserCount(siteID, userID) if e != nil { return nil, e } @@ -225,52 +266,17 @@ func (s *Rest) findUserCommentsCtrl(w http.ResponseWriter, r *http.Request) { } // GET /config?site=siteID - returns configuration -func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) { +func (s *public) configCtrl(w http.ResponseWriter, r *http.Request) { siteID := r.URL.Query().Get("site") - - type config struct { - Version string `json:"version"` - EditDuration int `json:"edit_duration"` - MaxCommentSize int `json:"max_comment_size"` - Admins []string `json:"admins"` - AdminEmail string `json:"admin_email"` - Auth []string `json:"auth_providers"` - LowScore int `json:"low_score"` - CriticalScore int `json:"critical_score"` - PositiveScore bool `json:"positive_score"` - ReadOnlyAge int `json:"readonly_age"` - MaxImageSize int `json:"max_image_size"` - } - - cnf := config{ - Version: s.Version, - EditDuration: int(s.DataService.EditDuration.Seconds()), - MaxCommentSize: s.DataService.MaxCommentSize, - Admins: s.DataService.AdminStore.Admins(siteID), - AdminEmail: s.DataService.AdminStore.Email(siteID), - LowScore: s.ScoreThresholds.Low, - CriticalScore: s.ScoreThresholds.Critical, - PositiveScore: s.DataService.PositiveScore, - ReadOnlyAge: s.ReadOnlyAge, - MaxImageSize: s.ImageService.Store.SizeLimit(), - } - - cnf.Auth = []string{} - for _, ap := range s.Authenticator.Providers() { - cnf.Auth = append(cnf.Auth, ap.Name()) - } - - if cnf.Admins == nil { // prevent json serialization to nil - cnf.Admins = []string{} - } + cnf := s.confFn(siteID) render.Status(r, http.StatusOK) render.JSON(w, r, cnf) } // GET /count?site=siteID&url=post-url - get number of comments for given post -func (s *Rest) countCtrl(w http.ResponseWriter, r *http.Request) { +func (s *public) countCtrl(w http.ResponseWriter, r *http.Request) { locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} - count, err := s.DataService.Count(locator) + count, err := s.dataService.Count(locator) if err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get count", rest.ErrPostNotFound) return @@ -279,7 +285,7 @@ func (s *Rest) countCtrl(w http.ResponseWriter, r *http.Request) { } // POST /counts?site=siteID - get number of comments for posts from post body -func (s *Rest) countMultiCtrl(w http.ResponseWriter, r *http.Request) { +func (s *public) countMultiCtrl(w http.ResponseWriter, r *http.Request) { siteID := r.URL.Query().Get("site") posts := []string{} if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &posts); err != nil { @@ -293,8 +299,8 @@ func (s *Rest) countMultiCtrl(w http.ResponseWriter, r *http.Request) { sha := base64.URLEncoding.EncodeToString(h[:]) key := cache.NewKey(siteID).ID(sha).Scopes(siteID) - data, err := s.Cache.Get(key, func() ([]byte, error) { - counts, e := s.DataService.Counts(siteID, posts) + data, err := s.cache.Get(key, func() ([]byte, error) { + counts, e := s.dataService.Counts(siteID, posts) if e != nil { return nil, e } @@ -312,7 +318,7 @@ func (s *Rest) countMultiCtrl(w http.ResponseWriter, r *http.Request) { } // GET /list?site=siteID&limit=50&skip=10 - list posts with comments -func (s *Rest) listCtrl(w http.ResponseWriter, r *http.Request) { +func (s *public) listCtrl(w http.ResponseWriter, r *http.Request) { siteID := r.URL.Query().Get("site") limit, skip := 0, 0 @@ -325,8 +331,8 @@ func (s *Rest) listCtrl(w http.ResponseWriter, r *http.Request) { } key := cache.NewKey(siteID).ID(URLKey(r)).Scopes(siteID) - data, err := s.Cache.Get(key, func() ([]byte, error) { - posts, e := s.DataService.List(siteID, limit, skip) + data, err := s.cache.Get(key, func() ([]byte, error) { + posts, e := s.dataService.List(siteID, limit, skip) if e != nil { return nil, e } @@ -344,7 +350,7 @@ func (s *Rest) listCtrl(w http.ResponseWriter, r *http.Request) { } // GET /picture/{user}/{id} - get picture -func (s *Rest) loadPictureCtrl(w http.ResponseWriter, r *http.Request) { +func (s *public) loadPictureCtrl(w http.ResponseWriter, r *http.Request) { imgContentType := func(img string) string { img = strings.ToLower(img) @@ -360,7 +366,7 @@ func (s *Rest) loadPictureCtrl(w http.ResponseWriter, r *http.Request) { } id := chi.URLParam(r, "user") + "/" + chi.URLParam(r, "id") - imgRdr, size, err := s.ImageService.Load(id) + imgRdr, size, err := s.imageService.Load(id) if err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get image "+id, rest.ErrAssetNotFound) return @@ -391,8 +397,8 @@ func (s *Rest) loadPictureCtrl(w http.ResponseWriter, r *http.Request) { } // GET /index.html - respond to /index.html with the content of getstarted.html under /web root -func (s *Rest) getStartedCtrl(w http.ResponseWriter, r *http.Request) { - data, err := ioutil.ReadFile(path.Join(s.WebRoot, "getstarted.html")) +func (s *public) getStartedCtrl(w http.ResponseWriter, r *http.Request) { + data, err := ioutil.ReadFile(path.Join(s.webRoot, "getstarted.html")) if err != nil { w.WriteHeader(http.StatusNotFound) return @@ -401,7 +407,7 @@ func (s *Rest) getStartedCtrl(w http.ResponseWriter, r *http.Request) { } // GET /robots.txt -func (s *Rest) getRobotsCtrl(w http.ResponseWriter, r *http.Request) { +func (s *public) getRobotsCtrl(w http.ResponseWriter, r *http.Request) { allowed := []string{"/find", "/last", "/id", "/count", "/counts", "/list", "/config", "/img", "/avatar", "/picture"} for i := range allowed { diff --git a/backend/app/rest/api/rest_public_test.go b/backend/app/rest/api/rest_public_test.go index 3334ccc6..91a54313 100644 --- a/backend/app/rest/api/rest_public_test.go +++ b/backend/app/rest/api/rest_public_test.go @@ -453,7 +453,7 @@ func TestRest_Info(t *testing.T) { ts, srv, teardown := startupT(t) defer teardown() - srv.ReadOnlyAge = 10000000 // make sure we don't hit read-only + srv.pubRest.readOnlyAge = 10000000 // make sure we don't hit read-only user := store.User{ID: "user1", Name: "user name 1"} c1 := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", From 622a0bdacf7f241c646d517cb7f62c4529cf8899 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 16 May 2019 23:07:26 -0500 Subject: [PATCH 4/6] move all private rest controllers to private struct, restrict store --- backend/app/rest/api/rest.go | 87 +++++++++++----- backend/app/rest/api/rest_private.go | 120 +++++++++++----------- backend/app/rest/api/rest_private_test.go | 49 +++------ backend/app/rest/api/rest_test.go | 24 +++++ 4 files changed, 161 insertions(+), 119 deletions(-) diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index 5cb3a097..16d6bccd 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -59,8 +59,9 @@ type Rest struct { httpServer *http.Server lock sync.Mutex - adminService admin - pubRest public + pubRest public + privRest private + adminRest admin } const hardBodyLimit = 1024 * 64 // limit size of body @@ -169,16 +170,27 @@ func (s *Rest) routes() chi.Router { router.Use(R.AppInfo("remark42", "umputun", s.Version), R.Ping) s.pubRest = public{ - confFn: s.config, dataService: s.DataService, cache: s.Cache, imageService: s.ImageService, commentFormatter: s.CommentFormatter, readOnlyAge: s.ReadOnlyAge, + confFn: s.config, webRoot: s.WebRoot, } - s.adminService = admin{ + s.privRest = private{ + dataService: s.DataService, + cache: s.Cache, + imageService: s.ImageService, + commentFormatter: s.CommentFormatter, + readOnlyAge: s.ReadOnlyAge, + authenticator: s.Authenticator, + notifyService: s.NotifyService, + remarkURL: s.RemarkURL, + } + + s.adminRest = admin{ dataService: s.DataService, migrator: s.Migrator, cache: s.Cache, @@ -256,8 +268,8 @@ func (s *Rest) routes() chi.Router { rapi.Group(func(rauth chi.Router) { rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil))) rauth.Use(authMiddleware.Auth, middleware.NoCache, logInfoWithBody) - rauth.Get("/user", s.userInfoCtrl) - rauth.Get("/userdata", s.userAllDataCtrl) + rauth.Get("/user", s.privRest.userInfoCtrl) + rauth.Get("/userdata", s.privRest.userAllDataCtrl) }) // admin routes, require auth and admin users only @@ -266,22 +278,22 @@ func (s *Rest) routes() chi.Router { radmin.Use(authMiddleware.Auth, authMiddleware.AdminOnly) radmin.Use(middleware.NoCache, logInfoWithBody) - radmin.Delete("/comment/{id}", s.adminService.deleteCommentCtrl) - radmin.Put("/user/{userid}", s.adminService.setBlockCtrl) - radmin.Delete("/user/{userid}", s.adminService.deleteUserCtrl) - radmin.Get("/user/{userid}", s.adminService.getUserInfoCtrl) - radmin.Get("/deleteme", s.adminService.deleteMeRequestCtrl) - radmin.Put("/verify/{userid}", s.adminService.setVerifyCtrl) - radmin.Put("/pin/{id}", s.adminService.setPinCtrl) - radmin.Get("/blocked", s.adminService.blockedUsersCtrl) - radmin.Put("/readonly", s.adminService.setReadOnlyCtrl) - radmin.Put("/title/{id}", s.adminService.setTitleCtrl) + radmin.Delete("/comment/{id}", s.adminRest.deleteCommentCtrl) + radmin.Put("/user/{userid}", s.adminRest.setBlockCtrl) + radmin.Delete("/user/{userid}", s.adminRest.deleteUserCtrl) + radmin.Get("/user/{userid}", s.adminRest.getUserInfoCtrl) + radmin.Get("/deleteme", s.adminRest.deleteMeRequestCtrl) + radmin.Put("/verify/{userid}", s.adminRest.setVerifyCtrl) + radmin.Put("/pin/{id}", s.adminRest.setPinCtrl) + radmin.Get("/blocked", s.adminRest.blockedUsersCtrl) + radmin.Put("/readonly", s.adminRest.setReadOnlyCtrl) + radmin.Put("/title/{id}", s.adminRest.setTitleCtrl) // migrator - radmin.Get("/export", s.adminService.migrator.exportCtrl) - radmin.Post("/import", s.adminService.migrator.importCtrl) - radmin.Post("/import/form", s.adminService.migrator.importFormCtrl) - radmin.Get("/import/wait", s.adminService.migrator.importWaitCtrl) + radmin.Get("/export", s.adminRest.migrator.exportCtrl) + radmin.Post("/import", s.adminRest.migrator.importCtrl) + radmin.Post("/import/form", s.adminRest.migrator.importFormCtrl) + radmin.Get("/import/wait", s.adminRest.migrator.importWaitCtrl) }) // protected routes, throttled to 10/s by default, controlled by external UpdateLimiter param @@ -291,17 +303,17 @@ func (s *Rest) routes() chi.Router { rauth.Use(middleware.NoCache) rauth.Use(logger.New(logger.Log(log.Default()), logger.WithBody, logger.Prefix("[DEBUG]"), logger.IPfn(ipFn)).Handler) - rauth.Put("/comment/{id}", s.updateCommentCtrl) - rauth.Post("/comment", s.createCommentCtrl) - rauth.With(rejectAnonUser).Put("/vote/{id}", s.voteCtrl) - rauth.With(rejectAnonUser).Post("/deleteme", s.deleteMeCtrl) + rauth.Put("/comment/{id}", s.privRest.updateCommentCtrl) + rauth.Post("/comment", s.privRest.createCommentCtrl) + rauth.With(rejectAnonUser).Put("/vote/{id}", s.privRest.voteCtrl) + rauth.With(rejectAnonUser).Post("/deleteme", s.privRest.deleteMeCtrl) }) rapi.Group(func(rauth chi.Router) { rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(s.updateLimiter(), nil))) rauth.Use(authMiddleware.Auth, rejectAnonUser) rauth.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]"), logger.IPfn(ipFn)).Handler) - rauth.Post("/picture", s.savePictureCtrl) + rauth.Post("/picture", s.privRest.savePictureCtrl) }) }) @@ -450,3 +462,28 @@ func (s *Rest) config(siteID string) config { } return cnf } + +func parseError(err error, defaultCode int) (code int) { + code = defaultCode + + switch { + // voting errors + case strings.Contains(err.Error(), "can not vote for his own comment"): + code = rest.ErrVoteSelf + case strings.Contains(err.Error(), "already voted for"): + code = rest.ErrVoteDbl + case strings.Contains(err.Error(), "maximum number of votes exceeded for comment"): + code = rest.ErrVoteMax + case strings.Contains(err.Error(), "minimal score reached for comment"): + code = rest.ErrVoteMinScore + + // edit errors + case strings.HasPrefix(err.Error(), "too late to edit"): + code = rest.ErrCommentEditExpired + case strings.HasPrefix(err.Error(), "parent comment with reply can't be edited"): + code = rest.ErrCommentEditChanged + + } + + return code +} diff --git a/backend/app/rest/api/rest_private.go b/backend/app/rest/api/rest_private.go index 58045611..c66a3d33 100644 --- a/backend/app/rest/api/rest_private.go +++ b/backend/app/rest/api/rest_private.go @@ -12,19 +12,46 @@ import ( "github.com/dgrijalva/jwt-go" "github.com/go-chi/chi" "github.com/go-chi/render" + "github.com/go-pkgz/auth" "github.com/go-pkgz/auth/token" log "github.com/go-pkgz/lgr" R "github.com/go-pkgz/rest" "github.com/go-pkgz/rest/cache" "github.com/hashicorp/go-multierror" + "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/image" "github.com/umputun/remark/backend/app/store/service" ) +type private struct { + dataService privStore + cache cache.LoadingCache + readOnlyAge int + commentFormatter *store.CommentFormatter + imageService *image.Service + notifyService *notify.Service + authenticator *auth.Service + remarkURL string +} + +type privStore interface { + Create(comment store.Comment) (commentID string, err error) + EditComment(locator store.Locator, commentID string, req service.EditRequest) (comment store.Comment, err error) + Vote(locator store.Locator, commentID string, userID string, val bool) (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) + ValidateComment(c *store.Comment) error + IsVerified(siteID string, userID string) bool + IsReadOnly(locator store.Locator) bool + IsBlocked(siteID string, userID string) bool + Info(locator store.Locator, readonlyAge int) (store.PostInfo, error) +} + // POST /comment - adds comment, resets all immutable fields -func (s *Rest) createCommentCtrl(w http.ResponseWriter, r *http.Request) { +func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) { comment := store.Comment{} if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &comment); err != nil { @@ -39,14 +66,14 @@ func (s *Rest) createCommentCtrl(w http.ResponseWriter, r *http.Request) { comment.User.IP = strings.Split(r.RemoteAddr, ":")[0] comment.Orig = comment.Text // original comment text, prior to md render - if err := s.DataService.ValidateComment(&comment); err != nil { + if err := s.dataService.ValidateComment(&comment); err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation) return } - comment = s.CommentFormatter.Format(comment) + comment = s.commentFormatter.Format(comment) // check if user blocked - if s.adminService.checkBlocked(comment.Locator.SiteID, comment.User) { + if s.dataService.IsBlocked(comment.Locator.SiteID, comment.User.ID) { rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("rejected"), "user blocked", rest.ErrUserBlocked) return } @@ -56,7 +83,7 @@ func (s *Rest) createCommentCtrl(w http.ResponseWriter, r *http.Request) { return } - id, err := s.DataService.Create(comment) + id, err := s.dataService.Create(comment) if err == service.ErrRestrictedWordsFound { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation) return @@ -66,17 +93,17 @@ func (s *Rest) createCommentCtrl(w http.ResponseWriter, r *http.Request) { return } - // DataService modifies comment - finalComment, err := s.DataService.Get(comment.Locator, id, rest.GetUserOrEmpty(r)) + // dataService modifies comment + finalComment, err := s.dataService.Get(comment.Locator, id, rest.GetUserOrEmpty(r)) if err != nil { rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't load created comment", rest.ErrInternal) return } - s.Cache.Flush(cache.Flusher(comment.Locator.SiteID). + 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) + if s.notifyService != nil { + s.notifyService.Submit(finalComment) } log.Printf("[DEBUG] created commend %+v", finalComment) @@ -86,7 +113,7 @@ func (s *Rest) createCommentCtrl(w http.ResponseWriter, r *http.Request) { } // PUT /comment/{id}?site=siteID&url=post-url - update comment -func (s *Rest) updateCommentCtrl(w http.ResponseWriter, r *http.Request) { +func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) { edit := struct { Text string @@ -107,7 +134,7 @@ func (s *Rest) updateCommentCtrl(w http.ResponseWriter, r *http.Request) { var currComment store.Comment var err error - if currComment, err = s.DataService.Get(locator, id, rest.GetUserOrEmpty(r)); err != nil { + if currComment, err = s.dataService.Get(locator, id, rest.GetUserOrEmpty(r)); err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't find comment", rest.ErrCommentNotFound) return } @@ -119,40 +146,40 @@ func (s *Rest) updateCommentCtrl(w http.ResponseWriter, r *http.Request) { } editReq := service.EditRequest{ - Text: s.CommentFormatter.FormatText(edit.Text), + Text: s.commentFormatter.FormatText(edit.Text), Orig: edit.Text, Summary: edit.Summary, Delete: edit.Delete, } - res, err := s.DataService.EditComment(locator, id, editReq) + res, err := s.dataService.EditComment(locator, id, editReq) if err == service.ErrRestrictedWordsFound { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation) return } if err != nil { - code := s.parseError(err, rest.ErrCommentRejected) + code := parseError(err, rest.ErrCommentRejected) rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't update comment", code) return } - s.Cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.SiteID, locator.URL, lastCommentsScope, user.ID)) + s.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.SiteID, locator.URL, lastCommentsScope, user.ID)) render.JSON(w, r, res) } // GET /user?site=siteID - returns user info -func (s *Rest) userInfoCtrl(w http.ResponseWriter, r *http.Request) { +func (s *private) userInfoCtrl(w http.ResponseWriter, r *http.Request) { user := rest.MustGetUserInfo(r) if siteID := r.URL.Query().Get("site"); siteID != "" { - user.Verified = s.DataService.IsVerified(siteID, user.ID) + user.Verified = s.dataService.IsVerified(siteID, user.ID) } render.JSON(w, r, user) } // PUT /vote/{id}?site=siteID&url=post-url&vote=1 - vote for/against comment -func (s *Rest) voteCtrl(w http.ResponseWriter, r *http.Request) { +func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) { user := rest.MustGetUserInfo(r) locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")} id := chi.URLParam(r, "id") @@ -166,23 +193,23 @@ func (s *Rest) voteCtrl(w http.ResponseWriter, r *http.Request) { } // check if user blocked - if s.adminService.checkBlocked(locator.SiteID, user) { + if s.dataService.IsBlocked(locator.SiteID, user.ID) { rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("rejected"), "user blocked", rest.ErrUserBlocked) return } - comment, err := s.DataService.Vote(locator, id, user.ID, vote) + comment, err := s.dataService.Vote(locator, id, user.ID, vote) if err != nil { - code := s.parseError(err, rest.ErrVoteRejected) + code := parseError(err, rest.ErrVoteRejected) rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't vote for comment", code) return } - s.Cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, comment.User.ID)) + s.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, comment.User.ID)) render.JSON(w, r, R.JSON{"id": comment.ID, "score": comment.Score}) } // GET /userdata?site=siteID - exports all data about the user as a json with user info and list of all comments -func (s *Rest) userAllDataCtrl(w http.ResponseWriter, r *http.Request) { +func (s *private) userAllDataCtrl(w http.ResponseWriter, r *http.Request) { siteID := r.URL.Query().Get("site") user := rest.MustGetUserInfo(r) userB, err := json.Marshal(&user) @@ -213,7 +240,7 @@ func (s *Rest) userAllDataCtrl(w http.ResponseWriter, r *http.Request) { // get comments in 100 in each paginated request for i := 0; i < 100; i++ { - comments, errUser := s.DataService.User(siteID, user.ID, 100, i*100, rest.GetUserOrEmpty(r)) + comments, errUser := s.dataService.User(siteID, user.ID, 100, i*100, rest.GetUserOrEmpty(r)) if errUser != nil { rest.SendErrorJSON(w, r, http.StatusInternalServerError, errUser, "can't get user comments", rest.ErrInternal) return @@ -240,7 +267,7 @@ func (s *Rest) userAllDataCtrl(w http.ResponseWriter, r *http.Request) { // POST /deleteme?site_id=site - requesting delete of all user info // makes jwt with user info and sends it back as a part of json response -func (s *Rest) deleteMeCtrl(w http.ResponseWriter, r *http.Request) { +func (s *private) deleteMeCtrl(w http.ResponseWriter, r *http.Request) { user := rest.MustGetUserInfo(r) siteID := r.URL.Query().Get("site") @@ -260,18 +287,18 @@ func (s *Rest) deleteMeCtrl(w http.ResponseWriter, r *http.Request) { }, } - tokenStr, err := s.Authenticator.TokenService().Token(claims) + tokenStr, err := s.authenticator.TokenService().Token(claims) if err != nil { rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't make token", rest.ErrInternal) return } - link := fmt.Sprintf("%s/web/deleteme.html?token=%s", s.RemarkURL, tokenStr) + link := fmt.Sprintf("%s/web/deleteme.html?token=%s", s.remarkURL, tokenStr) render.JSON(w, r, R.JSON{"site": siteID, "user_id": user.ID, "token": tokenStr, "link": link}) } // POST /image - save image with form request -func (s *Rest) savePictureCtrl(w http.ResponseWriter, r *http.Request) { +func (s *private) savePictureCtrl(w http.ResponseWriter, r *http.Request) { user := rest.MustGetUserInfo(r) if err := r.ParseMultipartForm(5 * 1024 * 1024); err != nil { // 5M max memory, if bigger will make a file @@ -286,7 +313,7 @@ func (s *Rest) savePictureCtrl(w http.ResponseWriter, r *http.Request) { } defer func() { _ = file.Close() }() - id, err := s.ImageService.Save(header.Filename, user.ID, file) + id, err := s.imageService.Save(header.Filename, user.ID, file) if err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't save image", rest.ErrInternal) return @@ -295,37 +322,12 @@ func (s *Rest) savePictureCtrl(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, R.JSON{"id": id}) } -func (s *Rest) isReadOnly(locator store.Locator) bool { - if s.ReadOnlyAge > 0 { +func (s *private) isReadOnly(locator store.Locator) bool { + if s.readOnlyAge > 0 { // check RO by age - if info, e := s.DataService.Info(locator, s.ReadOnlyAge); e == nil && info.ReadOnly { + if info, e := s.dataService.Info(locator, s.readOnlyAge); e == nil && info.ReadOnly { return true } } - return s.DataService.IsReadOnly(locator) // ro manually -} - -func (s *Rest) parseError(err error, defaultCode int) (code int) { - code = defaultCode - - switch { - // voting errors - case strings.Contains(err.Error(), "can not vote for his own comment"): - code = rest.ErrVoteSelf - case strings.Contains(err.Error(), "already voted for"): - code = rest.ErrVoteDbl - case strings.Contains(err.Error(), "maximum number of votes exceeded for comment"): - code = rest.ErrVoteMax - case strings.Contains(err.Error(), "minimal score reached for comment"): - code = rest.ErrVoteMinScore - - // edit errors - case strings.HasPrefix(err.Error(), "too late to edit"): - code = rest.ErrCommentEditExpired - case strings.HasPrefix(err.Error(), "parent comment with reply can't be edited"): - code = rest.ErrCommentEditChanged - - } - - return code + return s.dataService.IsReadOnly(locator) // ro manually } diff --git a/backend/app/rest/api/rest_private_test.go b/backend/app/rest/api/rest_private_test.go index 20be01e3..7059bdb3 100644 --- a/backend/app/rest/api/rest_private_test.go +++ b/backend/app/rest/api/rest_private_test.go @@ -11,17 +11,14 @@ import ( "mime/multipart" "net/http" "os" - "strconv" "strings" "testing" "time" "github.com/go-pkgz/lgr" R "github.com/go-pkgz/rest" - "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/umputun/remark/backend/app/rest" "github.com/umputun/remark/backend/app/store" "github.com/umputun/remark/backend/app/store/image" @@ -620,16 +617,21 @@ func TestRest_CreateWithPictures(t *testing.T) { }() lgr.Setup(lgr.Debug, lgr.CallerFile, lgr.CallerFunc) - svc.ImageService = &image.Service{ - Store: &image.FileSystem{ - Staging: "/tmp/remark42/images.staging", - Location: "/tmp/remark42/images", - MaxSize: 2000, - }, - TTL: time.Millisecond * 100, + imageService := svc.ImageService + imageService.Store = &image.FileSystem{ + Staging: "/tmp/remark42/images.staging", + Location: "/tmp/remark42/images", + MaxSize: 2000, } - svc.DataService.EditDuration = time.Millisecond * 100 - svc.DataService.ImageService = svc.ImageService + imageService.TTL = 100 * time.Millisecond + + svc.privRest.imageService = imageService + svc.ImageService = imageService + + dataService := svc.DataService + dataService.EditDuration = time.Millisecond * 100 + dataService.ImageService = svc.ImageService + svc.privRest.dataService = dataService uploadPicture := func(file string) (id string) { bodyBuf := &bytes.Buffer{} @@ -683,26 +685,3 @@ func TestRest_CreateWithPictures(t *testing.T) { _, err = os.Stat("/tmp/remark42/images/" + id3) assert.NoError(t, err, "moved from staging") } - -func TestRest_parseError(t *testing.T) { - tbl := []struct { - err error - res int - }{ - {errors.New("can not vote for his own comment"), rest.ErrVoteSelf}, - {errors.New("already voted for"), rest.ErrVoteDbl}, - {errors.New("maximum number of votes exceeded for comment"), rest.ErrVoteMax}, - {errors.New("minimal score reached for comment"), rest.ErrVoteMinScore}, - {errors.New("too late to edit"), rest.ErrCommentEditExpired}, - {errors.New("parent comment with reply can't be edited"), rest.ErrCommentEditChanged}, - {errors.New("blah blah"), rest.ErrInternal}, - } - - svc := Rest{} - for n, tt := range tbl { - t.Run(strconv.Itoa(n), func(t *testing.T) { - res := svc.parseError(tt.err, rest.ErrInternal) - assert.Equal(t, tt.res, res) - }) - } -} diff --git a/backend/app/rest/api/rest_test.go b/backend/app/rest/api/rest_test.go index a4a432ff..e0af2b0a 100644 --- a/backend/app/rest/api/rest_test.go +++ b/backend/app/rest/api/rest_test.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/tls" "encoding/json" + "errors" "fmt" "io/ioutil" "math/rand" @@ -249,6 +250,29 @@ func Test_URLKeyWithUser(t *testing.T) { } } + +func TestRest_parseError(t *testing.T) { + tbl := []struct { + err error + res int + }{ + {errors.New("can not vote for his own comment"), rest.ErrVoteSelf}, + {errors.New("already voted for"), rest.ErrVoteDbl}, + {errors.New("maximum number of votes exceeded for comment"), rest.ErrVoteMax}, + {errors.New("minimal score reached for comment"), rest.ErrVoteMinScore}, + {errors.New("too late to edit"), rest.ErrCommentEditExpired}, + {errors.New("parent comment with reply can't be edited"), rest.ErrCommentEditChanged}, + {errors.New("blah blah"), rest.ErrInternal}, + } + + for n, tt := range tbl { + t.Run(strconv.Itoa(n), func(t *testing.T) { + res := parseError(tt.err, rest.ErrInternal) + assert.Equal(t, tt.res, res) + }) + } +} + func startupT(t *testing.T) (ts *httptest.Server, srv *Rest, teardown func()) { testDb := fmt.Sprintf("/tmp/test-remark-%d.db", rand.Int31()) From cc37fbef13949de3af735185f3c0f8b60634b1b5 Mon Sep 17 00:00:00 2001 From: Umputun Date: Thu, 16 May 2019 23:16:08 -0500 Subject: [PATCH 5/6] switch admin rest to interface with subset of store api --- backend/app/rest/api/admin.go | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/backend/app/rest/api/admin.go b/backend/app/rest/api/admin.go index 59b3b979..dec80e92 100644 --- a/backend/app/rest/api/admin.go +++ b/backend/app/rest/api/admin.go @@ -15,18 +15,31 @@ import ( "github.com/umputun/remark/backend/app/rest" "github.com/umputun/remark/backend/app/store" - "github.com/umputun/remark/backend/app/store/service" ) // admin provides router for all requests available for admin users only type admin struct { - dataService *service.DataStore + dataService adminStore cache cache.LoadingCache authenticator *auth.Service readOnlyAge int migrator *Migrator } +type adminStore interface { + Delete(locator store.Locator, commentID string, mode store.DeleteMode) error + DeleteUser(siteID string, userID string) error + User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error) + IsBlocked(siteID string, userID string) bool + SetBlock(siteID string, userID string, status bool, ttl time.Duration) error + Blocked(siteID string) ([]store.BlockedUser, error) + Info(locator store.Locator, readonlyAge int) (store.PostInfo, error) + SetTitle(locator store.Locator, commentID string) (comment store.Comment, err error) + SetVerified(siteID string, userID string, status bool) error + SetReadOnly(locator store.Locator, status bool) error + SetPin(locator store.Locator, commentID string, status bool) error +} + // DELETE /comment/{id}?site=siteID&url=post-url - removes comment func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) { @@ -218,7 +231,3 @@ func (a *admin) setPinCtrl(w http.ResponseWriter, r *http.Request) { a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL)) render.JSON(w, r, R.JSON{"id": commentID, "locator": locator, "pin": pinStatus}) } - -func (a *admin) checkBlocked(siteID string, user store.User) bool { - return a.dataService.IsBlocked(siteID, user.ID) -} From 00166fe57c812835e1fb52cbe6a061ec9d594e63 Mon Sep 17 00:00:00 2001 From: Umputun Date: Fri, 17 May 2019 02:40:38 -0500 Subject: [PATCH 6/6] add test to alter comment --- backend/app/store/service/service_test.go | 42 ++++++++++++++--------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/backend/app/store/service/service_test.go b/backend/app/store/service/service_test.go index d6948df0..01cd7031 100644 --- a/backend/app/store/service/service_test.go +++ b/backend/app/store/service/service_test.go @@ -6,7 +6,6 @@ import ( "net/http" "net/http/httptest" "os" - "strconv" "strings" "sync" "sync/atomic" @@ -726,22 +725,33 @@ func TestService_submitImages(t *testing.T) { func TestService_alterComment(t *testing.T) { defer teardown(t) - svc := DataStore{Interface: prepStoreEngine(t), AdminStore: admin.NewStaticKeyStore("secret 123")} - tbl := []struct { - c store.Comment - u store.User - r store.Comment - }{ - {store.Comment{}, store.User{}, store.Comment{}}, - {store.Comment{}, store.User{}, store.Comment{}}, - } - for i, tt := range tbl { - t.Run(strconv.Itoa(i), func(t *testing.T) { - r := svc.alterComment(tt.c, tt.u) - assert.Equal(t, tt.r, r) - }) - } + engineMock := engine.MockInterface{} + engineMock.On("IsBlocked", mock.Anything, mock.Anything).Return(false) + engineMock.On("IsVerified", mock.Anything, mock.Anything).Return(false) + svc := DataStore{Interface: &engineMock} + + r := svc.alterComment(store.Comment{ID: "123", User: store.User{IP: "127.0.0.1"}}, store.User{Name: "dev", Admin: false}) + assert.Equal(t, store.Comment{ID: "123", User: store.User{IP: ""}}, r, "ip cleaned") + r = svc.alterComment(store.Comment{ID: "123", User: store.User{IP: "127.0.0.1"}}, store.User{Name: "dev", Admin: true}) + assert.Equal(t, store.Comment{ID: "123", User: store.User{IP: "127.0.0.1"}}, r, "ip not cleaned") + + engineMock = engine.MockInterface{} + engineMock.On("IsBlocked", mock.Anything, mock.Anything).Return(false) + engineMock.On("IsVerified", mock.Anything, mock.Anything).Return(true) + svc = DataStore{Interface: &engineMock} + r = svc.alterComment(store.Comment{ID: "123", User: store.User{IP: "127.0.0.1", Verified: true}}, + store.User{Name: "dev", Admin: false}) + assert.Equal(t, store.Comment{ID: "123", User: store.User{IP: "", Verified: true}}, r, "verified set") + + engineMock = engine.MockInterface{} + engineMock.On("IsBlocked", mock.Anything, mock.Anything).Return(true) + engineMock.On("IsVerified", mock.Anything, mock.Anything).Return(false) + svc = DataStore{Interface: &engineMock} + r = svc.alterComment(store.Comment{ID: "123", User: store.User{IP: "127.0.0.1", Verified: true}}, + store.User{Name: "dev", Admin: false}) + assert.Equal(t, store.Comment{ID: "123", User: store.User{IP: "", Verified: true, Blocked: true}, Deleted: true}, r, + "blocked") } // makes new boltdb, put two records