Merge pull request 'System wide settings' (#125) from settings124 into trunk

Reviewed-on: #125
This commit is contained in:
Daniel Ponte 2025-02-26 13:01:56 -05:00
commit b32e437011
15 changed files with 656 additions and 106 deletions

View file

@ -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.subscriptions.add(
this.fetchCalls this.fetchCalls
.pipe( .pipe(

View file

@ -1,16 +1,19 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { import {
BehaviorSubject, map,
concatMap, merge,
Observable, Observable,
ReplaySubject, ReplaySubject,
share,
shareReplay, shareReplay,
Subscription, Subscription,
switchMap, switchMap,
} from 'rxjs'; } from 'rxjs';
export interface UserSysPreferences {
userPrefs: Preferences;
sysPrefs: Preferences;
}
export interface Preferences { export interface Preferences {
[key: string]: any; [key: string]: any;
} }
@ -28,8 +31,8 @@ function mapToObj(map: Map<any, any>): { [key: string]: any } {
}) })
export class PrefsService { export class PrefsService {
private readonly _getPref = new Map<string, ReplaySubject<any>>(); private readonly _getPref = new Map<string, ReplaySubject<any>>();
prefs$: Observable<Preferences>; prefs$: Observable<UserSysPreferences>;
last!: Preferences; last!: UserSysPreferences;
subscriptions = new Subscription(); subscriptions = new Subscription();
constructor(private http: HttpClient) { constructor(private http: HttpClient) {
@ -57,20 +60,22 @@ export class PrefsService {
} }
}); });
} else { } else {
this.last = {}; this.last = <UserSysPreferences>{};
} }
}), }),
); );
} }
fetch(): Observable<Preferences> { fetch(): Observable<UserSysPreferences> {
return this.http.get<Preferences>('/api/user/prefs/stillbox'); return this.http.get<UserSysPreferences>('/api/prefs/stillbox');
} }
get(k: string): Observable<any> { get(k: string): Observable<any> {
if (!this._getPref.get(k)) { if (!this._getPref.get(k)) {
return this.prefs$.pipe( return this.prefs$.pipe(
switchMap((pref) => { switchMap((res) => {
let rv = <Preferences>res.sysPrefs;
let pref = <Preferences>mergeDeep(rv, res.userPrefs);
let ns = new ReplaySubject<any>(1); let ns = new ReplaySubject<any>(1);
ns.next(pref ? pref[k] : null); ns.next(pref ? pref[k] : null);
return ns; return ns;
@ -82,14 +87,54 @@ export class PrefsService {
} }
set(pref: string, value: any) { set(pref: string, value: any) {
this.last[pref] = value; if (this.last == null) {
this.last = <UserSysPreferences>{};
}
if (this.last.userPrefs == null) {
this.last.userPrefs = <Preferences>{};
}
this.last.userPrefs[pref] = value;
let ex = this._getPref.get(pref); let ex = this._getPref.get(pref);
if (!ex) { if (ex == null) {
ex = new ReplaySubject<any>(1); ex = new ReplaySubject<any>(1);
this._getPref.set(pref, ex); this._getPref.set(pref, ex);
ex.next(value);
} }
console.log('gonna up');
this.http this.http
.put<Preferences>('/api/user/prefs/stillbox', this.last) .put<Preferences>('/api/prefs/stillbox', this.last.userPrefs)
.subscribe((ev) => {}); .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);
}

View file

@ -26,7 +26,7 @@
<mat-label>Group</mat-label <mat-label>Group</mat-label
><input ><input
matInput matInput
name="tgTroup" name="tgGroup"
type="text" type="text"
formControlName="tgGroup" formControlName="tgGroup"
/> />

View file

@ -947,6 +947,53 @@ func (_c *Store_DeleteIncident_Call) RunAndReturn(run func(context.Context, uuid
return _c 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 // DeleteShare provides a mock function with given fields: ctx, id
func (_m *Store) DeleteShare(ctx context.Context, id string) error { func (_m *Store) DeleteShare(ctx context.Context, id string) error {
ret := _m.Called(ctx, id) ret := _m.Called(ctx, id)
@ -1989,6 +2036,65 @@ func (_c *Store_GetIncidentTalkgroups_Call) RunAndReturn(run func(context.Contex
return _c 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 // GetShare provides a mock function with given fields: ctx, id
func (_m *Store) GetShare(ctx context.Context, id string) (database.Share, error) { func (_m *Store) GetShare(ctx context.Context, id string) (database.Share, error) {
ret := _m.Called(ctx, id) ret := _m.Called(ctx, id)
@ -3804,6 +3910,55 @@ func (_c *Store_SetCallTranscript_Call) RunAndReturn(run func(context.Context, u
return _c 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 // 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 { func (_m *Store) SetTalkgroupTags(ctx context.Context, tags []string, systemID int32, tGID int32) error {
ret := _m.Called(ctx, tags, systemID, tGID) ret := _m.Called(ctx, tags, systemID, tGID)

View file

@ -26,6 +26,7 @@ type Querier interface {
DeleteAPIKey(ctx context.Context, apiKey string) error DeleteAPIKey(ctx context.Context, apiKey string) error
DeleteCall(ctx context.Context, id uuid.UUID) error DeleteCall(ctx context.Context, id uuid.UUID) error
DeleteIncident(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 DeleteShare(ctx context.Context, id string) error
DeleteSystem(ctx context.Context, id int) error DeleteSystem(ctx context.Context, id int) error
DeleteTalkgroup(ctx context.Context, systemID int32, tGID int32) 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) GetIncidentCalls(ctx context.Context, id uuid.UUID) ([]GetIncidentCallsRow, error)
GetIncidentOwner(ctx context.Context, id uuid.UUID) (int, error) GetIncidentOwner(ctx context.Context, id uuid.UUID) (int, error)
GetIncidentTalkgroups(ctx context.Context, incidentID uuid.UUID) ([]GetIncidentTalkgroupsRow, 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) GetShare(ctx context.Context, id string) (Share, error)
GetSharesP(ctx context.Context, arg GetSharesPParams) ([]GetSharesPRow, error) GetSharesP(ctx context.Context, arg GetSharesPParams) ([]GetSharesPRow, error)
GetSharesPCount(ctx context.Context, owner *int32) (int64, error) GetSharesPCount(ctx context.Context, owner *int32) (int64, error)
@ -71,6 +73,7 @@ type Querier interface {
RestoreTalkgroupVersion(ctx context.Context, versionIds int) (Talkgroup, error) RestoreTalkgroupVersion(ctx context.Context, versionIds int) (Talkgroup, error)
SetAppPrefs(ctx context.Context, appName string, prefs []byte, uid int) error SetAppPrefs(ctx context.Context, appName string, prefs []byte, uid int) error
SetCallTranscript(ctx context.Context, iD uuid.UUID, transcript *string) 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 SetTalkgroupTags(ctx context.Context, tags []string, systemID int32, tGID int32) error
StoreDeletedTGVersion(ctx context.Context, systemID *int32, tGID *int32, submitter *int32) error StoreDeletedTGVersion(ctx context.Context, systemID *int32, tGID *int32, submitter *int32) error
StoreTGVersion(ctx context.Context, arg []StoreTGVersionParams) *StoreTGVersionBatchResults StoreTGVersion(ctx context.Context, arg []StoreTGVersionParams) *StoreTGVersionBatchResults

View file

@ -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
}

View file

@ -23,6 +23,7 @@ const (
ResourceShare = "Share" ResourceShare = "Share"
ResourceAPIKey = "APIKey" ResourceAPIKey = "APIKey"
ResourceCallStats = "CallStats" ResourceCallStats = "CallStats"
ResourceSetting = "Setting"
ActionRead = "read" ActionRead = "read"
ActionCreate = "create" ActionCreate = "create"
@ -97,3 +98,9 @@ func (s *SystemServiceSubject) String() string {
func (s *SystemServiceSubject) GetRoles() []string { func (s *SystemServiceSubject) GetRoles() []string {
return []string{RoleSystem} return []string{RoleSystem}
} }
func IsSystemService(sub Subject) bool {
_, is := sub.(*SystemServiceSubject)
return is
}

View file

@ -50,6 +50,9 @@ var Policy = &restrict.PolicyDefinition{
entities.ResourceCallStats: { entities.ResourceCallStats: {
&restrict.Permission{Action: entities.ActionRead}, &restrict.Permission{Action: entities.ActionRead},
}, },
entities.ResourceSetting: {
&restrict.Permission{Action: entities.ActionRead},
},
}, },
}, },
entities.RoleSubmitter: { entities.RoleSubmitter: {
@ -108,6 +111,12 @@ var Policy = &restrict.PolicyDefinition{
&restrict.Permission{Action: entities.ActionCreate}, &restrict.Permission{Action: entities.ActionCreate},
&restrict.Permission{Action: entities.ActionDelete}, &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: { entities.RoleSystem: {

View file

@ -35,6 +35,7 @@ type api struct {
calls *callsAPI calls *callsAPI
users *usersAPI users *usersAPI
incidents *incidentsAPI incidents *incidentsAPI
prefs *prefsAPI
} }
func (a *api) ShareRouter() http.Handler { func (a *api) ShareRouter() http.Handler {
@ -48,6 +49,7 @@ func New(baseURL url.URL) *api {
calls: new(callsAPI), calls: new(callsAPI),
incidents: newIncidentsAPI(&baseURL), incidents: newIncidentsAPI(&baseURL),
users: new(usersAPI), users: new(usersAPI),
prefs: new(prefsAPI),
} }
s.shares = newShareAPI(&baseURL, s.shareHandlers()) s.shares = newShareAPI(&baseURL, s.shareHandlers())
return s return s
@ -61,6 +63,7 @@ func (a *api) Subrouter() http.Handler {
r.Mount("/call", a.calls.Subrouter()) r.Mount("/call", a.calls.Subrouter())
r.Mount("/incident", a.incidents.Subrouter()) r.Mount("/incident", a.incidents.Subrouter())
r.Mount("/share", a.shares.Subrouter()) r.Mount("/share", a.shares.Subrouter())
r.Mount("/prefs", a.prefs.Subrouter())
return r return r
} }

128
pkg/rest/prefs.go Normal file
View file

@ -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)
}

View file

@ -1,112 +1,21 @@
package rest package rest
import ( import (
"errors"
"io"
"net/http" "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" "github.com/go-chi/chi/v5"
) )
var (
ErrBadAppName = errors.New("bad app name")
)
type usersAPI struct { type usersAPI struct {
} }
func (ua *usersAPI) Subrouter() http.Handler { func (ua *usersAPI) Subrouter() http.Handler {
r := chi.NewMux() r := chi.NewMux()
r.Get(`/prefs/{appName}`, ua.getPrefs) r.Get("/{user}", ua.getUser)
r.Put(`/prefs/{appName}`, ua.putPrefs)
return r return r
} }
func (ua *usersAPI) getPrefs(w http.ResponseWriter, r *http.Request) { func (ua *usersAPI) getUser(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)
} }

View file

@ -20,6 +20,7 @@ import (
"dynatron.me/x/stillbox/pkg/rbac/policy" "dynatron.me/x/stillbox/pkg/rbac/policy"
"dynatron.me/x/stillbox/pkg/rest" "dynatron.me/x/stillbox/pkg/rest"
"dynatron.me/x/stillbox/pkg/services" "dynatron.me/x/stillbox/pkg/services"
"dynatron.me/x/stillbox/pkg/settings"
"dynatron.me/x/stillbox/pkg/shares" "dynatron.me/x/stillbox/pkg/shares"
"dynatron.me/x/stillbox/pkg/sinks" "dynatron.me/x/stillbox/pkg/sinks"
"dynatron.me/x/stillbox/pkg/sources" "dynatron.me/x/stillbox/pkg/sources"
@ -57,6 +58,7 @@ type Server struct {
share shares.Service share shares.Service
rbac rbac.RBAC rbac rbac.RBAC
stats stats.Stats stats stats.Stats
settings settings.Store
} }
func New(ctx context.Context, cfg *config.Configuration) (*Server, error) { 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(), incidents: incstore.NewStore(),
rbac: rbacSvc, rbac: rbacSvc,
stats: statsSvc, stats: statsSvc,
settings: settings.New(settings.ConfigDefaults),
} }
if cfg.DB.Partition.Enabled { 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 = shares.CtxWithStore(ctx, s.share)
ctx = rbac.CtxWithRBAC(ctx, s.rbac) ctx = rbac.CtxWithRBAC(ctx, s.rbac)
ctx = stats.CtxWithStats(ctx, s.stats) ctx = stats.CtxWithStats(ctx, s.stats)
ctx = settings.CtxWithStore(ctx, s.settings)
return ctx return ctx
} }

22
pkg/settings/defaults.go Normal file
View file

@ -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,
}),
}

205
pkg/settings/settings.go Normal file
View file

@ -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)
}

View file

@ -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;