diff --git a/README.md b/README.md index a4dd84bb..0de5c99f 100644 --- a/README.md +++ b/README.md @@ -428,13 +428,13 @@ Sort can be `time`, `active` or `score`. Supported sort order with prefix -/+, i ### Admin * `DELETE /api/v1/admin/comment/{id}?site=site-id&url=post-url` - delete comment by `id`. -* `PUT /api/v1/admin/user/{userid}?site=site-id&block=1` - block or unblock user. -* `GET api/v1/admin/blocked&site=site-id` - list of blocked user ids. +* `PUT /api/v1/admin/user/{userid}?site=site-id&block=1&ttl=7d` - block or unblock user with optional ttl (default=permanent) +* `GET api/v1/admin/blocked&site=site-id` - list of blocked user ids ```go type BlockedUser struct { ID string `json:"id"` Name string `json:"name"` - Timestamp time.Time `json:"time"` + Until time.Time `json:"time"` } ``` * `GET /api/v1/admin/export?site=side-id&mode=[stream|file]` - export all comments to json stream or gz file. diff --git a/backend/app/rest/api/admin.go b/backend/app/rest/api/admin.go index 037b17b8..5bbcbc5c 100644 --- a/backend/app/rest/api/admin.go +++ b/backend/app/rest/api/admin.go @@ -117,13 +117,20 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, JSON{"user_id": claims.User.ID, "site_id": claims.SiteID}) } -// PUT /user/{userid}?site=side-id&block=1 - block or unblock user +// PUT /user/{userid}?site=side-id&block=1&ttl=7d - block or unblock user func (a *admin) setBlockCtrl(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userid") siteID := r.URL.Query().Get("site") blockStatus := r.URL.Query().Get("block") == "1" - if err := a.dataService.SetBlock(siteID, userID, blockStatus); err != nil { + ttl := time.Duration(0) // unlimited duration by default + if ttlParam := r.URL.Query().Get("ttl"); ttlParam != "" { + if d, err := time.ParseDuration(ttlParam); err == nil { + ttl = d + } + } + + if err := a.dataService.SetBlock(siteID, userID, blockStatus, ttl); err != nil { rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set blocking status") return } diff --git a/backend/app/rest/api/admin_test.go b/backend/app/rest/api/admin_test.go index 73e36bc1..dd10f4c7 100644 --- a/backend/app/rest/api/admin_test.go +++ b/backend/app/rest/api/admin_test.go @@ -161,10 +161,13 @@ func TestAdmin_Block(t *testing.T) { _, err = srv.DataService.Create(c2) assert.Nil(t, err) - block := func(val int) (code int, body []byte) { + block := func(val int, ttl string) (code int, body []byte) { client := http.Client{} - req, e := http.NewRequest(http.MethodPut, - fmt.Sprintf("%s/api/v1/admin/user/%s?site=radio-t&block=%d", ts.URL, "user1", val), nil) + url := fmt.Sprintf("%s/api/v1/admin/user/%s?site=radio-t&block=%d", ts.URL, "user1", val) + if ttl != "" { + url = url + "&ttl=" + ttl + } + req, e := http.NewRequest(http.MethodPut, url, nil) assert.Nil(t, e) req.SetBasicAuth("dev", "password") resp, e := client.Do(req) @@ -175,7 +178,8 @@ func TestAdmin_Block(t *testing.T) { return resp.StatusCode, body } - code, body := block(1) + // block permanently + code, body := block(1, "") require.Equal(t, 200, code) j := JSON{} err = json.Unmarshal(body, &j) @@ -193,11 +197,34 @@ func TestAdmin_Block(t *testing.T) { assert.Equal(t, "", comments.Comments[0].Text) assert.True(t, comments.Comments[0].Deleted) - code, body = block(-1) + code, body = block(-1, "") require.Equal(t, 200, code) err = json.Unmarshal(body, &j) assert.Nil(t, err) assert.Equal(t, false, j["block"]) + + // block with ttl + code, _ = block(1, "10ms") + require.Equal(t, 200, code) + + 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) + comments = commentsWithInfo{} + err = json.Unmarshal([]byte(res), &comments) + assert.Nil(t, err) + assert.Equal(t, 2, len(comments.Comments), "should have 2 comments") + assert.Equal(t, "", comments.Comments[0].Text) + assert.True(t, comments.Comments[0].Deleted) + + time.Sleep(11 * 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) + comments = commentsWithInfo{} + err = json.Unmarshal([]byte(res), &comments) + assert.Nil(t, err) + assert.Equal(t, 2, len(comments.Comments), "should have 2 comments") + assert.Equal(t, "test test #1", comments.Comments[0].Text) + assert.False(t, comments.Comments[0].Deleted) } func TestAdmin_BlockedList(t *testing.T) { @@ -217,7 +244,7 @@ func TestAdmin_BlockedList(t *testing.T) { // block user2 req, err = http.NewRequest(http.MethodPut, - fmt.Sprintf("%s/api/v1/admin/user/%s?site=radio-t&block=%d", ts.URL, "user2", 1), nil) + fmt.Sprintf("%s/api/v1/admin/user/%s?site=radio-t&block=%d&ttl=10ms", ts.URL, "user2", 1), nil) assert.Nil(t, err) req.SetBasicAuth("dev", "password") _, err = client.Do(req) @@ -231,6 +258,15 @@ func TestAdmin_BlockedList(t *testing.T) { assert.Equal(t, 2, len(users), "two users blocked") assert.Equal(t, "user1", users[0].ID) assert.Equal(t, "user2", users[1].ID) + + time.Sleep(11 * time.Millisecond) + res, code = getWithAuth(t, ts.URL+"/api/v1/admin/blocked?site=radio-t") + require.Equal(t, 200, code, res) + users = []store.BlockedUser{} + err = json.Unmarshal([]byte(res), &users) + assert.Nil(t, err) + assert.Equal(t, 1, len(users), "one user left blocked") + } func TestAdmin_ReadOnly(t *testing.T) { diff --git a/backend/app/store/comment.go b/backend/app/store/comment.go index e0afd575..a18854e9 100644 --- a/backend/app/store/comment.go +++ b/backend/app/store/comment.go @@ -50,9 +50,9 @@ type PostInfo struct { // BlockedUser holds id and ts for blocked user type BlockedUser struct { - ID string `json:"id"` - Name string `json:"name"` - Timestamp time.Time `json:"time"` + ID string `json:"id"` + Name string `json:"name"` + Until time.Time `json:"time"` } // DeleteMode defines how much comment info will be erased diff --git a/backend/app/store/engine/bolt_admin.go b/backend/app/store/engine/bolt_admin.go index e3d07670..31059060 100644 --- a/backend/app/store/engine/bolt_admin.go +++ b/backend/app/store/engine/bolt_admin.go @@ -155,8 +155,9 @@ func (b *BoltDB) DeleteUser(siteID string, userID string) error { return err } -// SetBlock blocks/unblocks user for given site -func (b *BoltDB) SetBlock(siteID string, userID string, status bool) error { +// SetBlock blocks/unblocks user for given site. ttl defines for for how long, 0 - permanent +// block uses blocksBucketName with key=userID and val=TTL+now +func (b *BoltDB) SetBlock(siteID string, userID string, status bool, ttl time.Duration) error { bdb, err := b.db(siteID) if err != nil { @@ -167,7 +168,11 @@ func (b *BoltDB) SetBlock(siteID string, userID string, status bool) error { bucket := tx.Bucket([]byte(blocksBucketName)) switch status { case true: - if e := bucket.Put([]byte(userID), []byte(time.Now().Format(tsNano))); e != nil { + val := time.Now().AddDate(100, 0, 0).Format(tsNano) // permanent is 50year + if ttl > 0 { + val = time.Now().Add(ttl).Format(tsNano) + } + if e := bucket.Put([]byte(userID), []byte(val)); e != nil { return errors.Wrapf(e, "failed to put %s to %s", userID, blocksBucketName) } case false: @@ -189,7 +194,18 @@ func (b *BoltDB) IsBlocked(siteID string, userID string) (blocked bool) { _ = bdb.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(blocksBucketName)) - blocked = bucket.Get([]byte(userID)) != nil + val := bucket.Get([]byte(userID)) + if val == nil { + blocked = false + return nil + } + + until, err := time.Parse(tsNano, string(val)) + if err != nil { + blocked = false + return nil + } + blocked = time.Now().Before(until) return nil }) return blocked @@ -211,14 +227,15 @@ func (b *BoltDB) Blocked(siteID string) (users []store.BlockedUser, err error) { if e != nil { return errors.Wrap(e, "can't parse block ts") } - - // get user name from comment user section - userName := "" - userComments, e := b.User(siteID, string(k), 1, 0) - if e == nil && len(userComments) > 0 { - userName = userComments[0].User.Name + if time.Now().Before(ts) { + // get user name from comment user section + userName := "" + userComments, e := b.User(siteID, string(k), 1, 0) + if e == nil && len(userComments) > 0 { + userName = userComments[0].User.Name + } + users = append(users, store.BlockedUser{ID: string(k), Name: userName, Until: ts}) } - users = append(users, store.BlockedUser{ID: string(k), Name: userName, Timestamp: ts}) return nil }) }) diff --git a/backend/app/store/engine/bolt_admin_test.go b/backend/app/store/engine/bolt_admin_test.go index 23c49153..82086b1a 100644 --- a/backend/app/store/engine/bolt_admin_test.go +++ b/backend/app/store/engine/bolt_admin_test.go @@ -3,6 +3,7 @@ package engine import ( "os" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -126,27 +127,37 @@ func TestBoltAdmin_BlockUser(t *testing.T) { assert.False(t, b.IsBlocked("radio-t", "user1"), "nothing blocked") - assert.NoError(t, b.SetBlock("radio-t", "user1", true)) + assert.NoError(t, b.SetBlock("radio-t", "user1", true, 0)) assert.True(t, b.IsBlocked("radio-t", "user1"), "user1 blocked") assert.False(t, b.IsBlocked("radio-t", "user2"), "user2 still unblocked") - assert.NoError(t, b.SetBlock("radio-t", "user1", false)) + assert.NoError(t, b.SetBlock("radio-t", "user1", false, 0)) assert.False(t, b.IsBlocked("radio-t", "user1"), "user1 unblocked") - assert.EqualError(t, b.SetBlock("bad", "user1", true), `site "bad" not found`) - assert.NoError(t, b.SetBlock("radio-t", "userX", false)) + assert.EqualError(t, b.SetBlock("bad", "user1", true, 0), `site "bad" not found`) + assert.NoError(t, b.SetBlock("radio-t", "userX", false, 0)) assert.False(t, b.IsBlocked("radio-t-bad", "user1"), "nothing blocked on wrong site") } +func TestBoltAdmin_BlockUserWithTTL(t *testing.T) { + defer os.Remove(testDb) + b := prep(t) + assert.False(t, b.IsBlocked("radio-t", "user1"), "nothing blocked") + assert.NoError(t, b.SetBlock("radio-t", "user1", true, 10*time.Millisecond)) + assert.True(t, b.IsBlocked("radio-t", "user1"), "user1 blocked") + time.Sleep(11 * time.Millisecond) + assert.False(t, b.IsBlocked("radio-t", "user1"), "user1 un-blocked automatically") +} + func TestBoltAdmin_BlockList(t *testing.T) { defer os.Remove(testDb) b := prep(t) - assert.NoError(t, b.SetBlock("radio-t", "user1", true)) - assert.NoError(t, b.SetBlock("radio-t", "user2", true)) - assert.NoError(t, b.SetBlock("radio-t", "user3", false)) + assert.NoError(t, b.SetBlock("radio-t", "user1", true, 0)) + assert.NoError(t, b.SetBlock("radio-t", "user2", true, 10*time.Millisecond)) + assert.NoError(t, b.SetBlock("radio-t", "user3", false, 0)) ids, err := b.Blocked("radio-t") assert.NoError(t, err) @@ -156,6 +167,12 @@ func TestBoltAdmin_BlockList(t *testing.T) { assert.Equal(t, "user2", ids[1].ID) t.Logf("%+v", ids) + time.Sleep(11 * time.Millisecond) + ids, err = b.Blocked("radio-t") + assert.NoError(t, err) + assert.Equal(t, 1, len(ids)) + assert.Equal(t, "user1", ids[0].ID) + _, err = b.Blocked("bad") assert.EqualError(t, err, `site "bad" not found`) } diff --git a/backend/app/store/engine/engine.go b/backend/app/store/engine/engine.go index 86955e09..3b75c115 100644 --- a/backend/app/store/engine/engine.go +++ b/backend/app/store/engine/engine.go @@ -5,6 +5,7 @@ package engine import ( "sort" "strings" + "time" "github.com/umputun/remark/backend/app/store" ) @@ -44,7 +45,7 @@ type Admin interface { Delete(locator store.Locator, commentID string, mode store.DeleteMode) error // delete comment by id DeleteAll(siteID string) error // delete all data from site DeleteUser(siteID string, userID string) error // remove all comments from user - SetBlock(siteID string, userID string, status bool) error // block or unblock user + SetBlock(siteID string, userID string, status bool, ttl time.Duration) error // block or unblock user with TTL (0-permanent) IsBlocked(siteID string, userID string) bool // check if user blocked Blocked(siteID string) ([]store.BlockedUser, error) // get list of blocked users SetReadOnly(locator store.Locator, status bool) error // set/reset read-only flag