Merge pull request #331 from umputun/alter

Alter
This commit is contained in:
Umputun
2019-05-17 02:50:32 -05:00
committed by GitHub
26 changed files with 927 additions and 368 deletions
+1 -1
View File
@@ -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")
+3 -1
View File
@@ -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)
+4 -3
View File
@@ -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")
}
+1 -1
View File
@@ -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
}
+8 -5
View File
@@ -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))
}
+1 -1
View File
@@ -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")
+2 -2
View File
@@ -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
}
}
+1 -1
View File
@@ -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")
+16 -42
View File
@@ -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) {
@@ -67,7 +80,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
@@ -218,42 +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)
}
// 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
}
+3 -3
View File
@@ -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)
@@ -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")
}
+110 -71
View File
@@ -59,7 +59,9 @@ type Rest struct {
httpServer *http.Server
lock sync.Mutex
adminService admin
pubRest public
privRest private
adminRest admin
}
const hardBodyLimit = 1024 * 64 // limit size of body
@@ -167,7 +169,28 @@ 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.adminService = admin{
s.pubRest = public{
dataService: s.DataService,
cache: s.Cache,
imageService: s.ImageService,
commentFormatter: s.CommentFormatter,
readOnlyAge: s.ReadOnlyAge,
confFn: s.config,
webRoot: s.WebRoot,
}
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,
@@ -215,16 +238,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,15 +261,15 @@ 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
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
@@ -255,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
@@ -280,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)
})
})
@@ -298,8 +321,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
@@ -307,41 +330,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
@@ -448,3 +436,54 @@ 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
}
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
}
+61 -59
View File
@@ -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)
// 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); 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)
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
}
+15 -36
View File
@@ -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"
@@ -63,7 +60,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))
@@ -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)
})
}
}
+81 -79
View File
@@ -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,26 +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)
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
}
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{}
}
if s.DataService.IsReadOnly(locator) {
if s.dataService.IsReadOnly(locator) {
tree.Info.ReadOnly = true
}
b, e = encodeJSONWithHTML(tree)
default:
withInfo := commentsWithInfo{Comments: maskedComments}
if info, ee := s.DataService.Info(locator, s.ReadOnlyAge); ee == nil {
withInfo := commentsWithInfo{Comments: comments}
if info, ee := s.dataService.Info(locator, s.readOnlyAge); ee == nil {
withInfo.Info = info
}
b, e = encodeJSONWithHTML(withInfo)
@@ -71,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)
@@ -85,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
}
@@ -120,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)
@@ -132,21 +172,20 @@ 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
}
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)
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
}
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)
@@ -163,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")
@@ -171,12 +210,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 {
@@ -185,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")
@@ -203,14 +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)
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 = s.alterComments(comments, r)
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
}
@@ -229,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
@@ -283,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 {
@@ -297,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
}
@@ -316,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
@@ -329,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
}
@@ -348,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)
@@ -364,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
@@ -395,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
@@ -405,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 {
+1 -1
View File
@@ -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",
+24
View File
@@ -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())
+5 -8
View File
@@ -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 {
+8
View File
@@ -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
+2 -1
View File
@@ -10,7 +10,8 @@ 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"
// 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
type Interface interface {
+408
View File
@@ -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
}
+107 -25
View File
@@ -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
}
+58 -26
View File
@@ -18,11 +18,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 +40,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 +66,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 +94,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 +102,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 +145,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 +174,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 +195,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 +204,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 +250,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 +271,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 +290,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 +309,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 +324,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 +370,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 +404,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 +413,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 +428,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 +441,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 +455,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 +464,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 +474,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 +491,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 +534,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 +670,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 +688,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 +723,37 @@ func TestService_submitImages(t *testing.T) {
time.Sleep(250 * time.Millisecond)
}
func TestService_alterComment(t *testing.T) {
defer teardown(t)
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
func prepStoreEngine(t *testing.T) engine.Interface {
_ = os.Remove(testDb)
+1 -2
View File
@@ -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
+3
View File
@@ -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=
+1
View File
@@ -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=
+2
View File
@@ -1 +1,3 @@
module github.com/hashicorp/golang-lru
go 1.12