From fec62689838935c03d6227123abe55d323db6d89 Mon Sep 17 00:00:00 2001 From: Umputun Date: Wed, 6 Jun 2018 12:56:16 -0500 Subject: [PATCH] admin support for one-click deleteme activation --- README.md | 1 + app/main.go | 1 + app/rest/api/admin.go | 36 +++++++++-- app/rest/api/admin_test.go | 99 +++++++++++++++++++++++++++++++ app/rest/api/rest.go | 8 ++- app/rest/api/rest_private.go | 3 +- app/rest/api/rest_private_test.go | 1 + app/rest/api/rest_public_test.go | 2 + app/rest/api/rest_test.go | 1 + 9 files changed, 143 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3d8112eb..d91f74be 100644 --- a/README.md +++ b/README.md @@ -411,6 +411,7 @@ Sort can be `time`, `active` or `score`. Supported sort order with prefix -/+, i * `DELETE /api/v1/admin/user/{userid}?site=site-id&block=1` - delete all user's comments. * `PUT /api/v1/admin/readonly?site=site-id&url=post-url&ro=1` - set read-only status * `PUT /api/v1/admin/verify/{userid}?site=site-id&verified=1` - set verified status +* `GET /api/v1/admin/deleteme?token=token` - process deleteme user's request _all admin calls require auth and admin privilege_ diff --git a/app/main.go b/app/main.go index 39907355..556a7128 100644 --- a/app/main.go +++ b/app/main.go @@ -147,6 +147,7 @@ func New(opts Opts) (*Application, error) { DataService: dataService, Exporter: exporter, WebRoot: opts.WebRoot, + RemarkURL: opts.RemarkURL, ImageProxy: &proxy.Image{Enabled: opts.ImageProxy, RoutePath: "/api/v1/img", RemarkURL: opts.RemarkURL}, AvatarProxy: avatarProxy, ReadOnlyAge: opts.ReadOnlyAge, diff --git a/app/rest/api/admin.go b/app/rest/api/admin.go index 7fc22129..36810701 100644 --- a/app/rest/api/admin.go +++ b/app/rest/api/admin.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi" "github.com/go-chi/render" + "github.com/umputun/remark/app/rest/auth" "github.com/umputun/remark/app/migrator" "github.com/umputun/remark/app/rest" @@ -20,10 +21,11 @@ import ( // admin provides router for all requests available for admin users only type admin struct { - dataService service.DataStore - exporter migrator.Exporter - cache cache.LoadingCache - defAvatarURL string + dataService service.DataStore + exporter migrator.Exporter + cache cache.LoadingCache + defAvatarURL string + authenticator auth.Authenticator } func (a *admin) routes(middlewares ...func(http.Handler) http.Handler) chi.Router { @@ -32,6 +34,7 @@ func (a *admin) routes(middlewares ...func(http.Handler) http.Handler) chi.Route router.Delete("/comment/{id}", a.deleteCommentCtrl) router.Put("/user/{userid}", a.setBlockCtrl) router.Delete("/user/{userid}", a.deleteUserCtrl) + router.Get("/deleteme", a.deleteMeRequestCtrl) router.Put("/verify/{userid}", a.setVerifyCtrl) router.Get("/export", a.exportCtrl) router.Put("/pin/{id}", a.setPinCtrl) @@ -57,7 +60,7 @@ func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, JSON{"id": id, "locator": locator}) } -// DELETE /user/{userid}?site=side-id +// DELETE /user/{userid}?site=side-id - delete all user comments for requested userid func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userid") @@ -73,6 +76,29 @@ func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, JSON{"user_id": userID, "site_id": siteID}) } +// GET /deleteme?token=jwt - delete all user comments by user's request. Gets info about deleted used from provided token +// request made GET to allow direct click from the email sent by user +func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) { + + token := r.URL.Query().Get("token") + + claims, err := a.authenticator.JWTService.Parse(token) + if err != nil { + rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't process token") + return + } + + log.Printf("[INFO] delete all user comments by request for %s, site %s", claims.User.ID, claims.SiteID) + + if err := a.dataService.DeleteUser(claims.SiteID, claims.User.ID); err != nil { + rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't delete user") + return + } + a.cache.Flush(claims.SiteID, claims.User.ID) + render.Status(r, http.StatusOK) + 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 func (a *admin) setBlockCtrl(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userid") diff --git a/app/rest/api/admin_test.go b/app/rest/api/admin_test.go index 76a199e9..068c3cba 100644 --- a/app/rest/api/admin_test.go +++ b/app/rest/api/admin_test.go @@ -10,8 +10,10 @@ import ( "testing" "time" + "github.com/dgrijalva/jwt-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/umputun/remark/app/rest/auth" "github.com/umputun/remark/app/store" ) @@ -364,3 +366,100 @@ func TestAdmin_ExportFile(t *testing.T) { assert.Equal(t, 2, strings.Count(string(ungzBody), "\"text\"")) t.Logf("%s", string(ungzBody)) } + +func TestAdmin_DeleteMeRequest(t *testing.T) { + srv, ts := prep(t) + assert.NotNil(t, srv) + defer cleanup(ts) + + c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", + URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}} + c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t", + URL: "https://radio-t.com/blah"}, User: store.User{Name: "user2", ID: "user2"}} + + _, err := srv.DataService.Create(c1) + assert.Nil(t, err) + _, err = srv.DataService.Create(c2) + assert.Nil(t, err) + + comments, err := srv.DataService.User("radio-t", "user1", 0, 0) + assert.Nil(t, err) + assert.Equal(t, 1, len(comments), "a comment for user1") + + claims := auth.CustomClaims{ + SiteID: "radio-t", + SessionOnly: true, + StandardClaims: jwt.StandardClaims{ + Id: "1234567", + Issuer: "remark42", + NotBefore: time.Now().Add(-1 * time.Minute).Unix(), + ExpiresAt: time.Now().Add(30 * time.Minute).Unix(), + }, + User: &store.User{ + ID: "user1", + }, + } + + token, err := srv.Authenticator.JWTService.Token(&claims) + assert.Nil(t, err) + + client := http.Client{} + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, token), nil) + assert.Nil(t, err) + req.SetBasicAuth("dev", "password") + resp, err := client.Do(req) + assert.Nil(t, err) + assert.Equal(t, 200, resp.StatusCode) + + _, err = srv.DataService.User("radio-t", "user1", 0, 0) + assert.EqualError(t, err, "no comments for user user1 in store") +} + +func TestAdmin_DeleteMeRequestFailed(t *testing.T) { + srv, ts := prep(t) + assert.NotNil(t, srv) + defer cleanup(ts) + + c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "radio-t", + URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}} + c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "radio-t", + URL: "https://radio-t.com/blah"}, User: store.User{Name: "user2", ID: "user2"}} + + _, err := srv.DataService.Create(c1) + assert.Nil(t, err) + _, err = srv.DataService.Create(c2) + assert.Nil(t, err) + + // try with bad token + client := http.Client{} + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, "bad token"), nil) + assert.Nil(t, err) + req.SetBasicAuth("dev", "password") + resp, err := client.Do(req) + assert.Nil(t, err) + assert.Equal(t, 400, resp.StatusCode) + + // try with bad auth + claims := auth.CustomClaims{ + SiteID: "radio-t", + SessionOnly: true, + StandardClaims: jwt.StandardClaims{ + Id: "1234567", + Issuer: "remark42", + NotBefore: time.Now().Add(-1 * time.Minute).Unix(), + ExpiresAt: time.Now().Add(30 * time.Minute).Unix(), + }, + User: &store.User{ + ID: "user1", + }, + } + + token, err := srv.Authenticator.JWTService.Token(&claims) + assert.Nil(t, err) + req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, token), nil) + assert.Nil(t, err) + req.SetBasicAuth("dev", "bad-password") + resp, err = client.Do(req) + assert.Nil(t, err) + assert.Equal(t, 401, resp.StatusCode) +} diff --git a/app/rest/api/rest.go b/app/rest/api/rest.go index 43e56c0c..fb41b01a 100644 --- a/app/rest/api/rest.go +++ b/app/rest/api/rest.go @@ -38,6 +38,7 @@ type Rest struct { AvatarProxy *proxy.Avatar ImageProxy *proxy.Image WebRoot string + RemarkURL string ReadOnlyAge int ScoreThresholds struct { Low int @@ -100,9 +101,10 @@ func (s *Rest) routes() chi.Router { router.Use(AppInfo("remark42", s.Version), Ping) s.adminService = admin{ - dataService: s.DataService, - exporter: s.Exporter, - cache: s.Cache, + dataService: s.DataService, + exporter: s.Exporter, + cache: s.Cache, + authenticator: s.Authenticator, } ipFn := func(ip string) string { return store.HashValue(ip, s.DataService.Secret)[:12] } // logger uses it for anonymization diff --git a/app/rest/api/rest_private.go b/app/rest/api/rest_private.go index 007ccd71..dff8667c 100644 --- a/app/rest/api/rest_private.go +++ b/app/rest/api/rest_private.go @@ -252,5 +252,6 @@ func (s *Rest) deleteMeCtrl(w http.ResponseWriter, r *http.Request) { rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't make token") return } - render.JSON(w, r, JSON{"site": siteID, "user_id": user.ID, "token": tokenStr}) + link := fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", s.RemarkURL, tokenStr) + render.JSON(w, r, JSON{"site": siteID, "user_id": user.ID, "token": tokenStr, "link": link}) } diff --git a/app/rest/api/rest_private_test.go b/app/rest/api/rest_private_test.go index d7dbd6f4..ee46061a 100644 --- a/app/rest/api/rest_private_test.go +++ b/app/rest/api/rest_private_test.go @@ -375,6 +375,7 @@ func TestRest_DeleteMe(t *testing.T) { claims, err := srv.Authenticator.JWTService.Parse(token) assert.Nil(t, err) assert.Equal(t, "dev", claims.User.ID) + assert.Equal(t, "https://demo.remark42.com/api/v1/admin/deleteme?token="+token, m["link"]) req, err = http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/deleteme?site=radio-t", ts.URL), nil) assert.Nil(t, err) diff --git a/app/rest/api/rest_public_test.go b/app/rest/api/rest_public_test.go index 25cd187c..d6663dfc 100644 --- a/app/rest/api/rest_public_test.go +++ b/app/rest/api/rest_public_test.go @@ -393,6 +393,8 @@ func TestRest_Info(t *testing.T) { assert.NotNil(t, srv) defer cleanup(ts) + srv.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", URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 05, 27, 1, 14, 10, 0, time.Local)} diff --git a/app/rest/api/rest_test.go b/app/rest/api/rest_test.go index a6d24953..5866ff88 100644 --- a/app/rest/api/rest_test.go +++ b/app/rest/api/rest_test.go @@ -63,6 +63,7 @@ func prep(t *testing.T) (srv *Rest, ts *httptest.Server) { Exporter: &migrator.Remark{DataStore: &dataStore}, Cache: &mockCache{}, WebRoot: "/tmp", + RemarkURL: "https://demo.remark42.com", AvatarProxy: &proxy.Avatar{StorePath: "/tmp", RoutePath: "/api/v1/avatar"}, ImageProxy: &proxy.Image{}, ReadOnlyAge: 10,