System wide settings #125
15 changed files with 656 additions and 106 deletions
|
@ -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(
|
||||
|
|
|
@ -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<any, any>): { [key: string]: any } {
|
|||
})
|
||||
export class PrefsService {
|
||||
private readonly _getPref = new Map<string, ReplaySubject<any>>();
|
||||
prefs$: Observable<Preferences>;
|
||||
last!: Preferences;
|
||||
prefs$: Observable<UserSysPreferences>;
|
||||
last!: UserSysPreferences;
|
||||
subscriptions = new Subscription();
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
|
@ -57,20 +60,22 @@ export class PrefsService {
|
|||
}
|
||||
});
|
||||
} else {
|
||||
this.last = {};
|
||||
this.last = <UserSysPreferences>{};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
fetch(): Observable<Preferences> {
|
||||
return this.http.get<Preferences>('/api/user/prefs/stillbox');
|
||||
fetch(): Observable<UserSysPreferences> {
|
||||
return this.http.get<UserSysPreferences>('/api/prefs/stillbox');
|
||||
}
|
||||
|
||||
get(k: string): Observable<any> {
|
||||
if (!this._getPref.get(k)) {
|
||||
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);
|
||||
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 = <UserSysPreferences>{};
|
||||
}
|
||||
if (this.last.userPrefs == null) {
|
||||
this.last.userPrefs = <Preferences>{};
|
||||
}
|
||||
this.last.userPrefs[pref] = value;
|
||||
let ex = this._getPref.get(pref);
|
||||
if (!ex) {
|
||||
if (ex == null) {
|
||||
ex = new ReplaySubject<any>(1);
|
||||
this._getPref.set(pref, ex);
|
||||
ex.next(value);
|
||||
}
|
||||
console.log('gonna up');
|
||||
this.http
|
||||
.put<Preferences>('/api/user/prefs/stillbox', this.last)
|
||||
.put<Preferences>('/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);
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
<mat-label>Group</mat-label
|
||||
><input
|
||||
matInput
|
||||
name="tgTroup"
|
||||
name="tgGroup"
|
||||
type="text"
|
||||
formControlName="tgGroup"
|
||||
/>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
42
pkg/database/settings.sql.go
Normal file
42
pkg/database/settings.sql.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
128
pkg/rest/prefs.go
Normal file
128
pkg/rest/prefs.go
Normal 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)
|
||||
}
|
|
@ -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) {
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
22
pkg/settings/defaults.go
Normal file
22
pkg/settings/defaults.go
Normal 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
205
pkg/settings/settings.go
Normal 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)
|
||||
}
|
11
sql/postgres/queries/settings.sql
Normal file
11
sql/postgres/queries/settings.sql
Normal 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;
|
Loading…
Add table
Reference in a new issue