From 051bae5dd2957205b6b9eebab47b324e4d69d1b7 Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Sun, 23 Feb 2025 12:35:25 -0500 Subject: [PATCH 1/8] Settings store --- pkg/database/mocks/Store.go | 155 ++++++++++++++++++++++++++++++ pkg/database/querier.go | 3 + pkg/database/settings.sql.go | 42 ++++++++ pkg/rbac/entities/entities.go | 7 ++ pkg/rbac/policy/policy.go | 9 ++ pkg/server/server.go | 4 + pkg/settings/settings.go | 131 +++++++++++++++++++++++++ sql/postgres/queries/settings.sql | 11 +++ 8 files changed, 362 insertions(+) create mode 100644 pkg/database/settings.sql.go create mode 100644 pkg/settings/settings.go create mode 100644 sql/postgres/queries/settings.sql diff --git a/pkg/database/mocks/Store.go b/pkg/database/mocks/Store.go index 34021cd..adf3ff7 100644 --- a/pkg/database/mocks/Store.go +++ b/pkg/database/mocks/Store.go @@ -947,6 +947,53 @@ func (_c *Store_DeleteIncident_Call) RunAndReturn(run func(context.Context, uuid return _c } +// DeleteSetting provides a mock function with given fields: ctx, name +func (_m *Store) DeleteSetting(ctx context.Context, name string) error { + ret := _m.Called(ctx, name) + + if len(ret) == 0 { + panic("no return value specified for DeleteSetting") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = rf(ctx, name) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Store_DeleteSetting_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSetting' +type Store_DeleteSetting_Call struct { + *mock.Call +} + +// DeleteSetting is a helper method to define mock.On call +// - ctx context.Context +// - name string +func (_e *Store_Expecter) DeleteSetting(ctx interface{}, name interface{}) *Store_DeleteSetting_Call { + return &Store_DeleteSetting_Call{Call: _e.mock.On("DeleteSetting", ctx, name)} +} + +func (_c *Store_DeleteSetting_Call) Run(run func(ctx context.Context, name string)) *Store_DeleteSetting_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *Store_DeleteSetting_Call) Return(_a0 error) *Store_DeleteSetting_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Store_DeleteSetting_Call) RunAndReturn(run func(context.Context, string) error) *Store_DeleteSetting_Call { + _c.Call.Return(run) + return _c +} + // DeleteShare provides a mock function with given fields: ctx, id func (_m *Store) DeleteShare(ctx context.Context, id string) error { ret := _m.Called(ctx, id) @@ -1989,6 +2036,65 @@ func (_c *Store_GetIncidentTalkgroups_Call) RunAndReturn(run func(context.Contex return _c } +// GetSetting provides a mock function with given fields: ctx, name +func (_m *Store) GetSetting(ctx context.Context, name string) ([]byte, error) { + ret := _m.Called(ctx, name) + + if len(ret) == 0 { + panic("no return value specified for GetSetting") + } + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) ([]byte, error)); ok { + return rf(ctx, name) + } + if rf, ok := ret.Get(0).(func(context.Context, string) []byte); ok { + r0 = rf(ctx, name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Store_GetSetting_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSetting' +type Store_GetSetting_Call struct { + *mock.Call +} + +// GetSetting is a helper method to define mock.On call +// - ctx context.Context +// - name string +func (_e *Store_Expecter) GetSetting(ctx interface{}, name interface{}) *Store_GetSetting_Call { + return &Store_GetSetting_Call{Call: _e.mock.On("GetSetting", ctx, name)} +} + +func (_c *Store_GetSetting_Call) Run(run func(ctx context.Context, name string)) *Store_GetSetting_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *Store_GetSetting_Call) Return(_a0 []byte, _a1 error) *Store_GetSetting_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Store_GetSetting_Call) RunAndReturn(run func(context.Context, string) ([]byte, error)) *Store_GetSetting_Call { + _c.Call.Return(run) + return _c +} + // GetShare provides a mock function with given fields: ctx, id func (_m *Store) GetShare(ctx context.Context, id string) (database.Share, error) { ret := _m.Called(ctx, id) @@ -3804,6 +3910,55 @@ func (_c *Store_SetCallTranscript_Call) RunAndReturn(run func(context.Context, u return _c } +// SetSetting provides a mock function with given fields: ctx, name, updatedBy, value +func (_m *Store) SetSetting(ctx context.Context, name string, updatedBy *int32, value []byte) error { + ret := _m.Called(ctx, name, updatedBy, value) + + if len(ret) == 0 { + panic("no return value specified for SetSetting") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, *int32, []byte) error); ok { + r0 = rf(ctx, name, updatedBy, value) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Store_SetSetting_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetSetting' +type Store_SetSetting_Call struct { + *mock.Call +} + +// SetSetting is a helper method to define mock.On call +// - ctx context.Context +// - name string +// - updatedBy *int32 +// - value []byte +func (_e *Store_Expecter) SetSetting(ctx interface{}, name interface{}, updatedBy interface{}, value interface{}) *Store_SetSetting_Call { + return &Store_SetSetting_Call{Call: _e.mock.On("SetSetting", ctx, name, updatedBy, value)} +} + +func (_c *Store_SetSetting_Call) Run(run func(ctx context.Context, name string, updatedBy *int32, value []byte)) *Store_SetSetting_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(*int32), args[3].([]byte)) + }) + return _c +} + +func (_c *Store_SetSetting_Call) Return(_a0 error) *Store_SetSetting_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Store_SetSetting_Call) RunAndReturn(run func(context.Context, string, *int32, []byte) error) *Store_SetSetting_Call { + _c.Call.Return(run) + return _c +} + // SetTalkgroupTags provides a mock function with given fields: ctx, tags, systemID, tGID func (_m *Store) SetTalkgroupTags(ctx context.Context, tags []string, systemID int32, tGID int32) error { ret := _m.Called(ctx, tags, systemID, tGID) diff --git a/pkg/database/querier.go b/pkg/database/querier.go index f2c7e35..fd039be 100644 --- a/pkg/database/querier.go +++ b/pkg/database/querier.go @@ -26,6 +26,7 @@ type Querier interface { DeleteAPIKey(ctx context.Context, apiKey string) error DeleteCall(ctx context.Context, id uuid.UUID) error DeleteIncident(ctx context.Context, id uuid.UUID) error + DeleteSetting(ctx context.Context, name string) error DeleteShare(ctx context.Context, id string) error DeleteSystem(ctx context.Context, id int) error DeleteTalkgroup(ctx context.Context, systemID int32, tGID int32) error @@ -43,6 +44,7 @@ type Querier interface { GetIncidentCalls(ctx context.Context, id uuid.UUID) ([]GetIncidentCallsRow, error) GetIncidentOwner(ctx context.Context, id uuid.UUID) (int, error) GetIncidentTalkgroups(ctx context.Context, incidentID uuid.UUID) ([]GetIncidentTalkgroupsRow, error) + GetSetting(ctx context.Context, name string) ([]byte, error) GetShare(ctx context.Context, id string) (Share, error) GetSharesP(ctx context.Context, arg GetSharesPParams) ([]GetSharesPRow, error) GetSharesPCount(ctx context.Context, owner *int32) (int64, error) @@ -71,6 +73,7 @@ type Querier interface { RestoreTalkgroupVersion(ctx context.Context, versionIds int) (Talkgroup, error) SetAppPrefs(ctx context.Context, appName string, prefs []byte, uid int) error SetCallTranscript(ctx context.Context, iD uuid.UUID, transcript *string) error + SetSetting(ctx context.Context, name string, updatedBy *int32, value []byte) error SetTalkgroupTags(ctx context.Context, tags []string, systemID int32, tGID int32) error StoreDeletedTGVersion(ctx context.Context, systemID *int32, tGID *int32, submitter *int32) error StoreTGVersion(ctx context.Context, arg []StoreTGVersionParams) *StoreTGVersionBatchResults diff --git a/pkg/database/settings.sql.go b/pkg/database/settings.sql.go new file mode 100644 index 0000000..db15296 --- /dev/null +++ b/pkg/database/settings.sql.go @@ -0,0 +1,42 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: settings.sql + +package database + +import ( + "context" +) + +const deleteSetting = `-- name: DeleteSetting :exec +DELETE FROM settings WHERE name = $1 +` + +func (q *Queries) DeleteSetting(ctx context.Context, name string) error { + _, err := q.db.Exec(ctx, deleteSetting, name) + return err +} + +const getSetting = `-- name: GetSetting :one +SELECT value FROM settings WHERE name = $1 +` + +func (q *Queries) GetSetting(ctx context.Context, name string) ([]byte, error) { + row := q.db.QueryRow(ctx, getSetting, name) + var value []byte + err := row.Scan(&value) + return value, err +} + +const setSetting = `-- name: SetSetting :exec +INSERT INTO settings (name, updated_by, value) VALUES ($1, $2, $3) + ON CONFLICT (name) DO UPDATE SET + value = $3, + updated_by = $2 +` + +func (q *Queries) SetSetting(ctx context.Context, name string, updatedBy *int32, value []byte) error { + _, err := q.db.Exec(ctx, setSetting, name, updatedBy, value) + return err +} diff --git a/pkg/rbac/entities/entities.go b/pkg/rbac/entities/entities.go index 62481a0..bccbf2c 100644 --- a/pkg/rbac/entities/entities.go +++ b/pkg/rbac/entities/entities.go @@ -23,6 +23,7 @@ const ( ResourceShare = "Share" ResourceAPIKey = "APIKey" ResourceCallStats = "CallStats" + ResourceSetting = "Setting" ActionRead = "read" ActionCreate = "create" @@ -97,3 +98,9 @@ func (s *SystemServiceSubject) String() string { func (s *SystemServiceSubject) GetRoles() []string { return []string{RoleSystem} } + +func IsSystemService(sub Subject) bool { + _, is := sub.(*SystemServiceSubject) + + return is +} diff --git a/pkg/rbac/policy/policy.go b/pkg/rbac/policy/policy.go index b6d8634..1c8c524 100644 --- a/pkg/rbac/policy/policy.go +++ b/pkg/rbac/policy/policy.go @@ -50,6 +50,9 @@ var Policy = &restrict.PolicyDefinition{ entities.ResourceCallStats: { &restrict.Permission{Action: entities.ActionRead}, }, + entities.ResourceSetting: { + &restrict.Permission{Action: entities.ActionRead}, + }, }, }, entities.RoleSubmitter: { @@ -108,6 +111,12 @@ var Policy = &restrict.PolicyDefinition{ &restrict.Permission{Action: entities.ActionCreate}, &restrict.Permission{Action: entities.ActionDelete}, }, + entities.ResourceSetting: { + &restrict.Permission{Action: entities.ActionRead}, + &restrict.Permission{Action: entities.ActionCreate}, + &restrict.Permission{Action: entities.ActionUpdate}, + &restrict.Permission{Action: entities.ActionDelete}, + }, }, }, entities.RoleSystem: { diff --git a/pkg/server/server.go b/pkg/server/server.go index ec8d07b..b9c3497 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -20,6 +20,7 @@ import ( "dynatron.me/x/stillbox/pkg/rbac/policy" "dynatron.me/x/stillbox/pkg/rest" "dynatron.me/x/stillbox/pkg/services" + "dynatron.me/x/stillbox/pkg/settings" "dynatron.me/x/stillbox/pkg/shares" "dynatron.me/x/stillbox/pkg/sinks" "dynatron.me/x/stillbox/pkg/sources" @@ -57,6 +58,7 @@ type Server struct { share shares.Service rbac rbac.RBAC stats stats.Stats + settings settings.Store } func New(ctx context.Context, cfg *config.Configuration) (*Server, error) { @@ -110,6 +112,7 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) { incidents: incstore.NewStore(), rbac: rbacSvc, stats: statsSvc, + settings: settings.New(), } if cfg.DB.Partition.Enabled { @@ -176,6 +179,7 @@ func (s *Server) fillCtx(ctx context.Context) context.Context { ctx = shares.CtxWithStore(ctx, s.share) ctx = rbac.CtxWithRBAC(ctx, s.rbac) ctx = stats.CtxWithStats(ctx, s.stats) + ctx = settings.CtxWithStore(ctx, s.settings) return ctx } diff --git a/pkg/settings/settings.go b/pkg/settings/settings.go new file mode 100644 index 0000000..b029cff --- /dev/null +++ b/pkg/settings/settings.go @@ -0,0 +1,131 @@ +package settings + +import ( + "context" + "encoding/json" + "errors" + + "dynatron.me/x/stillbox/internal/cache" + "dynatron.me/x/stillbox/pkg/database" + "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" + "dynatron.me/x/stillbox/pkg/services" + "dynatron.me/x/stillbox/pkg/users" +) + +var ( + ErrNoSetting = errors.New("no such setting") +) + +type Store interface { + // Get gets a setting and unmarshals it into dst. + Get(ctx context.Context, name string, dest interface{}) error + + // Set sets a setting. + Set(ctx context.Context, name string, val interface{}) error + + // Delete removes a setting. + Delete(ctx context.Context, name string) error +} + +type postgresStore struct { + c cache.Cache[string, []byte] +} + +type storeCtxKey string + +const StoreCtxKey storeCtxKey = "store" + +func CtxWithStore(ctx context.Context, s Store) context.Context { + return services.WithValue(ctx, StoreCtxKey, s) +} + +func FromCtx(ctx context.Context) Store { + s, ok := services.Value(ctx, StoreCtxKey).(Store) + if !ok { + panic("no settings store in context") + } + + return s +} + +func New() *postgresStore { + s := &postgresStore{ + c: cache.New[string, []byte](), + } + + return s +} + +func (s *postgresStore) Get(ctx context.Context, name string, dest interface{}) error { + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceSetting), rbac.WithActions(entities.ActionRead)) + if err != nil { + return err + } + + ci, has := s.c.Get(name) + if !has { + db := database.FromCtx(ctx) + + ci, err = db.GetSetting(ctx, name) + if err != nil { + if database.IsNoRows(err) { + return ErrNoSetting + } + + return err + } + + s.c.Set(name, ci) + } + + return json.Unmarshal(ci, dest) +} + +func (s *postgresStore) Set(ctx context.Context, name string, val interface{}) error { + subj, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceSetting), rbac.WithActions(entities.ActionCreate, entities.ActionUpdate)) + if err != nil { + return err + } + + b, err := json.Marshal(val) + if err != nil { + return err + } + + var uid *int32 + switch u := subj.(type) { + case *entities.SystemServiceSubject: + // uid remains null + case *users.User: + u, err := users.FromSubject(subj) + if err != nil { + return err + } + + uid = u.ID.Int32Ptr() + default: + return rbac.ErrBadSubject + } + + db := database.FromCtx(ctx) + + err = db.SetSetting(ctx, name, uid, b) + if err != nil { + return err + } + + s.c.Set(name, b) + + return nil +} + +func (s *postgresStore) Delete(ctx context.Context, name string) error { + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceSetting), rbac.WithActions(entities.ActionDelete)) + if err != nil { + return err + } + + s.c.Delete(name) + return database.FromCtx(ctx).DeleteSetting(ctx, name) +} diff --git a/sql/postgres/queries/settings.sql b/sql/postgres/queries/settings.sql new file mode 100644 index 0000000..e4f99cf --- /dev/null +++ b/sql/postgres/queries/settings.sql @@ -0,0 +1,11 @@ +-- name: GetSetting :one +SELECT value FROM settings WHERE name = @name; + +-- name: SetSetting :exec +INSERT INTO settings (name, updated_by, value) VALUES (@name, @updated_by, @value) + ON CONFLICT (name) DO UPDATE SET + value = @value, + updated_by = @updated_by; + +-- name: DeleteSetting :exec +DELETE FROM settings WHERE name = @name; -- 2.48.1 From 31d50f1191cb5029e4c7b2f0e7f61fa12f01a466 Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Sun, 23 Feb 2025 18:15:59 -0500 Subject: [PATCH 2/8] Settings defaults --- pkg/server/server.go | 7 ++++- pkg/settings/defaults.go | 7 +++++ pkg/settings/settings.go | 68 +++++++++++++++++++++++++++++----------- 3 files changed, 62 insertions(+), 20 deletions(-) create mode 100644 pkg/settings/defaults.go diff --git a/pkg/server/server.go b/pkg/server/server.go index b9c3497..1de9d6a 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -17,6 +17,7 @@ import ( "dynatron.me/x/stillbox/pkg/nexus" "dynatron.me/x/stillbox/pkg/notify" "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/rbac/policy" "dynatron.me/x/stillbox/pkg/rest" "dynatron.me/x/stillbox/pkg/services" @@ -191,6 +192,11 @@ func (s *Server) Go(ctx context.Context) error { ctx = s.fillCtx(ctx) + err := s.settings.PrimeDefaults(entities.CtxWithServiceSubject(ctx, "settings"), settings.ConfigDefaults) + if err != nil { + return err + } + httpSrv := &http.Server{ Addr: s.conf.Listen, Handler: s.r, @@ -204,7 +210,6 @@ func (s *Server) Go(ctx context.Context) error { go pm.Go(ctx) } - var err error go func() { err = httpSrv.ListenAndServe() }() diff --git a/pkg/settings/defaults.go b/pkg/settings/defaults.go new file mode 100644 index 0000000..b23256c --- /dev/null +++ b/pkg/settings/defaults.go @@ -0,0 +1,7 @@ +package settings + +type Defaults map[string]Setting + +var ConfigDefaults = Defaults{ + "calls.view.showSourceAlias": false, +} diff --git a/pkg/settings/settings.go b/pkg/settings/settings.go index b029cff..b19e987 100644 --- a/pkg/settings/settings.go +++ b/pkg/settings/settings.go @@ -19,17 +19,23 @@ var ( type Store interface { // Get gets a setting and unmarshals it into dst. - Get(ctx context.Context, name string, dest interface{}) error + Get(ctx context.Context, name string) (Setting, error) // Set sets a setting. - Set(ctx context.Context, name string, val interface{}) error + Set(ctx context.Context, name string, val Setting) error + + // PrimeDefaults primes the cache with defaults and sets them in the database if they do not exist. + PrimeDefaults(ctx context.Context, def Defaults) error // Delete removes a setting. Delete(ctx context.Context, name string) error } +type Setting interface { +} + type postgresStore struct { - c cache.Cache[string, []byte] + c cache.Cache[string, Setting] } type storeCtxKey string @@ -51,38 +57,44 @@ func FromCtx(ctx context.Context) Store { func New() *postgresStore { s := &postgresStore{ - c: cache.New[string, []byte](), + c: cache.New[string, Setting](), } return s } -func (s *postgresStore) Get(ctx context.Context, name string, dest interface{}) error { +func (s *postgresStore) Get(ctx context.Context, name string) (Setting, error) { _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceSetting), rbac.WithActions(entities.ActionRead)) if err != nil { - return err + return nil, err } ci, has := s.c.Get(name) - if !has { - db := database.FromCtx(ctx) + if has { + return ci, nil + } + db := database.FromCtx(ctx) - ci, err = db.GetSetting(ctx, name) - if err != nil { - if database.IsNoRows(err) { - return ErrNoSetting - } - - return err + cBytes, err := db.GetSetting(ctx, name) + if err != nil { + if database.IsNoRows(err) { + return nil, ErrNoSetting } - s.c.Set(name, ci) + return nil, err } - return json.Unmarshal(ci, dest) + err = json.Unmarshal(cBytes, ci) + if err != nil { + return nil, err + } + + s.c.Set(name, ci) + + return ci, nil } -func (s *postgresStore) Set(ctx context.Context, name string, val interface{}) error { +func (s *postgresStore) Set(ctx context.Context, name string, val Setting) error { subj, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceSetting), rbac.WithActions(entities.ActionCreate, entities.ActionUpdate)) if err != nil { return err @@ -115,7 +127,7 @@ func (s *postgresStore) Set(ctx context.Context, name string, val interface{}) e return err } - s.c.Set(name, b) + s.c.Set(name, val) return nil } @@ -129,3 +141,21 @@ func (s *postgresStore) Delete(ctx context.Context, name string) error { s.c.Delete(name) return database.FromCtx(ctx).DeleteSetting(ctx, name) } + +func (s *postgresStore) PrimeDefaults(ctx context.Context, def Defaults) error { + for k, v := range def { + _, err := s.Get(ctx, k) + switch err { + case nil: + case ErrNoSetting: + err = s.Set(ctx, k, v) + if err != nil { + return err + } + default: + return err + } + } + + return nil +} -- 2.48.1 From b2758816970c004ee71a5068af6be1793ddafff3 Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Mon, 24 Feb 2025 08:48:44 -0500 Subject: [PATCH 3/8] jit default --- pkg/server/server.go | 10 ++-------- pkg/settings/settings.go | 34 ++++++++++------------------------ 2 files changed, 12 insertions(+), 32 deletions(-) diff --git a/pkg/server/server.go b/pkg/server/server.go index 1de9d6a..0555819 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -17,7 +17,6 @@ import ( "dynatron.me/x/stillbox/pkg/nexus" "dynatron.me/x/stillbox/pkg/notify" "dynatron.me/x/stillbox/pkg/rbac" - "dynatron.me/x/stillbox/pkg/rbac/entities" "dynatron.me/x/stillbox/pkg/rbac/policy" "dynatron.me/x/stillbox/pkg/rest" "dynatron.me/x/stillbox/pkg/services" @@ -113,7 +112,7 @@ func New(ctx context.Context, cfg *config.Configuration) (*Server, error) { incidents: incstore.NewStore(), rbac: rbacSvc, stats: statsSvc, - settings: settings.New(), + settings: settings.New(settings.ConfigDefaults), } if cfg.DB.Partition.Enabled { @@ -180,7 +179,6 @@ func (s *Server) fillCtx(ctx context.Context) context.Context { ctx = shares.CtxWithStore(ctx, s.share) ctx = rbac.CtxWithRBAC(ctx, s.rbac) ctx = stats.CtxWithStats(ctx, s.stats) - ctx = settings.CtxWithStore(ctx, s.settings) return ctx } @@ -192,11 +190,6 @@ func (s *Server) Go(ctx context.Context) error { ctx = s.fillCtx(ctx) - err := s.settings.PrimeDefaults(entities.CtxWithServiceSubject(ctx, "settings"), settings.ConfigDefaults) - if err != nil { - return err - } - httpSrv := &http.Server{ Addr: s.conf.Listen, Handler: s.r, @@ -210,6 +203,7 @@ func (s *Server) Go(ctx context.Context) error { go pm.Go(ctx) } + var err error go func() { err = httpSrv.ListenAndServe() }() diff --git a/pkg/settings/settings.go b/pkg/settings/settings.go index b19e987..a5330d9 100644 --- a/pkg/settings/settings.go +++ b/pkg/settings/settings.go @@ -24,9 +24,6 @@ type Store interface { // Set sets a setting. Set(ctx context.Context, name string, val Setting) error - // PrimeDefaults primes the cache with defaults and sets them in the database if they do not exist. - PrimeDefaults(ctx context.Context, def Defaults) error - // Delete removes a setting. Delete(ctx context.Context, name string) error } @@ -35,7 +32,8 @@ type Setting interface { } type postgresStore struct { - c cache.Cache[string, Setting] + c cache.Cache[string, Setting] + defaults Defaults } type storeCtxKey string @@ -55,9 +53,10 @@ func FromCtx(ctx context.Context) Store { return s } -func New() *postgresStore { +func New(defaults Defaults) *postgresStore { s := &postgresStore{ - c: cache.New[string, Setting](), + c: cache.New[string, Setting](), + defaults: defaults, } return s @@ -78,6 +77,11 @@ func (s *postgresStore) Get(ctx context.Context, name string) (Setting, error) { cBytes, err := db.GetSetting(ctx, name) if err != nil { if database.IsNoRows(err) { + def, hasDefault := s.defaults[name] + if hasDefault { + return def, nil + } + return nil, ErrNoSetting } @@ -141,21 +145,3 @@ func (s *postgresStore) Delete(ctx context.Context, name string) error { s.c.Delete(name) return database.FromCtx(ctx).DeleteSetting(ctx, name) } - -func (s *postgresStore) PrimeDefaults(ctx context.Context, def Defaults) error { - for k, v := range def { - _, err := s.Get(ctx, k) - switch err { - case nil: - case ErrNoSetting: - err = s.Set(ctx, k, v) - if err != nil { - return err - } - default: - return err - } - } - - return nil -} -- 2.48.1 From c6ca85663500463f25b7b911a993ccf55444c97c Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Tue, 25 Feb 2025 21:42:07 -0500 Subject: [PATCH 4/8] Initial settings --- pkg/rest/api.go | 3 + pkg/rest/prefs.go | 128 +++++++++++++++++++++++++++++++++++++++ pkg/rest/users.go | 95 +---------------------------- pkg/server/server.go | 1 + pkg/settings/defaults.go | 17 +++++- pkg/settings/settings.go | 82 +++++++++++++++++++++---- 6 files changed, 219 insertions(+), 107 deletions(-) create mode 100644 pkg/rest/prefs.go diff --git a/pkg/rest/api.go b/pkg/rest/api.go index 6167393..6d75f97 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -35,6 +35,7 @@ type api struct { calls *callsAPI users *usersAPI incidents *incidentsAPI + prefs *prefsAPI } func (a *api) ShareRouter() http.Handler { @@ -48,6 +49,7 @@ func New(baseURL url.URL) *api { calls: new(callsAPI), incidents: newIncidentsAPI(&baseURL), users: new(usersAPI), + prefs: new(prefsAPI), } s.shares = newShareAPI(&baseURL, s.shareHandlers()) return s @@ -61,6 +63,7 @@ func (a *api) Subrouter() http.Handler { r.Mount("/call", a.calls.Subrouter()) r.Mount("/incident", a.incidents.Subrouter()) r.Mount("/share", a.shares.Subrouter()) + r.Mount("/prefs", a.prefs.Subrouter()) return r } diff --git a/pkg/rest/prefs.go b/pkg/rest/prefs.go new file mode 100644 index 0000000..95a8853 --- /dev/null +++ b/pkg/rest/prefs.go @@ -0,0 +1,128 @@ +package rest + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "dynatron.me/x/stillbox/pkg/auth" + "dynatron.me/x/stillbox/pkg/rbac" + "dynatron.me/x/stillbox/pkg/settings" + "dynatron.me/x/stillbox/pkg/users" + + "github.com/go-chi/chi/v5" +) + +var ( + ErrBadAppName = errors.New("bad app name") +) + +type prefsAPI struct { +} + +func (pa *prefsAPI) Subrouter() http.Handler { + r := chi.NewMux() + + r.Get(`/{appName}`, pa.getPrefs) + r.Put(`/{appName}`, pa.putPrefs) + + return r +} + +func (pa *prefsAPI) getPrefs(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + username := auth.UsernameFrom(ctx) + + if username == nil { + wErr(w, r, autoError(rbac.ErrBadSubject)) + return + } + + p := struct { + AppName *string `param:"appName"` + }{} + + err := decodeParams(&p, r) + if err != nil { + wErr(w, r, badRequest(err)) + return + } + + if p.AppName == nil { + wErr(w, r, autoError(ErrBadAppName)) + return + } + + us := users.FromCtx(ctx) + prefs, err := us.UserPrefs(ctx, *username, *p.AppName) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + sysPrefs, err := settings.FromCtx(ctx).GetPrefs(ctx, *p.AppName) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + po := struct { + User json.RawMessage `json:"userPrefs"` + System json.RawMessage `json:"sysPrefs"` + }{ + User: prefs, + System: sysPrefs, + } + + respond(w, r, po) +} + +func (pa *prefsAPI) putPrefs(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + username := auth.UsernameFrom(ctx) + + if username == nil { + wErr(w, r, autoError(rbac.ErrBadSubject)) + return + } + + contentType := strings.Split(r.Header.Get("Content-Type"), ";")[0] + if contentType != "application/json" { + wErr(w, r, badRequest(errors.New("only json accepted"))) + return + } + + p := struct { + AppName *string `param:"appName"` + }{} + + err := decodeParams(&p, r) + if err != nil { + wErr(w, r, badRequest(err)) + return + } + + if p.AppName == nil { + wErr(w, r, autoError(ErrBadAppName)) + return + } + + prefs, err := io.ReadAll(r.Body) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + us := users.FromCtx(ctx) + err = us.SetUserPrefs(ctx, *username, *p.AppName, prefs) + if err != nil { + wErr(w, r, autoError(err)) + return + } + + _, _ = w.Write(prefs) +} diff --git a/pkg/rest/users.go b/pkg/rest/users.go index 1fa6703..0fb816d 100644 --- a/pkg/rest/users.go +++ b/pkg/rest/users.go @@ -1,112 +1,21 @@ package rest import ( - "errors" - "io" "net/http" - "strings" - - "dynatron.me/x/stillbox/pkg/auth" - "dynatron.me/x/stillbox/pkg/rbac" - "dynatron.me/x/stillbox/pkg/users" "github.com/go-chi/chi/v5" ) -var ( - ErrBadAppName = errors.New("bad app name") -) - type usersAPI struct { } func (ua *usersAPI) Subrouter() http.Handler { r := chi.NewMux() - r.Get(`/prefs/{appName}`, ua.getPrefs) - r.Put(`/prefs/{appName}`, ua.putPrefs) + r.Get("/{user}", ua.getUser) return r } -func (ua *usersAPI) getPrefs(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - username := auth.UsernameFrom(ctx) - - if username == nil { - wErr(w, r, autoError(rbac.ErrBadSubject)) - return - } - - p := struct { - AppName *string `param:"appName"` - }{} - - err := decodeParams(&p, r) - if err != nil { - wErr(w, r, badRequest(err)) - return - } - - if p.AppName == nil { - wErr(w, r, autoError(ErrBadAppName)) - return - } - - us := users.FromCtx(ctx) - prefs, err := us.UserPrefs(ctx, *username, *p.AppName) - if err != nil { - wErr(w, r, autoError(err)) - return - } - - _, _ = w.Write(prefs) -} - -func (ua *usersAPI) putPrefs(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - username := auth.UsernameFrom(ctx) - - if username == nil { - wErr(w, r, autoError(rbac.ErrBadSubject)) - return - } - - contentType := strings.Split(r.Header.Get("Content-Type"), ";")[0] - if contentType != "application/json" { - wErr(w, r, badRequest(errors.New("only json accepted"))) - return - } - - p := struct { - AppName *string `param:"appName"` - }{} - - err := decodeParams(&p, r) - if err != nil { - wErr(w, r, badRequest(err)) - return - } - - if p.AppName == nil { - wErr(w, r, autoError(ErrBadAppName)) - return - } - - prefs, err := io.ReadAll(r.Body) - if err != nil { - wErr(w, r, autoError(err)) - return - } - - us := users.FromCtx(ctx) - err = us.SetUserPrefs(ctx, *username, *p.AppName, prefs) - if err != nil { - wErr(w, r, autoError(err)) - return - } - - _, _ = w.Write(prefs) +func (ua *usersAPI) getUser(w http.ResponseWriter, r *http.Request) { } diff --git a/pkg/server/server.go b/pkg/server/server.go index 0555819..3fcb0dc 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -179,6 +179,7 @@ func (s *Server) fillCtx(ctx context.Context) context.Context { ctx = shares.CtxWithStore(ctx, s.share) ctx = rbac.CtxWithRBAC(ctx, s.rbac) ctx = stats.CtxWithStats(ctx, s.stats) + ctx = settings.CtxWithStore(ctx, s.settings) return ctx } diff --git a/pkg/settings/defaults.go b/pkg/settings/defaults.go index b23256c..654247a 100644 --- a/pkg/settings/defaults.go +++ b/pkg/settings/defaults.go @@ -1,7 +1,20 @@ package settings -type Defaults map[string]Setting +import "encoding/json" + +type Defaults map[Setting]Setting + +func MustMarshal(s Setting) json.RawMessage { + b, err := json.Marshal(s) + if err != nil { + panic(err) + } + + return b +} var ConfigDefaults = Defaults{ - "calls.view.showSourceAlias": false, + prefsName("stillbox"): MustMarshal(Defaults{ + "calls.view.showSourceAlias": false, + }), } diff --git a/pkg/settings/settings.go b/pkg/settings/settings.go index a5330d9..5ba376a 100644 --- a/pkg/settings/settings.go +++ b/pkg/settings/settings.go @@ -21,6 +21,12 @@ type Store interface { // Get gets a setting and unmarshals it into dst. Get(ctx context.Context, name string) (Setting, error) + // GetPrefs gets system prefs for an app name. + GetPrefs(ctx context.Context, appName string) (json.RawMessage, error) + + // SetPrefs gets system prefs for an app name. + SetPrefs(ctx context.Context, appName string, val Setting) error + // Set sets a setting. Set(ctx context.Context, name string, val Setting) error @@ -62,6 +68,30 @@ func New(defaults Defaults) *postgresStore { return s } +func (s *postgresStore) defaultPrefs(appName string) json.RawMessage { + d, has := s.defaults[prefsName(appName)].(json.RawMessage) + if has { + return d + } + + return nil +} + +func (s *postgresStore) getJSONB(ctx context.Context, name string) (json.RawMessage, error) { + db := database.FromCtx(ctx) + + sRes, err := db.GetSetting(ctx, name) + if err != nil && database.IsNoRows(err) { + return nil, ErrNoSetting + } + + return sRes, err +} + +func prefsName(appName string) string { + return "prefs." + appName +} + func (s *postgresStore) Get(ctx context.Context, name string) (Setting, error) { _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceSetting), rbac.WithActions(entities.ActionRead)) if err != nil { @@ -72,23 +102,17 @@ func (s *postgresStore) Get(ctx context.Context, name string) (Setting, error) { if has { return ci, nil } - db := database.FromCtx(ctx) - - cBytes, err := db.GetSetting(ctx, name) - if err != nil { - if database.IsNoRows(err) { - def, hasDefault := s.defaults[name] - if hasDefault { - return def, nil - } - - return nil, ErrNoSetting + sRes, err := s.getJSONB(ctx, name) + if err != nil && errors.Is(err, ErrNoSetting) { + def, hasDefault := s.defaults[name] + if hasDefault { + return def, nil } return nil, err } - err = json.Unmarshal(cBytes, ci) + err = json.Unmarshal(sRes, &ci) if err != nil { return nil, err } @@ -98,6 +122,40 @@ func (s *postgresStore) Get(ctx context.Context, name string) (Setting, error) { return ci, nil } +func (s *postgresStore) GetPrefs(ctx context.Context, appName string) (json.RawMessage, error) { + _, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceSetting), rbac.WithActions(entities.ActionRead)) + if err != nil { + return nil, err + } + + prName := prefsName(appName) + ci, has := s.c.Get(prName) + if has { + rm, isRM := ci.(json.RawMessage) + if isRM { + return rm, nil + } + } + + p, err := s.getJSONB(ctx, prName) + if errors.Is(err, ErrNoSetting) { + def, hasDefault := s.defaults[prName].(json.RawMessage) + if hasDefault { + return def, nil + } + + return nil, err + } + + s.c.Set(prName, p) + + return p, nil +} + +func (s *postgresStore) SetPrefs(ctx context.Context, appName string, val Setting) error { + return s.Set(ctx, prefsName(appName), val) +} + func (s *postgresStore) Set(ctx context.Context, name string, val Setting) error { subj, err := rbac.Check(ctx, rbac.UseResource(entities.ResourceSetting), rbac.WithActions(entities.ActionCreate, entities.ActionUpdate)) if err != nil { -- 2.48.1 From 9daa71609fd9d1d1c7194aca83d999d70ff9fa7a Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Tue, 25 Feb 2025 22:19:28 -0500 Subject: [PATCH 5/8] UI WIP --- .../stillbox/src/app/prefs/prefs.service.ts | 62 +++++++++++++++---- .../talkgroup-record.component.html | 2 +- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/client/stillbox/src/app/prefs/prefs.service.ts b/client/stillbox/src/app/prefs/prefs.service.ts index 64037dc..ac284dd 100644 --- a/client/stillbox/src/app/prefs/prefs.service.ts +++ b/client/stillbox/src/app/prefs/prefs.service.ts @@ -1,16 +1,18 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { - BehaviorSubject, - concatMap, + map, Observable, ReplaySubject, - share, shareReplay, Subscription, switchMap, } from 'rxjs'; +export interface UserSysPreferences { + userPrefs: Preferences; + sysPrefs: Preferences; +} export interface Preferences { [key: string]: any; } @@ -28,8 +30,8 @@ function mapToObj(map: Map): { [key: string]: any } { }) export class PrefsService { private readonly _getPref = new Map>(); - prefs$: Observable; - last!: Preferences; + prefs$: Observable; + last!: UserSysPreferences; subscriptions = new Subscription(); constructor(private http: HttpClient) { @@ -57,19 +59,23 @@ export class PrefsService { } }); } else { - this.last = {}; + this.last = {}; } }), ); } - fetch(): Observable { - return this.http.get('/api/user/prefs/stillbox'); + fetch(): Observable { + return this.http.get('/api/prefs/stillbox'); } get(k: string): Observable { if (!this._getPref.get(k)) { return this.prefs$.pipe( + map((res) => { + let rv = res.sysPrefs; + return mergeDeep(rv, res.userPrefs); + }), switchMap((pref) => { let ns = new ReplaySubject(1); ns.next(pref ? pref[k] : null); @@ -82,14 +88,48 @@ export class PrefsService { } set(pref: string, value: any) { - this.last[pref] = value; + this.last.userPrefs[pref] = value; let ex = this._getPref.get(pref); - if (!ex) { + if (ex == null) { ex = new ReplaySubject(1); this._getPref.set(pref, ex); } + console.log("set", pref, value); this.http - .put('/api/user/prefs/stillbox', this.last) + .put('/api/prefs/stillbox', this.last.userPrefs) .subscribe((ev) => {}); + console.log("set", pref, value); } } + +/** + * Simple object check. + * @param item + * @returns {boolean} + */ +export function isObject(item: any): boolean { + return (item && typeof item === 'object' && !Array.isArray(item)); +} + +/** + * Deep merge two objects. + * @param target + * @param ...sources + */ +export function mergeDeep(target: any, ...sources: any): any { + if (!sources.length) return target; + const source = sources.shift(); + + if (isObject(target) && isObject(source)) { + for (const key in source) { + if (isObject(source[key])) { + if (!target[key]) Object.assign(target, { [key]: {} }); + mergeDeep(target[key], source[key]); + } else { + Object.assign(target, { [key]: source[key] }); + } + } + } + + return mergeDeep(target, ...sources); +} \ No newline at end of file diff --git a/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.html b/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.html index 25ead7a..1e872e7 100644 --- a/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.html +++ b/client/stillbox/src/app/talkgroups/talkgroup-record/talkgroup-record.component.html @@ -26,7 +26,7 @@ Group -- 2.48.1 From 8f27ba6735dd242ad5d6bdd44586e97287d4e7a9 Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Wed, 26 Feb 2025 11:56:25 -0500 Subject: [PATCH 6/8] Fix prefs --- .../stillbox/src/app/prefs/prefs.service.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/client/stillbox/src/app/prefs/prefs.service.ts b/client/stillbox/src/app/prefs/prefs.service.ts index ac284dd..f6c07bf 100644 --- a/client/stillbox/src/app/prefs/prefs.service.ts +++ b/client/stillbox/src/app/prefs/prefs.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { map, + merge, Observable, ReplaySubject, shareReplay, @@ -72,11 +73,9 @@ export class PrefsService { get(k: string): Observable { if (!this._getPref.get(k)) { return this.prefs$.pipe( - map((res) => { + switchMap((res) => { let rv = res.sysPrefs; - return mergeDeep(rv, res.userPrefs); - }), - switchMap((pref) => { + let pref = mergeDeep(rv, res.userPrefs); let ns = new ReplaySubject(1); ns.next(pref ? pref[k] : null); return ns; @@ -88,17 +87,23 @@ export class PrefsService { } set(pref: string, value: any) { + if (this.last == null) { + this.last = {}; + } + if (this.last.userPrefs == null) { + this.last.userPrefs = {}; + } this.last.userPrefs[pref] = value; let ex = this._getPref.get(pref); if (ex == null) { ex = new ReplaySubject(1); this._getPref.set(pref, ex); + ex.next(value); } - console.log("set", pref, value); + console.log('gonna up'); this.http .put('/api/prefs/stillbox', this.last.userPrefs) .subscribe((ev) => {}); - console.log("set", pref, value); } } @@ -108,7 +113,7 @@ export class PrefsService { * @returns {boolean} */ export function isObject(item: any): boolean { - return (item && typeof item === 'object' && !Array.isArray(item)); + return item && typeof item === 'object' && !Array.isArray(item); } /** @@ -132,4 +137,4 @@ export function mergeDeep(target: any, ...sources: any): any { } return mergeDeep(target, ...sources); -} \ No newline at end of file +} -- 2.48.1 From 9ac453e04210be7f4c28a1ca782c7d8b5bf01615 Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Wed, 26 Feb 2025 12:56:24 -0500 Subject: [PATCH 7/8] Fix --- pkg/settings/defaults.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/settings/defaults.go b/pkg/settings/defaults.go index 654247a..82bf00d 100644 --- a/pkg/settings/defaults.go +++ b/pkg/settings/defaults.go @@ -2,9 +2,11 @@ package settings import "encoding/json" -type Defaults map[Setting]Setting +type Defaults map[string]Setting -func MustMarshal(s Setting) json.RawMessage { + + +func MustMarshal(s interface{}) json.RawMessage { b, err := json.Marshal(s) if err != nil { panic(err) -- 2.48.1 From 4f3f003b6a2c245b5c2990f310503671954dfe83 Mon Sep 17 00:00:00 2001 From: Daniel Ponte Date: Wed, 26 Feb 2025 12:59:27 -0500 Subject: [PATCH 8/8] Add show talker --- client/stillbox/src/app/calls/calls.component.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/stillbox/src/app/calls/calls.component.ts b/client/stillbox/src/app/calls/calls.component.ts index f3df31d..3d92e53 100644 --- a/client/stillbox/src/app/calls/calls.component.ts +++ b/client/stillbox/src/app/calls/calls.component.ts @@ -260,6 +260,13 @@ export class CallsComponent { } }), ); + this.subscriptions.add( + this.prefsSvc.get('calls.view.showSourceAlias').subscribe((v) => { + if (v != true) { + this.columns = this.columns.filter((e) => e != 'talker'); + } + }), + ); this.subscriptions.add( this.fetchCalls .pipe( -- 2.48.1