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( diff --git a/client/stillbox/src/app/prefs/prefs.service.ts b/client/stillbox/src/app/prefs/prefs.service.ts index 64037dc..f6c07bf 100644 --- a/client/stillbox/src/app/prefs/prefs.service.ts +++ b/client/stillbox/src/app/prefs/prefs.service.ts @@ -1,16 +1,19 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { - BehaviorSubject, - concatMap, + map, + merge, Observable, ReplaySubject, - share, shareReplay, Subscription, switchMap, } from 'rxjs'; +export interface UserSysPreferences { + userPrefs: Preferences; + sysPrefs: Preferences; +} export interface Preferences { [key: string]: any; } @@ -28,8 +31,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,20 +60,22 @@ 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( - switchMap((pref) => { + switchMap((res) => { + let rv = res.sysPrefs; + let pref = mergeDeep(rv, res.userPrefs); let ns = new ReplaySubject(1); ns.next(pref ? pref[k] : null); return ns; @@ -82,14 +87,54 @@ export class PrefsService { } set(pref: string, value: any) { - this.last[pref] = value; + 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) { + if (ex == null) { ex = new ReplaySubject(1); this._getPref.set(pref, ex); + ex.next(value); } + console.log('gonna up'); this.http - .put('/api/user/prefs/stillbox', this.last) + .put('/api/prefs/stillbox', this.last.userPrefs) .subscribe((ev) => {}); } } + +/** + * 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); +} 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 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/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 ec8d07b..3fcb0dc 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(settings.ConfigDefaults), } 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/defaults.go b/pkg/settings/defaults.go new file mode 100644 index 0000000..82bf00d --- /dev/null +++ b/pkg/settings/defaults.go @@ -0,0 +1,22 @@ +package settings + +import "encoding/json" + +type Defaults map[string]Setting + + + +func MustMarshal(s interface{}) json.RawMessage { + b, err := json.Marshal(s) + if err != nil { + panic(err) + } + + return b +} + +var ConfigDefaults = Defaults{ + prefsName("stillbox"): MustMarshal(Defaults{ + "calls.view.showSourceAlias": false, + }), +} diff --git a/pkg/settings/settings.go b/pkg/settings/settings.go new file mode 100644 index 0000000..5ba376a --- /dev/null +++ b/pkg/settings/settings.go @@ -0,0 +1,205 @@ +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) (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 + + // Delete removes a setting. + Delete(ctx context.Context, name string) error +} + +type Setting interface { +} + +type postgresStore struct { + c cache.Cache[string, Setting] + defaults Defaults +} + +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(defaults Defaults) *postgresStore { + s := &postgresStore{ + c: cache.New[string, Setting](), + defaults: defaults, + } + + 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 { + return nil, err + } + + ci, has := s.c.Get(name) + if has { + return ci, nil + } + 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(sRes, &ci) + if err != nil { + return nil, err + } + + s.c.Set(name, ci) + + 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 { + 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, val) + + 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;