From 0cd76dfd2f573a7c64402ab9b609b6b200fac056 Mon Sep 17 00:00:00 2001 From: Umputun Date: Sun, 2 Sep 2018 13:00:20 -0500 Subject: [PATCH] Feature/admin_store (#197) * generalize admins and email info with store interface * lint: missing group comment * fix admin group with shared substruct --- README.md | 4 +-- backend/app/cmd/cmd.go | 3 ++ backend/app/cmd/server.go | 45 ++++++++++++++++++-------- backend/app/cmd/server_test.go | 2 +- backend/app/main.go | 2 +- backend/app/rest/api/rest.go | 7 ++-- backend/app/rest/api/rest_public.go | 6 ++-- backend/app/rest/api/rest_test.go | 15 +++++---- backend/app/rest/auth/auth.go | 7 ++-- backend/app/rest/auth/provider.go | 2 +- backend/app/rest/auth/provider_test.go | 2 +- backend/app/store/admin/admin.go | 28 ++++++++++++++++ backend/app/store/admin/admin_test.go | 17 ++++++++++ backend/app/store/service/service.go | 24 ++++++++++---- 14 files changed, 123 insertions(+), 41 deletions(-) create mode 100644 backend/app/store/admin/admin.go create mode 100644 backend/app/store/admin/admin_test.go diff --git a/README.md b/README.md index b8756152..a84b3c48 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,8 @@ _this is the recommended way to run remark42_ | store.bolt.timeout | STORE_BOLT_TIMEOUT | `30s` | boltdb access timeout | | store.mongo.url | STORE_MONGO_URL | | mongo url for data store | | store.mongo.db | STORE_MONGO_DB | | mongo db for data store | -| admin | ADMIN | | admin names (list of user ids), _multi_ | -| admin-email | ADMIN_EMAIL | `admin@${REMARK_URL}` | admin email | +| admin.shared.id | ADMIN_SHARED_ID | | admin names (list of user ids), _multi_ | +| admin.shared.email | ADMIN_SHARED_EMAIL | `admin@${REMARK_URL}` | admin email | | backup | BACKUP_PATH | `./var/backup` | backups location | | max-back | MAX_BACKUP_FILES | `10` | max backup files to keep | | cache.max.items | CACHE_MAX_ITEMS | `1000` | max number of cached items, `0` - unlimited | diff --git a/backend/app/cmd/cmd.go b/backend/app/cmd/cmd.go index 1414a356..9407f2ed 100644 --- a/backend/app/cmd/cmd.go +++ b/backend/app/cmd/cmd.go @@ -16,6 +16,9 @@ import ( "github.com/pkg/errors" ) +// Revision sets from main +var Revision = "unknown" + // fileParser used to convert template strings like blah-{{.SITE}}-{{.YYYYMMDD}} the final format type fileParser struct { site string diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index 4d0999a8..7f9f4314 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -14,6 +14,7 @@ import ( "github.com/coreos/bbolt" "github.com/go-pkgz/mongo" "github.com/pkg/errors" + "github.com/umputun/remark/backend/app/store/admin" "github.com/umputun/remark/backend/app/migrator" "github.com/umputun/remark/backend/app/rest/api" @@ -37,10 +38,9 @@ type ServerCommand struct { Cache CacheGroup `group:"cache" namespace:"cache" env-namespace:"CACHE"` Mongo MongoGroup `group:"mongo" namespace:"mongo" env-namespace:"MONGO"` Key KeyGroup `group:"key" namespace:"key" env-namespace:"KEY"` + Admin AdminGroup `group:"admin" namespace:"admin" env-namespace:"ADMIN"` Sites []string `long:"site" env:"SITE" default:"remark" description:"site names" env-delim:","` - Admins []string `long:"admin" env:"ADMIN" description:"admin(s) names" env-delim:","` - AdminEmail string `long:"admin-email" env:"ADMIN_EMAIL" default:"" description:"admin email"` DevPasswd string `long:"dev-passwd" env:"DEV_PASSWD" default:"" description:"development mode password"` BackupLocation string `long:"backup" env:"BACKUP_PATH" default:"./var/backup" description:"backups location"` MaxBackupFiles int `long:"max-back" env:"MAX_BACKUP_FILES" default:"10" description:"max backups to keep"` @@ -52,7 +52,6 @@ type ServerCommand struct { EditDuration time.Duration `long:"edit-time" env:"EDIT_TIME" default:"5m" description:"edit window"` Port int `long:"port" env:"REMARK_PORT" default:"8080" description:"port"` WebRoot string `long:"web-root" env:"REMARK_WEB_ROOT" default:"./web" description:"web root directory"` - // Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"` Auth struct { TTL struct { @@ -112,8 +111,14 @@ type KeyGroup struct { Type string `long:"type" env:"TYPE" description:"type of key store" choice:"shared" choice:"mongo" default:"shared"` } -// Revision sets from main -var Revision = "unknown" +// AdminGroup defines options group for admin params +type AdminGroup struct { + Type string `long:"type" env:"TYPE" description:"type of admin store" choice:"shared" choice:"mongo" default:"shared"` + Shared struct { + Admins []string `long:"id" env:"ID" description:"admin(s) ids" env-delim:","` + Email string `long:"email" env:"EMAIL" default:"" description:"admin email"` + } `group:"shared" namespace:"shared" env-namespace:"SHARED"` +} // serverApp holds all active objects type serverApp struct { @@ -174,12 +179,17 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { return nil, err } + adminStore, err := s.makeAdminStore() + if err != nil { + return nil, err + } + dataService := &service.DataStore{ Interface: storeEngine, EditDuration: s.EditDuration, KeyStore: keyStore, + AdminStore: adminStore, MaxCommentSize: s.MaxCommentSize, - Admins: s.Admins, } loadingCache, err := s.makeCache() @@ -229,7 +239,7 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { SharedSecret: s.SharedSecret, Authenticator: auth.Authenticator{ JWTService: jwtService, - AdminEmail: s.AdminEmail, + AdminStore: adminStore, Providers: authProviders, DevPasswd: s.DevPasswd, PermissionChecker: dataService, @@ -237,13 +247,6 @@ func (s *ServerCommand) newServerApp() (*serverApp, error) { Cache: loadingCache, } - // no admin email, use admin@domain - if srv.Authenticator.AdminEmail == "" { - if u, err := url.Parse(s.RemarkURL); err == nil { - srv.Authenticator.AdminEmail = "admin@" + u.Host - } - } - srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = s.LowScore, s.CriticalScore var devAuth *auth.DevAuthServer @@ -362,6 +365,20 @@ func (s *ServerCommand) makeKeyStore() (keys.Store, error) { } } +func (s *ServerCommand) makeAdminStore() (admin.Store, error) { + switch s.Admin.Type { + case "shared": + if s.Admin.Shared.Email == "" { // no admin email, use admin@domain + if u, err := url.Parse(s.RemarkURL); err == nil { + s.Admin.Shared.Email = "admin@" + u.Host + } + } + return admin.NewStaticStore(s.Admin.Shared.Admins, s.Admin.Shared.Email), nil + default: + return nil, errors.Errorf("unsupported admin store type %s", s.Key.Type) + } +} + func (s *ServerCommand) makeCache() (cache.LoadingCache, error) { switch s.Cache.Type { case "mem": diff --git a/backend/app/cmd/server_test.go b/backend/app/cmd/server_test.go index f0dc3943..1204f688 100644 --- a/backend/app/cmd/server_test.go +++ b/backend/app/cmd/server_test.go @@ -45,7 +45,7 @@ func TestServerApp(t *testing.T) { body, _ = ioutil.ReadAll(resp.Body) t.Log(string(body)) - assert.Equal(t, "admin@demo.remark42.com", app.restSrv.Authenticator.AdminEmail, "default admin email") + assert.Equal(t, "admin@demo.remark42.com", app.restSrv.Authenticator.AdminStore.Email(""), "default admin email") app.Wait() } diff --git a/backend/app/main.go b/backend/app/main.go index 5c0a598d..70d3e9aa 100644 --- a/backend/app/main.go +++ b/backend/app/main.go @@ -11,7 +11,7 @@ import ( "github.com/umputun/remark/backend/app/cmd" ) -// Opts has all commands +// Opts with all cli commands and flags type Opts struct { ServerCmd cmd.ServerCommand `command:"server"` ImportCmd cmd.ImportCommand `command:"import"` diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index 3c17dd71..6b2dcc4a 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -71,9 +71,10 @@ type commentsWithInfo struct { func (s *Rest) Run(port int) { log.Printf("[INFO] activate rest server on port %d", port) - if s.DataService != nil && len(s.DataService.Admins) > 0 { - log.Printf("[DEBUG] admins %+v", s.DataService.Admins) - } + // TODO: restore admin info dbg + //if s.DataService != nil && len(s.DataService.AdminStore.Admins()) > 0 { + // log.Printf("[DEBUG] admins %+v", s.DataService.Admins) + //} router := s.routes() diff --git a/backend/app/rest/api/rest_public.go b/backend/app/rest/api/rest_public.go index 02460a68..b6582acd 100644 --- a/backend/app/rest/api/rest_public.go +++ b/backend/app/rest/api/rest_public.go @@ -195,6 +195,8 @@ 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) { + siteID := r.URL.Query().Get("site") + type config struct { Version string `json:"version"` EditDuration int `json:"edit_duration"` @@ -211,8 +213,8 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) { Version: s.Version, EditDuration: int(s.DataService.EditDuration.Seconds()), MaxCommentSize: s.DataService.MaxCommentSize, - Admins: s.DataService.Admins, - AdminEmail: s.Authenticator.AdminEmail, + Admins: s.DataService.AdminStore.Admins(siteID), + AdminEmail: s.DataService.AdminStore.Email(siteID), LowScore: s.ScoreThresholds.Low, CriticalScore: s.ScoreThresholds.Critical, ReadOnlyAge: s.ReadOnlyAge, diff --git a/backend/app/rest/api/rest_test.go b/backend/app/rest/api/rest_test.go index cf1f7bc0..8cac32e5 100644 --- a/backend/app/rest/api/rest_test.go +++ b/backend/app/rest/api/rest_test.go @@ -14,15 +14,16 @@ import ( "github.com/coreos/bbolt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/umputun/remark/backend/app/store/keys" "github.com/umputun/remark/backend/app/migrator" "github.com/umputun/remark/backend/app/rest/auth" "github.com/umputun/remark/backend/app/rest/cache" "github.com/umputun/remark/backend/app/rest/proxy" "github.com/umputun/remark/backend/app/store" + adminstore "github.com/umputun/remark/backend/app/store/admin" "github.com/umputun/remark/backend/app/store/avatar" "github.com/umputun/remark/backend/app/store/engine" + "github.com/umputun/remark/backend/app/store/keys" "github.com/umputun/remark/backend/app/store/service" ) @@ -90,20 +91,22 @@ func TestRest_filterComments(t *testing.T) { func prep(t *testing.T) (srv *Rest, ts *httptest.Server) { b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: testDb, SiteID: "radio-t"}) require.Nil(t, err) + + adminStore := adminstore.NewStaticStore([]string{"a1", "a2"}, "admin@remark-42.com") + dataStore := &service.DataStore{ Interface: b, EditDuration: 5 * time.Minute, MaxCommentSize: 4000, KeyStore: keys.NewStaticStore("123456"), - Admins: []string{"a1", "a2"}, + AdminStore: adminStore, } srv = &Rest{ DataService: dataStore, Authenticator: auth.Authenticator{ - DevPasswd: "password", - Providers: nil, - - AdminEmail: "admin@remark-42.com", + DevPasswd: "password", + Providers: nil, + AdminStore: adminStore, JWTService: auth.NewJWT(keys.NewStaticStore("123456"), false, time.Minute, time.Hour), }, Exporter: &migrator.Remark{DataStore: dataStore}, diff --git a/backend/app/rest/auth/auth.go b/backend/app/rest/auth/auth.go index a77446a0..e6ff1d56 100644 --- a/backend/app/rest/auth/auth.go +++ b/backend/app/rest/auth/auth.go @@ -9,13 +9,14 @@ import ( "github.com/umputun/remark/backend/app/rest" "github.com/umputun/remark/backend/app/store" + "github.com/umputun/remark/backend/app/store/admin" ) // Authenticator is top level auth object providing middlewares type Authenticator struct { JWTService *JWT Providers []Provider - AdminEmail string + AdminStore admin.Store DevPasswd string PermissionChecker PermissionChecker } @@ -31,7 +32,7 @@ var devUser = store.User{ type PermissionChecker interface { IsVerified(siteID, userID string) bool IsBlocked(siteID, userID string) bool - IsAdmin(userID string) bool + IsAdmin(siteID, userID string) bool } // Auth middleware adds auth from session and populates user info @@ -98,7 +99,7 @@ func (a *Authenticator) Auth(reqAuth bool) func(http.Handler) http.Handler { func (a *Authenticator) refreshExpiredToken(w http.ResponseWriter, claims *CustomClaims) (*CustomClaims, error) { if a.PermissionChecker != nil { - claims.User.Admin = a.PermissionChecker.IsAdmin(claims.User.ID) + claims.User.Admin = a.PermissionChecker.IsAdmin(claims.SiteID, claims.User.ID) claims.User.Blocked = a.PermissionChecker.IsBlocked(claims.SiteID, claims.User.ID) claims.User.Verified = a.PermissionChecker.IsVerified(claims.SiteID, claims.User.ID) } diff --git a/backend/app/rest/auth/provider.go b/backend/app/rest/auth/provider.go index 1fe0588d..4558384c 100644 --- a/backend/app/rest/auth/provider.go +++ b/backend/app/rest/auth/provider.go @@ -203,7 +203,7 @@ func (p Provider) setAvatar(u store.User) store.User { // setPermissions sets permission fields not handled by provider's MapUser, things like admin, verified and blocked func (p Provider) setPermissions(u store.User, siteID string) store.User { - u.Admin = p.PermissionChecker.IsAdmin(u.ID) + u.Admin = p.PermissionChecker.IsAdmin(siteID, u.ID) u.Verified = p.PermissionChecker.IsVerified(siteID, u.ID) u.Blocked = p.PermissionChecker.IsBlocked(siteID, u.ID) log.Printf("[DEBUG] set permissions for user %s, site %s - %+v", u.ID, siteID, u) diff --git a/backend/app/rest/auth/provider_test.go b/backend/app/rest/auth/provider_test.go index c26c9087..be78d789 100644 --- a/backend/app/rest/auth/provider_test.go +++ b/backend/app/rest/auth/provider_test.go @@ -233,6 +233,6 @@ type mockUserPermissions struct { blocked string } -func (m *mockUserPermissions) IsAdmin(userID string) bool { return userID == m.admin } +func (m *mockUserPermissions) IsAdmin(siteID, userID string) bool { return userID == m.admin } func (m *mockUserPermissions) IsVerified(siteID, userID string) bool { return userID == m.verified } func (m *mockUserPermissions) IsBlocked(siteID, userID string) bool { return userID == m.blocked } diff --git a/backend/app/store/admin/admin.go b/backend/app/store/admin/admin.go new file mode 100644 index 00000000..3c034972 --- /dev/null +++ b/backend/app/store/admin/admin.go @@ -0,0 +1,28 @@ +package admin + +// Store defines interface returning admins info for given site +type Store interface { + Admins(siteID string) (ids []string) + Email(siteID string) (email string) +} + +// StaticStore implements keys.Store with a single, predefined key +type StaticStore struct { + admins []string + email string +} + +// NewStaticStore makes StaticStore instance with given key +func NewStaticStore(admins []string, email string) *StaticStore { + return &StaticStore{admins: admins, email: email} +} + +// Admins returns static list of admin's ids, the same for all sites +func (s *StaticStore) Admins(string) (ids []string) { + return s.admins +} + +// Email gets static email address +func (s *StaticStore) Email(string) (email string) { + return s.email +} diff --git a/backend/app/store/admin/admin_test.go b/backend/app/store/admin/admin_test.go new file mode 100644 index 00000000..acd4df2c --- /dev/null +++ b/backend/app/store/admin/admin_test.go @@ -0,0 +1,17 @@ +package admin + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStaticStore_Get(t *testing.T) { + var ks Store = NewStaticStore([]string{"123", "xyz"}, "aa@example.com") + + a := ks.Admins("any") + assert.Equal(t, []string{"123", "xyz"}, a) + + email := ks.Email("blah") + assert.Equal(t, "aa@example.com", email) +} diff --git a/backend/app/store/service/service.go b/backend/app/store/service/service.go index c9c35f03..c9f2d852 100644 --- a/backend/app/store/service/service.go +++ b/backend/app/store/service/service.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" "github.com/pkg/errors" + "github.com/umputun/remark/backend/app/store/admin" "github.com/umputun/remark/backend/app/store" "github.com/umputun/remark/backend/app/store/engine" @@ -17,8 +18,8 @@ type DataStore struct { engine.Interface EditDuration time.Duration KeyStore keys.Store + AdminStore admin.Store MaxCommentSize int - Admins []string // granular locks scopedLocks struct { @@ -32,6 +33,16 @@ const defaultCommentMaxSize = 2000 // Create prepares comment and forward to Interface.Create func (s *DataStore) Create(comment store.Comment) (commentID string, err error) { + + if comment, err = s.prepareNewComment(comment); err != nil { + return "", errors.Wrap(err, "failed to prepare comment") + } + + return s.Interface.Create(comment) +} + +// prepareNewComment sets new comment fields, hashing and sanitizing data +func (s *DataStore) prepareNewComment(comment store.Comment) (store.Comment, error) { // fill ID and time if empty if comment.ID == "" { comment.ID = uuid.New().String() @@ -47,11 +58,10 @@ func (s *DataStore) Create(comment store.Comment) (commentID string, err error) secret, err := s.KeyStore.Get(comment.Locator.SiteID) if err != nil { - return "", errors.Wrapf(err, "can't get secret for site %s", comment.Locator.SiteID) + return store.Comment{}, errors.Wrapf(err, "can't get secret for site %s", comment.Locator.SiteID) } comment.User.HashIP(secret) // replace ip by hash - - return s.Interface.Create(comment) + return comment, nil } // SetPin pin/un-pin comment as special @@ -170,9 +180,9 @@ func (s *DataStore) ValidateComment(c *store.Comment) error { } // IsAdmin checks if usesID in the list of admins -func (s *DataStore) IsAdmin(userID string) bool { - for _, admin := range s.Admins { - if admin == userID { +func (s *DataStore) IsAdmin(siteID string, userID string) bool { + for _, a := range s.AdminStore.Admins(siteID) { + if a == userID { return true } }