From 09a31bf77f00ddf3a8ebccb39a0104ec2adf0a42 Mon Sep 17 00:00:00 2001 From: Randy Hammond Date: Fri, 24 Apr 2026 19:06:11 +0000 Subject: [PATCH] refactor(backend): extract admin CRUD into internal/admin package Phase 2 of the directory restructure. The WebSocket layer is now protocol-only; admin CRUD / config / import-export business logic lives in a new transport-agnostic internal/admin package. Changes: - New internal/admin/ package with Operations struct. Files split by feature: users.go, systems.go, talkgroups.go, tags.go, groups.go, units.go, api_keys.go, dirmonitors.go, downstreams.go, webhooks.go, shared_links.go, settings.go, transcription.go, radioreference.go, filesystem.go, imports.go, exports.go. Does not import internal/ws or net/http. - internal/admin/operations.go defines the EventSink interface (BroadcastAdminEvent, BroadcastCFG, DisconnectByUser, ClientCount). ws.Hub implements it; Operations.New takes it at construction. - internal/ws/admin_router.go replaces admin_ops.go as the transport adapter. The adminOpHandlers map preserves every wire-protocol op name (users.list, systems.create, config.update, export.config, etc.) byte-identically. Live-state ops (activity.stats, activity.chart, logs.query, logs.level, activity.top-talkgroups) stay on *Client because they read hub in-memory state. - internal/ws/admin_ops.go deleted (3,201 lines removed). - Hub construction unchanged at the call site: NewHub(queries, version, HubDeps{...}) still works. HubDeps is now a type alias for admin.Deps. - cmd/server/main.go updated: SensitiveSettingKeys moved from ws to admin package. - Tests: admin_ops_settings_test.go split into internal/admin/settings_test.go (CRUD semantics) and internal/ws/admin_router_test.go (dispatch + error envelope). No wire-protocol, auth, or route changes. All frames, error envelopes, and action names are byte-identical to before. --- CHANGELOG.md | 4 + backend/cmd/server/main.go | 5 +- backend/internal/admin/api_keys.go | 145 + backend/internal/admin/dirmonitors.go | 171 + backend/internal/admin/downstreams.go | 160 + backend/internal/admin/exports.go | 241 ++ backend/internal/admin/filesystem.go | 78 + backend/internal/admin/groups.go | 112 + backend/internal/admin/imports.go | 396 ++ backend/internal/admin/operations.go | 466 +++ backend/internal/admin/radioreference.go | 233 ++ backend/internal/admin/settings.go | 174 + .../settings_test.go} | 90 +- backend/internal/admin/shared_links.go | 39 + backend/internal/admin/systems.go | 131 + backend/internal/admin/tags.go | 112 + backend/internal/admin/talkgroups.go | 141 + backend/internal/admin/transcription.go | 289 ++ backend/internal/admin/units.go | 146 + backend/internal/admin/users.go | 203 ++ backend/internal/admin/webhooks.go | 130 + backend/internal/ws/admin_ops.go | 3201 ----------------- backend/internal/ws/admin_router.go | 162 + backend/internal/ws/admin_router_test.go | 131 + backend/internal/ws/client.go | 18 +- backend/internal/ws/hub.go | 42 +- 26 files changed, 3724 insertions(+), 3296 deletions(-) create mode 100644 backend/internal/admin/api_keys.go create mode 100644 backend/internal/admin/dirmonitors.go create mode 100644 backend/internal/admin/downstreams.go create mode 100644 backend/internal/admin/exports.go create mode 100644 backend/internal/admin/filesystem.go create mode 100644 backend/internal/admin/groups.go create mode 100644 backend/internal/admin/imports.go create mode 100644 backend/internal/admin/operations.go create mode 100644 backend/internal/admin/radioreference.go create mode 100644 backend/internal/admin/settings.go rename backend/internal/{ws/admin_ops_settings_test.go => admin/settings_test.go} (60%) create mode 100644 backend/internal/admin/shared_links.go create mode 100644 backend/internal/admin/systems.go create mode 100644 backend/internal/admin/tags.go create mode 100644 backend/internal/admin/talkgroups.go create mode 100644 backend/internal/admin/transcription.go create mode 100644 backend/internal/admin/units.go create mode 100644 backend/internal/admin/users.go create mode 100644 backend/internal/admin/webhooks.go delete mode 100644 backend/internal/ws/admin_ops.go create mode 100644 backend/internal/ws/admin_router.go create mode 100644 backend/internal/ws/admin_router_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 843ee63..1d9abdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Admin CRUD business logic has been extracted from `internal/ws` into a + new transport-agnostic `internal/admin` package. The WebSocket layer + now only routes `ADM_REQ` frames to `admin.Operations` methods; the + wire protocol, action names, and response shapes are unchanged. - Deployment guide reverse-proxy instructions now list `/api/ws` alongside `/ws` and `/api/admin/ws` as paths that need WebSocket-upgrade forwarding. diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index e6277a9..bf7e7d7 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -36,6 +36,7 @@ import ( "github.com/gin-gonic/gin" "github.com/kardianos/service" + "github.com/openscanner/openscanner/internal/admin" "github.com/openscanner/openscanner/internal/api" "github.com/openscanner/openscanner/internal/audio" "github.com/openscanner/openscanner/internal/auth" @@ -1224,7 +1225,7 @@ func migrateSecrets(ctx context.Context, queries *db.Queries, sqlDB *sql.DB, enc // Check for encrypted values with no key configured. if encryptionKey == "" { for _, s := range settings { - if ws.SensitiveSettingKeys[s.Key] && auth.IsEncrypted(s.Value) { + if admin.SensitiveSettingKeys[s.Key] && auth.IsEncrypted(s.Value) { return fmt.Errorf("setting %q is encrypted but no encryption key is configured — set --encryption-key or OPENSCANNER_ENCRYPTION_KEY", s.Key) } } @@ -1250,7 +1251,7 @@ func migrateSecrets(ctx context.Context, queries *db.Queries, sqlDB *sql.DB, enc migrated := 0 for _, s := range settings { - if !ws.SensitiveSettingKeys[s.Key] { + if !admin.SensitiveSettingKeys[s.Key] { continue } if s.Value == "" { diff --git a/backend/internal/admin/api_keys.go b/backend/internal/admin/api_keys.go new file mode 100644 index 0000000..3f74c30 --- /dev/null +++ b/backend/internal/admin/api_keys.go @@ -0,0 +1,145 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/google/uuid" + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" +) + +// APIKeysList returns all API keys. +func (o *Operations) APIKeysList(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + keys, err := o.Queries.ListAPIKeys(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list API keys: %w", err) + } + return mapAPIKeys(keys), nil +} + +// APIKeysCreate creates a new API key. Returns the plaintext key once. +func (o *Operations) APIKeysCreate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + Key *string `json:"key"` + Ident *string `json:"ident"` + Disabled int64 `json:"disabled"` + SystemsJson *string `json:"systemsJson"` + CallRateLimit *int64 `json:"callRateLimit"` + Order int64 `json:"order"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + + plainKey := uuid.New().String() + if req.Key != nil && *req.Key != "" { + plainKey = *req.Key + } + hashedKey := auth.HashAPIKey(plainKey) + + id, err := o.Queries.CreateAPIKey(ctx, db.CreateAPIKeyParams{ + Key: hashedKey, + Ident: ptrToNullStr(req.Ident), + Disabled: req.Disabled, + SystemsJson: ptrToNullStr(req.SystemsJson), + CallRateLimit: ptrToNullInt(req.CallRateLimit), + Order: req.Order, + }) + if isUniqueViolation(err) { + return nil, UserError("API key already exists") + } + if err != nil { + return nil, fmt.Errorf("failed to create API key: %w", err) + } + + key, err := o.Queries.GetAPIKey(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to fetch created API key: %w", err) + } + slog.Info("admin: api key created", "id", key.ID, "ident", key.Ident.String, "by", callerID) + o.broadcastAdminEvent("apikeys.updated", nil) + + resp := mapAPIKey(key) + resp["createdKey"] = plainKey // Return plain key once on creation. + return resp, nil +} + +// APIKeysUpdate updates an existing API key. A blank key preserves the current one. +func (o *Operations) APIKeysUpdate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + Key *string `json:"key"` + Ident *string `json:"ident"` + Disabled int64 `json:"disabled"` + SystemsJson *string `json:"systemsJson"` + CallRateLimit *int64 `json:"callRateLimit"` + Order int64 `json:"order"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + + current, err := o.Queries.GetAPIKey(ctx, req.ID) + if err != nil { + return nil, UserError("API key not found") + } + + keyHash := current.Key + if req.Key != nil && *req.Key != "" { + keyHash = auth.HashAPIKey(*req.Key) + } + + err = o.Queries.UpdateAPIKey(ctx, db.UpdateAPIKeyParams{ + ID: req.ID, + Key: keyHash, + Ident: ptrToNullStr(req.Ident), + Disabled: req.Disabled, + SystemsJson: ptrToNullStr(req.SystemsJson), + CallRateLimit: ptrToNullInt(req.CallRateLimit), + Order: req.Order, + }) + if isUniqueViolation(err) { + return nil, UserError("API key already exists") + } + if err != nil { + return nil, fmt.Errorf("failed to update API key: %w", err) + } + + key, err := o.Queries.GetAPIKey(ctx, req.ID) + if err != nil { + return nil, fmt.Errorf("failed to fetch updated API key: %w", err) + } + slog.Info("admin: api key updated", "id", key.ID, "ident", key.Ident.String, "by", callerID) + o.broadcastAdminEvent("apikeys.updated", nil) + return mapAPIKey(key), nil +} + +// APIKeysDelete deletes an API key. +func (o *Operations) APIKeysDelete(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + + if _, err := o.Queries.GetAPIKey(ctx, req.ID); err != nil { + return nil, UserError("API key not found") + } + + if err := o.Queries.DeleteAPIKey(ctx, req.ID); err != nil { + return nil, fmt.Errorf("failed to delete API key: %w", err) + } + slog.Info("admin: api key deleted", "id", req.ID, "by", callerID) + o.broadcastAdminEvent("apikeys.updated", nil) + return map[string]bool{"ok": true}, nil +} diff --git a/backend/internal/admin/dirmonitors.go b/backend/internal/admin/dirmonitors.go new file mode 100644 index 0000000..2ef126b --- /dev/null +++ b/backend/internal/admin/dirmonitors.go @@ -0,0 +1,171 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + + "github.com/openscanner/openscanner/internal/db" +) + +// DirMonitorsList returns all dirmonitors. +func (o *Operations) DirMonitorsList(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + dms, err := o.Queries.ListDirMonitors(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list dirmonitors: %w", err) + } + return mapDirMonitors(dms), nil +} + +// DirMonitorsCreate creates a new dirmonitor and triggers a reload. +func (o *Operations) DirMonitorsCreate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + Directory string `json:"directory"` + Type string `json:"type"` + Mask *string `json:"mask"` + Extension *string `json:"extension"` + Frequency *int64 `json:"frequency"` + Delay *int64 `json:"delay"` + DeleteAfter int64 `json:"deleteAfter"` + UsePolling int64 `json:"usePolling"` + Disabled int64 `json:"disabled"` + SystemID *int64 `json:"systemId"` + TalkgroupID *int64 `json:"talkgroupId"` + Order int64 `json:"order"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.Directory == "" { + return nil, UserError("directory is required") + } + if info, statErr := os.Stat(req.Directory); statErr != nil { + return nil, UserError("directory does not exist or is not accessible: " + statErr.Error()) + } else if !info.IsDir() { + return nil, UserError("path is not a directory: " + req.Directory) + } + + id, err := o.Queries.CreateDirMonitor(ctx, db.CreateDirMonitorParams{ + Directory: req.Directory, + Type: req.Type, + Mask: ptrToNullStr(req.Mask), + Extension: ptrToNullStr(req.Extension), + Frequency: ptrToNullInt(req.Frequency), + Delay: ptrToNullInt(req.Delay), + DeleteAfter: req.DeleteAfter, + UsePolling: req.UsePolling, + Disabled: req.Disabled, + SystemID: ptrToNullInt(req.SystemID), + TalkgroupID: ptrToNullInt(req.TalkgroupID), + Order: req.Order, + }) + if err != nil { + return nil, fmt.Errorf("failed to create dirmonitor: %w", err) + } + + dm, err := o.Queries.GetDirMonitor(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to fetch created dirmonitor: %w", err) + } + if o.Deps.DirMonitorReload != nil { + o.Deps.DirMonitorReload.Reload() + } + slog.Info("admin: dirmonitor created", "id", dm.ID, "dir", dm.Directory, "by", callerID) + o.broadcastAdminEvent("dirmonitors.updated", nil) + return mapDirMonitor(dm), nil +} + +// DirMonitorsUpdate updates a dirmonitor and triggers a reload. +func (o *Operations) DirMonitorsUpdate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + Directory string `json:"directory"` + Type string `json:"type"` + Mask *string `json:"mask"` + Extension *string `json:"extension"` + Frequency *int64 `json:"frequency"` + Delay *int64 `json:"delay"` + DeleteAfter int64 `json:"deleteAfter"` + UsePolling int64 `json:"usePolling"` + Disabled int64 `json:"disabled"` + SystemID *int64 `json:"systemId"` + TalkgroupID *int64 `json:"talkgroupId"` + Order int64 `json:"order"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + if req.Directory == "" { + return nil, UserError("directory is required") + } + if info, statErr := os.Stat(req.Directory); statErr != nil { + return nil, UserError("directory does not exist or is not accessible: " + statErr.Error()) + } else if !info.IsDir() { + return nil, UserError("path is not a directory: " + req.Directory) + } + + if _, err := o.Queries.GetDirMonitor(ctx, req.ID); err != nil { + return nil, UserError("dirmonitor not found") + } + + if err := o.Queries.UpdateDirMonitor(ctx, db.UpdateDirMonitorParams{ + ID: req.ID, + Directory: req.Directory, + Type: req.Type, + Mask: ptrToNullStr(req.Mask), + Extension: ptrToNullStr(req.Extension), + Frequency: ptrToNullInt(req.Frequency), + Delay: ptrToNullInt(req.Delay), + DeleteAfter: req.DeleteAfter, + UsePolling: req.UsePolling, + Disabled: req.Disabled, + SystemID: ptrToNullInt(req.SystemID), + TalkgroupID: ptrToNullInt(req.TalkgroupID), + Order: req.Order, + }); err != nil { + return nil, fmt.Errorf("failed to update dirmonitor: %w", err) + } + + dm, err := o.Queries.GetDirMonitor(ctx, req.ID) + if err != nil { + return nil, fmt.Errorf("failed to fetch updated dirmonitor: %w", err) + } + if o.Deps.DirMonitorReload != nil { + o.Deps.DirMonitorReload.Reload() + } + slog.Info("admin: dirmonitor updated", "id", dm.ID, "dir", dm.Directory, "by", callerID) + o.broadcastAdminEvent("dirmonitors.updated", nil) + return mapDirMonitor(dm), nil +} + +// DirMonitorsDelete deletes a dirmonitor and triggers a reload. +func (o *Operations) DirMonitorsDelete(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + + if _, err := o.Queries.GetDirMonitor(ctx, req.ID); err != nil { + return nil, UserError("dirmonitor not found") + } + + if err := o.Queries.DeleteDirMonitor(ctx, req.ID); err != nil { + return nil, fmt.Errorf("failed to delete dirmonitor: %w", err) + } + if o.Deps.DirMonitorReload != nil { + o.Deps.DirMonitorReload.Reload() + } + slog.Info("admin: dirmonitor deleted", "id", req.ID, "by", callerID) + o.broadcastAdminEvent("dirmonitors.updated", nil) + return map[string]bool{"ok": true}, nil +} diff --git a/backend/internal/admin/downstreams.go b/backend/internal/admin/downstreams.go new file mode 100644 index 0000000..ae50e3b --- /dev/null +++ b/backend/internal/admin/downstreams.go @@ -0,0 +1,160 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" +) + +// DownstreamsList returns all downstreams. +func (o *Operations) DownstreamsList(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + ds, err := o.Queries.ListDownstreams(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list downstreams: %w", err) + } + return mapDownstreams(ds), nil +} + +// DownstreamsCreate creates a new downstream and triggers a reload. +func (o *Operations) DownstreamsCreate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + Url string `json:"url"` + ApiKey string `json:"apiKey"` + SystemsJson *string `json:"systemsJson"` + Disabled int64 `json:"disabled"` + Order int64 `json:"order"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.Url == "" { + return nil, UserError("url is required") + } + if !validHTTPURL(req.Url) { + return nil, UserError("url must use http or https scheme") + } + + apiKey := req.ApiKey + if o.Deps.EncryptionKey != "" && apiKey != "" { + enc, err := auth.EncryptString(apiKey, o.Deps.EncryptionKey) + if err != nil { + return nil, fmt.Errorf("encrypt downstream API key: %w", err) + } + apiKey = enc + } + + id, err := o.Queries.CreateDownstream(ctx, db.CreateDownstreamParams{ + Url: req.Url, + ApiKey: apiKey, + SystemsJson: ptrToNullStr(req.SystemsJson), + Disabled: req.Disabled, + Order: req.Order, + }) + if err != nil { + return nil, fmt.Errorf("failed to create downstream: %w", err) + } + + ds, err := o.Queries.GetDownstream(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to fetch created downstream: %w", err) + } + if o.Deps.DownstreamReload != nil { + o.Deps.DownstreamReload.Reload() + } + slog.Info("admin: downstream created", "id", ds.ID, "url", ds.Url, "by", callerID) + o.broadcastAdminEvent("downstreams.updated", nil) + return mapDownstream(ds), nil +} + +// DownstreamsUpdate updates a downstream. Blank apiKey preserves the current one. +func (o *Operations) DownstreamsUpdate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + Url string `json:"url"` + ApiKey string `json:"apiKey"` + SystemsJson *string `json:"systemsJson"` + Disabled int64 `json:"disabled"` + Order int64 `json:"order"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + if req.Url != "" && !validHTTPURL(req.Url) { + return nil, UserError("url must use http or https scheme") + } + + existing, err := o.Queries.GetDownstream(ctx, req.ID) + if err != nil { + return nil, UserError("downstream not found") + } + + // Preserve existing API key if none provided (key is never sent to clients). + apiKey := existing.ApiKey + if req.ApiKey != "" { + if o.Deps.EncryptionKey != "" { + enc, err := auth.EncryptString(req.ApiKey, o.Deps.EncryptionKey) + if err != nil { + return nil, fmt.Errorf("encrypt downstream API key: %w", err) + } + apiKey = enc + } else { + apiKey = req.ApiKey + } + } + + if err := o.Queries.UpdateDownstream(ctx, db.UpdateDownstreamParams{ + ID: req.ID, + Url: req.Url, + ApiKey: apiKey, + SystemsJson: ptrToNullStr(req.SystemsJson), + Disabled: req.Disabled, + Order: req.Order, + }); err != nil { + return nil, fmt.Errorf("failed to update downstream: %w", err) + } + + ds, err := o.Queries.GetDownstream(ctx, req.ID) + if err != nil { + return nil, fmt.Errorf("failed to fetch updated downstream: %w", err) + } + if o.Deps.DownstreamReload != nil { + o.Deps.DownstreamReload.Reload() + } + slog.Info("admin: downstream updated", "id", ds.ID, "url", ds.Url, "by", callerID) + o.broadcastAdminEvent("downstreams.updated", nil) + return mapDownstream(ds), nil +} + +// DownstreamsDelete deletes a downstream and triggers a reload. +func (o *Operations) DownstreamsDelete(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + + if _, err := o.Queries.GetDownstream(ctx, req.ID); err != nil { + return nil, UserError("downstream not found") + } + + if err := o.Queries.DeleteDownstream(ctx, req.ID); err != nil { + return nil, fmt.Errorf("failed to delete downstream: %w", err) + } + if o.Deps.DownstreamReload != nil { + o.Deps.DownstreamReload.Reload() + } + slog.Info("admin: downstream deleted", "id", req.ID, "by", callerID) + o.broadcastAdminEvent("downstreams.updated", nil) + return map[string]bool{"ok": true}, nil +} diff --git a/backend/internal/admin/exports.go b/backend/internal/admin/exports.go new file mode 100644 index 0000000..2ac8c3c --- /dev/null +++ b/backend/internal/admin/exports.go @@ -0,0 +1,241 @@ +package admin + +import ( + "context" + "encoding/csv" + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// ExportConfig returns the full config (settings, systems, talkgroups, …) +// shaped for import.go to round-trip. +func (o *Operations) ExportConfig(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + settings, err := o.Queries.ListSettings(ctx) + if err != nil { + return nil, fmt.Errorf("failed to export settings: %w", err) + } + users, err := o.Queries.ListUsers(ctx) + if err != nil { + return nil, fmt.Errorf("failed to export users: %w", err) + } + systems, err := o.Queries.ListSystems(ctx) + if err != nil { + return nil, fmt.Errorf("failed to export systems: %w", err) + } + talkgroups, err := o.Queries.ListAllTalkgroups(ctx) + if err != nil { + return nil, fmt.Errorf("failed to export talkgroups: %w", err) + } + units, err := o.Queries.ListAllUnits(ctx) + if err != nil { + return nil, fmt.Errorf("failed to export units: %w", err) + } + groups, err := o.Queries.ListGroups(ctx) + if err != nil { + return nil, fmt.Errorf("failed to export groups: %w", err) + } + tags, err := o.Queries.ListTags(ctx) + if err != nil { + return nil, fmt.Errorf("failed to export tags: %w", err) + } + apiKeys, err := o.Queries.ListAPIKeys(ctx) + if err != nil { + return nil, fmt.Errorf("failed to export api keys: %w", err) + } + dirmonitors, err := o.Queries.ListDirMonitors(ctx) + if err != nil { + return nil, fmt.Errorf("failed to export dirmonitors: %w", err) + } + downstreams, err := o.Queries.ListDownstreams(ctx) + if err != nil { + return nil, fmt.Errorf("failed to export downstreams: %w", err) + } + webhooks, err := o.Queries.ListWebhooks(ctx) + if err != nil { + return nil, fmt.Errorf("failed to export webhooks: %w", err) + } + + // Export all fields — use snake_case keys to match db struct JSON tags. + // API keys include the hashed key so import can restore authentication. + // Downstream API keys and webhook secrets are included for full backup. + // The exported JSON file should be treated as sensitive. + exportAPIKeys := make([]map[string]any, len(apiKeys)) + for i, k := range apiKeys { + exportAPIKeys[i] = map[string]any{ + "id": k.ID, + "key": k.Key, + "ident": nullStr(k.Ident), + "disabled": k.Disabled, + "systems_json": nullStr(k.SystemsJson), + "call_rate_limit": nullInt(k.CallRateLimit), + "order": k.Order, + } + } + exportDownstreams := make([]map[string]any, len(downstreams)) + for i, d := range downstreams { + exportDownstreams[i] = map[string]any{ + "id": d.ID, + "url": d.Url, + "api_key": d.ApiKey, + "systems_json": nullStr(d.SystemsJson), + "disabled": d.Disabled, + "order": d.Order, + } + } + exportWebhooks := make([]map[string]any, len(webhooks)) + for i, w := range webhooks { + exportWebhooks[i] = map[string]any{ + "id": w.ID, + "url": w.Url, + "type": w.Type, + "secret": nullStr(w.Secret), + "systems_json": nullStr(w.SystemsJson), + "disabled": w.Disabled, + "order": w.Order, + } + } + + return map[string]any{ + "settings": settings, + "users": users, + "systems": systems, + "talkgroups": talkgroups, + "units": units, + "groups": groups, + "tags": tags, + "apiKeys": exportAPIKeys, + "dirmonitors": dirmonitors, + "downstreams": exportDownstreams, + "webhooks": exportWebhooks, + }, nil +} + +// ExportTalkgroups returns a CSV export of talkgroups for a given system. +func (o *Operations) ExportTalkgroups(ctx context.Context, params json.RawMessage, _ int64) (any, error) { + var req struct { + SystemID *int64 `json:"systemId"` + } + if params != nil { + _ = json.Unmarshal(params, &req) + } + if req.SystemID == nil { + return nil, fmt.Errorf("systemId is required") + } + + talkgroups, err := o.Queries.ListTalkgroupsBySystem(ctx, *req.SystemID) + if err != nil { + return nil, fmt.Errorf("failed to list talkgroups: %w", err) + } + + // Build ID→label maps so we can emit portable text names instead of + // PK integers (PKs are not stable across instances). + groupMap := make(map[int64]string) + if gs, err := o.Queries.ListGroups(ctx); err == nil { + for _, g := range gs { + groupMap[g.ID] = g.Label + } + } + tagMap := make(map[int64]string) + if ts, err := o.Queries.ListTags(ctx); err == nil { + for _, t := range ts { + tagMap[t.ID] = t.Label + } + } + + var buf strings.Builder + w := csv.NewWriter(&buf) + _ = w.Write([]string{"talkgroup_id", "label", "name", "tag", "group", "frequency", "led", "order"}) + for _, tg := range talkgroups { + freq := "" + if tg.Frequency.Valid { + freq = strconv.FormatInt(tg.Frequency.Int64, 10) + } + groupLabel := "" + if tg.GroupID.Valid { + groupLabel = groupMap[tg.GroupID.Int64] + } + tagLabel := "" + if tg.TagID.Valid { + tagLabel = tagMap[tg.TagID.Int64] + } + _ = w.Write([]string{ + strconv.FormatInt(tg.TalkgroupID, 10), + tg.Label.String, + tg.Name.String, + tagLabel, + groupLabel, + freq, + tg.Led.String, + strconv.FormatInt(tg.Order, 10), + }) + } + w.Flush() + + return buf.String(), nil +} + +// ExportUnits returns a CSV export of units for a given system. +func (o *Operations) ExportUnits(ctx context.Context, params json.RawMessage, _ int64) (any, error) { + var req struct { + SystemID *int64 `json:"systemId"` + } + if params != nil { + _ = json.Unmarshal(params, &req) + } + if req.SystemID == nil { + return nil, fmt.Errorf("systemId is required") + } + + units, err := o.Queries.ListUnitsBySystem(ctx, *req.SystemID) + if err != nil { + return nil, fmt.Errorf("failed to list units: %w", err) + } + + var buf strings.Builder + w := csv.NewWriter(&buf) + _ = w.Write([]string{"unit_id", "label", "order"}) + for _, u := range units { + _ = w.Write([]string{ + strconv.FormatInt(u.UnitID, 10), + u.Label.String, + strconv.FormatInt(u.Order, 10), + }) + } + w.Flush() + + return buf.String(), nil +} + +// ExportGroups returns a CSV export of groups. +func (o *Operations) ExportGroups(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + groups, err := o.Queries.ListGroups(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list groups: %w", err) + } + var buf strings.Builder + w := csv.NewWriter(&buf) + _ = w.Write([]string{"label"}) + for _, g := range groups { + _ = w.Write([]string{g.Label}) + } + w.Flush() + return buf.String(), nil +} + +// ExportTags returns a CSV export of tags. +func (o *Operations) ExportTags(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + tags, err := o.Queries.ListTags(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list tags: %w", err) + } + var buf strings.Builder + w := csv.NewWriter(&buf) + _ = w.Write([]string{"label"}) + for _, t := range tags { + _ = w.Write([]string{t.Label}) + } + w.Flush() + return buf.String(), nil +} diff --git a/backend/internal/admin/filesystem.go b/backend/internal/admin/filesystem.go new file mode 100644 index 0000000..c2599c0 --- /dev/null +++ b/backend/internal/admin/filesystem.go @@ -0,0 +1,78 @@ +package admin + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" +) + +// FSDirectories lists directories under the given path for the dirmonitor +// picker UI. Path must be absolute; hidden (dotfile) and top-level system +// directories are filtered out. +func (o *Operations) FSDirectories(_ context.Context, params json.RawMessage, _ int64) (any, error) { + var req struct { + Path string `json:"path"` + } + if params != nil { + _ = json.Unmarshal(params, &req) + } + if req.Path == "" { + req.Path = "/" + } + + clean := filepath.Clean(req.Path) + if !filepath.IsAbs(clean) { + return nil, UserError("path must be absolute") + } + + info, err := os.Stat(clean) + if err != nil { + return nil, UserError("directory does not exist or is not accessible: " + err.Error()) + } + if !info.IsDir() { + return nil, UserError("path is not a directory: " + clean) + } + + entries, err := os.ReadDir(clean) + if err != nil { + return nil, UserError("failed to read directory: " + err.Error()) + } + + type dirEntry struct { + Name string `json:"name"` + Path string `json:"path"` + } + + dirs := make([]dirEntry, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if clean == "/" && hiddenTopLevelDirs[name] { + continue + } + if strings.HasPrefix(name, ".") { + continue + } + dirs = append(dirs, dirEntry{Name: name, Path: filepath.Join(clean, name)}) + } + sort.Slice(dirs, func(i, j int) bool { + return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) + }) + + var parent *string + if clean != "/" { + p := filepath.Dir(clean) + parent = &p + } + + return map[string]any{ + "path": clean, + "parent": parent, + "directories": dirs, + }, nil +} diff --git a/backend/internal/admin/groups.go b/backend/internal/admin/groups.go new file mode 100644 index 0000000..0527012 --- /dev/null +++ b/backend/internal/admin/groups.go @@ -0,0 +1,112 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/openscanner/openscanner/internal/db" +) + +// GroupsList returns all groups. +func (o *Operations) GroupsList(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + groups, err := o.Queries.ListGroups(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list groups: %w", err) + } + return groups, nil +} + +// GroupsCreate creates a new group. +func (o *Operations) GroupsCreate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + Label string `json:"label"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.Label == "" { + return nil, UserError("label is required") + } + + id, err := o.Queries.CreateGroup(ctx, req.Label) + if isUniqueViolation(err) { + return nil, UserError("group label already exists") + } + if err != nil { + return nil, fmt.Errorf("failed to create group: %w", err) + } + + group, err := o.Queries.GetGroup(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to fetch created group: %w", err) + } + slog.Info("admin: group created", "id", group.ID, "label", group.Label, "by", callerID) + o.broadcastAdminEvent("groups.updated", nil) + o.broadcastCFG(ctx) + return group, nil +} + +// GroupsUpdate updates an existing group. +func (o *Operations) GroupsUpdate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + Label string `json:"label"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + if req.Label == "" { + return nil, UserError("label is required") + } + + if _, err := o.Queries.GetGroup(ctx, req.ID); err != nil { + return nil, UserError("group not found") + } + + err := o.Queries.UpdateGroup(ctx, db.UpdateGroupParams{ID: req.ID, Label: req.Label}) + if isUniqueViolation(err) { + return nil, UserError("group label already exists") + } + if err != nil { + return nil, fmt.Errorf("failed to update group: %w", err) + } + + group, err := o.Queries.GetGroup(ctx, req.ID) + if err != nil { + return nil, fmt.Errorf("failed to fetch updated group: %w", err) + } + slog.Info("admin: group updated", "id", group.ID, "label", group.Label, "by", callerID) + o.broadcastAdminEvent("groups.updated", nil) + o.broadcastCFG(ctx) + return group, nil +} + +// GroupsDelete deletes a group. +func (o *Operations) GroupsDelete(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + + if _, err := o.Queries.GetGroup(ctx, req.ID); err != nil { + return nil, UserError("group not found") + } + + if err := o.Queries.DeleteGroup(ctx, req.ID); err != nil { + return nil, fmt.Errorf("failed to delete group: %w", err) + } + slog.Info("admin: group deleted", "id", req.ID, "by", callerID) + o.broadcastAdminEvent("groups.updated", nil) + o.broadcastCFG(ctx) + return map[string]bool{"ok": true}, nil +} diff --git a/backend/internal/admin/imports.go b/backend/internal/admin/imports.go new file mode 100644 index 0000000..30dcaa5 --- /dev/null +++ b/backend/internal/admin/imports.go @@ -0,0 +1,396 @@ +package admin + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log/slog" + + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" +) + +// importAPIKey, importDownstream, and importWebhook mirror the flat shape +// emitted by ExportConfig (plain string/null instead of {String,Valid} +// blobs). Unmarshalling directly into the db.* structs would fail for any +// non-null nullable field because sql.NullString has no JSON unmarshaler. +type importAPIKey struct { + Key string `json:"key"` + Ident *string `json:"ident"` + Disabled int64 `json:"disabled"` + SystemsJson *string `json:"systems_json"` + CallRateLimit *int64 `json:"call_rate_limit"` + Order int64 `json:"order"` +} + +type importDownstream struct { + Url string `json:"url"` + ApiKey string `json:"api_key"` + SystemsJson *string `json:"systems_json"` + Disabled int64 `json:"disabled"` + Order int64 `json:"order"` +} + +type importWebhook struct { + Url string `json:"url"` + Type string `json:"type"` + Secret *string `json:"secret"` + SystemsJson *string `json:"systems_json"` + Disabled int64 `json:"disabled"` + Order int64 `json:"order"` +} + +// ImportConfig applies a full config backup atomically, remapping foreign +// keys between the source and destination databases. +func (o *Operations) ImportConfig(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var data struct { + Settings []db.Setting `json:"settings"` + Groups []db.Group `json:"groups"` + Tags []db.Tag `json:"tags"` + Systems []db.System `json:"systems"` + Talkgroups []db.Talkgroup `json:"talkgroups"` + Units []db.Unit `json:"units"` + APIKeys []importAPIKey `json:"apiKeys"` + DirMonitors []db.Dirmonitor `json:"dirmonitors"` + Downstreams []importDownstream `json:"downstreams"` + Webhooks []importWebhook `json:"webhooks"` + } + if err := json.Unmarshal(params, &data); err != nil { + slog.Warn("import config: failed to parse payload", "error", err) + return nil, UserError("invalid backup file: " + err.Error()) + } + + // Validate encrypted values: reject if no key configured, or if the wrong key is configured. + encKey := o.Deps.EncryptionKey + for _, s := range data.Settings { + if SensitiveSettingKeys[s.Key] && auth.IsEncrypted(s.Value) { + if encKey == "" { + return nil, UserError("backup contains encrypted settings but no encryption key is configured — set --encryption-key before importing") + } + if _, err := auth.DecryptString(s.Value, encKey); err != nil { + return nil, UserError("backup contains encrypted settings that cannot be decrypted with the current encryption key — check that --encryption-key matches the key used when the backup was created") + } + } + } + for _, d := range data.Downstreams { + if auth.IsEncrypted(d.ApiKey) { + if encKey == "" { + return nil, UserError("backup contains encrypted downstream API keys but no encryption key is configured — set --encryption-key before importing") + } + if _, err := auth.DecryptString(d.ApiKey, encKey); err != nil { + return nil, UserError("backup contains encrypted downstream API keys that cannot be decrypted with the current encryption key — check that --encryption-key matches the key used when the backup was created") + } + } + } + + sqlDB := o.Deps.SQLDB + if sqlDB == nil { + return nil, fmt.Errorf("transaction support not available") + } + + tx, err := sqlDB.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("database error: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + qtx := o.Queries.WithTx(tx) + + // Settings + for _, s := range data.Settings { + if !allowedSettingKeys[s.Key] { + slog.Warn("import config: skipping unknown setting key", "key", s.Key) + continue + } + if err := qtx.UpsertSetting(ctx, db.UpsertSettingParams(s)); err != nil { + return nil, fmt.Errorf("failed to import settings: %w", err) + } + } + + // Groups — capture old→new id remap so talkgroups can rewrite their + // group_id FKs (the export carries the source DB's PKs, but on a fresh + // install those PKs don't exist yet). + groupRemap := make(map[int64]int64, len(data.Groups)) + for _, g := range data.Groups { + newID, err := qtx.CreateGroup(ctx, g.Label) + if err != nil { + if !isUniqueViolation(err) { + return nil, fmt.Errorf("failed to import groups: %w", err) + } + existing, gerr := qtx.GetGroupByLabel(ctx, g.Label) + if gerr != nil { + return nil, fmt.Errorf("failed to look up existing group %q: %w", g.Label, gerr) + } + newID = existing.ID + } + groupRemap[g.ID] = newID + } + + // Tags — same remap pattern as groups. + tagRemap := make(map[int64]int64, len(data.Tags)) + for _, t := range data.Tags { + newID, err := qtx.CreateTag(ctx, t.Label) + if err != nil { + if !isUniqueViolation(err) { + return nil, fmt.Errorf("failed to import tags: %w", err) + } + existing, gerr := qtx.GetTagByLabel(ctx, t.Label) + if gerr != nil { + return nil, fmt.Errorf("failed to look up existing tag %q: %w", t.Label, gerr) + } + newID = existing.ID + } + tagRemap[t.ID] = newID + } + + // Systems — remap by old PK → new PK. The natural key is SystemID + // (the radio-system ID, e.g. 1, 100), which sqlc enforces UNIQUE. + systemRemap := make(map[int64]int64, len(data.Systems)) + for _, s := range data.Systems { + newID, err := qtx.CreateSystem(ctx, db.CreateSystemParams{ + SystemID: s.SystemID, + Label: s.Label, + AutoPopulateTalkgroups: s.AutoPopulateTalkgroups, + BlacklistsJson: s.BlacklistsJson, + Led: s.Led, + Order: s.Order, + }) + if err != nil { + if !isUniqueViolation(err) { + return nil, fmt.Errorf("failed to import systems: %w", err) + } + existing, gerr := qtx.GetSystemBySystemID(ctx, s.SystemID) + if gerr != nil { + return nil, fmt.Errorf("failed to look up existing system %d: %w", s.SystemID, gerr) + } + newID = existing.ID + } + systemRemap[s.ID] = newID + } + + // Talkgroups — translate FKs (system_id, group_id, tag_id) through the + // remaps built above, then upsert. Capture the new PK so dirmonitors + // can rewrite their talkgroup_id FKs. + tgRemap := make(map[int64]int64, len(data.Talkgroups)) + for _, tg := range data.Talkgroups { + newSystemID, ok := systemRemap[tg.SystemID] + if !ok { + slog.Warn("import config: skipping talkgroup with unknown system_id", + "talkgroup_id", tg.TalkgroupID, "system_id", tg.SystemID) + continue + } + groupID := tg.GroupID + if groupID.Valid { + if mapped, ok := groupRemap[groupID.Int64]; ok { + groupID.Int64 = mapped + } else { + // Group wasn't in the export — drop the FK rather than fail. + groupID = sql.NullInt64{} + } + } + tagID := tg.TagID + if tagID.Valid { + if mapped, ok := tagRemap[tagID.Int64]; ok { + tagID.Int64 = mapped + } else { + tagID = sql.NullInt64{} + } + } + if err := qtx.UpsertTalkgroup(ctx, db.UpsertTalkgroupParams{ + SystemID: newSystemID, + TalkgroupID: tg.TalkgroupID, + Label: tg.Label, + Name: tg.Name, + Frequency: tg.Frequency, + Led: tg.Led, + GroupID: groupID, + TagID: tagID, + Order: tg.Order, + }); err != nil { + return nil, fmt.Errorf("failed to import talkgroups: %w", err) + } + row, err := qtx.GetTalkgroupBySystemAndTGID(ctx, db.GetTalkgroupBySystemAndTGIDParams{ + SystemID: newSystemID, + TalkgroupID: tg.TalkgroupID, + }) + if err != nil { + return nil, fmt.Errorf("failed to look up imported talkgroup (system=%d tg=%d): %w", + newSystemID, tg.TalkgroupID, err) + } + tgRemap[tg.ID] = row.ID + } + + // Units — translate system_id. + for _, u := range data.Units { + newSystemID, ok := systemRemap[u.SystemID] + if !ok { + slog.Warn("import config: skipping unit with unknown system_id", + "unit_id", u.UnitID, "system_id", u.SystemID) + continue + } + if err := qtx.UpsertUnit(ctx, db.UpsertUnitParams{ + SystemID: newSystemID, + UnitID: u.UnitID, + Label: u.Label, + Order: u.Order, + }); err != nil { + return nil, fmt.Errorf("failed to import units: %w", err) + } + } + + // API Keys — remap any system PKs embedded in systems_json. + for _, k := range data.APIKeys { + if _, err := qtx.CreateAPIKey(ctx, db.CreateAPIKeyParams{ + Key: k.Key, + Ident: ptrToNullStr(k.Ident), + Disabled: k.Disabled, + SystemsJson: ptrToNullStr(remapSystemsJSON(k.SystemsJson, systemRemap)), + CallRateLimit: ptrToNullInt(k.CallRateLimit), + Order: k.Order, + }); err != nil && !isUniqueViolation(err) { + return nil, fmt.Errorf("failed to import api keys: %w", err) + } + } + + // DirMonitors — translate system_id and talkgroup_id FKs. + for _, d := range data.DirMonitors { + sysID := d.SystemID + if sysID.Valid { + if mapped, ok := systemRemap[sysID.Int64]; ok { + sysID.Int64 = mapped + } else { + slog.Warn("import config: dirmonitor system_id not found in import; dropping FK", + "directory", d.Directory, "system_id", sysID.Int64) + sysID = sql.NullInt64{} + } + } + tgID := d.TalkgroupID + if tgID.Valid { + if mapped, ok := tgRemap[tgID.Int64]; ok { + tgID.Int64 = mapped + } else { + slog.Warn("import config: dirmonitor talkgroup_id not found in import; dropping FK", + "directory", d.Directory, "talkgroup_id", tgID.Int64) + tgID = sql.NullInt64{} + } + } + if _, err := qtx.CreateDirMonitor(ctx, db.CreateDirMonitorParams{ + Directory: d.Directory, + Type: d.Type, + Mask: d.Mask, + Extension: d.Extension, + Frequency: d.Frequency, + Delay: d.Delay, + DeleteAfter: d.DeleteAfter, + UsePolling: d.UsePolling, + Disabled: d.Disabled, + SystemID: sysID, + TalkgroupID: tgID, + Order: d.Order, + }); err != nil && !isUniqueViolation(err) { + return nil, fmt.Errorf("failed to import dirmonitors: %w", err) + } + } + + // Downstreams — remap embedded system PKs. + for _, d := range data.Downstreams { + if !validHTTPURL(d.Url) { + slog.Warn("import config: skipping downstream with invalid URL", "url", d.Url) + continue + } + if _, err := qtx.CreateDownstream(ctx, db.CreateDownstreamParams{ + Url: d.Url, + ApiKey: d.ApiKey, + SystemsJson: ptrToNullStr(remapSystemsJSON(d.SystemsJson, systemRemap)), + Disabled: d.Disabled, + Order: d.Order, + }); err != nil && !isUniqueViolation(err) { + return nil, fmt.Errorf("failed to import downstreams: %w", err) + } + } + + // Webhooks — remap embedded system PKs. + for _, w := range data.Webhooks { + if !validHTTPURL(w.Url) { + slog.Warn("import config: skipping webhook with invalid URL", "url", w.Url) + continue + } + if _, err := qtx.CreateWebhook(ctx, db.CreateWebhookParams{ + Url: w.Url, + Type: w.Type, + Secret: ptrToNullStr(w.Secret), + SystemsJson: ptrToNullStr(remapSystemsJSON(w.SystemsJson, systemRemap)), + Disabled: w.Disabled, + Order: w.Order, + }); err != nil && !isUniqueViolation(err) { + return nil, fmt.Errorf("failed to import webhooks: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("failed to commit import: %w", err) + } + + // Hot-reload subsystems whose live state derives from the rows or + // settings we just rewrote. Without these, the in-process worker + // pools, downstream forwarders, and dirmonitor watchers keep using + // their pre-import config — symptom: transcription stops, downstream + // forwarding goes silent, dirmonitors don't pick up new directories + // until the operator restarts the server. + if o.Deps.TranscriberReload != nil && len(data.Settings) > 0 { + tEnabled, _ := o.Queries.GetSetting(ctx, "transcriptionEnabled") + tURL, _ := o.Queries.GetSetting(ctx, "transcriptionUrl") + tModel, _ := o.Queries.GetSetting(ctx, "transcriptionModel") + tLang, _ := o.Queries.GetSetting(ctx, "transcriptionLanguage") + tDiarize, _ := o.Queries.GetSetting(ctx, "transcriptionDiarize") + + ok := o.Deps.TranscriberReload.Reload( + tEnabled.Value == "true", + tURL.Value, + tModel.Value, + tLang.Value, + tDiarize.Value == "true", + ) + o.Deps.WhisperAvailable = ok && tEnabled.Value == "true" + } + if o.Deps.DirMonitorReload != nil && len(data.DirMonitors) > 0 { + o.Deps.DirMonitorReload.Reload() + } + if o.Deps.DownstreamReload != nil && len(data.Downstreams) > 0 { + o.Deps.DownstreamReload.Reload() + } + + // Notify all admin/listener clients to refetch — without these the + // admin UI shows stale (empty) lists and the user thinks the import + // silently failed. Order doesn't matter; events are fire-and-forget. + for _, topic := range []string{ + "groups.updated", + "tags.updated", + "systems.updated", + "talkgroups.updated", + "units.updated", + "apikeys.updated", + "dirmonitors.updated", + "downstreams.updated", + "webhooks.updated", + } { + o.broadcastAdminEvent(topic, nil) + } + o.broadcastCFG(ctx) + + slog.Info("config imported successfully via WS", + "by", callerID, + "settings", len(data.Settings), + "groups", len(data.Groups), + "tags", len(data.Tags), + "systems", len(data.Systems), + "talkgroups", len(data.Talkgroups), + "units", len(data.Units), + "apiKeys", len(data.APIKeys), + "dirmonitors", len(data.DirMonitors), + "downstreams", len(data.Downstreams), + "webhooks", len(data.Webhooks), + ) + return map[string]bool{"ok": true}, nil +} diff --git a/backend/internal/admin/operations.go b/backend/internal/admin/operations.go new file mode 100644 index 0000000..8612e47 --- /dev/null +++ b/backend/internal/admin/operations.go @@ -0,0 +1,466 @@ +// Package admin holds the transport-agnostic CRUD / config / import-export +// business logic for OpenScanner's admin surface. +// +// Every method on Operations takes (ctx, params, callerID) and returns +// (any, error); callers (currently internal/ws) are responsible for +// transport framing, authentication, authorization, and error envelope. +// This package MUST NOT import internal/ws or net/http-transport packages. +package admin + +import ( + "context" + "database/sql" + "encoding/json" + "log/slog" + "net/url" + "strings" + "time" + + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" +) + +// ── Public helper types ── + +// UserError is returned by Operations methods for validation errors that +// should be shown verbatim to the client. Callers can use errors.As to +// distinguish these from internal errors. +type UserError string + +func (e UserError) Error() string { return string(e) } + +// Reloader triggers a service config reload (dirmonitor, downstream). +type Reloader interface { + Reload() +} + +// TranscriberReloader can hot-reload the transcription subsystem. +type TranscriberReloader interface { + Reload(enabled bool, baseURL, model, language string, diarize bool) bool + Enabled() bool + BaseURL() string + QueueDepth() int +} + +// EventSink is the interface Operations uses to push admin events and +// broadcast config refreshes without importing the WebSocket package. The +// WS hub implements all three methods today, so the interface is satisfied +// automatically; tests can provide a no-op implementation. +type EventSink interface { + BroadcastAdminEvent(topic string, data any) + BroadcastCFG(ctx context.Context) + DisconnectByUser(userID int64) + ClientCount() int +} + +// Deps are the optional dependencies used by admin operations. Any field +// left zero disables the corresponding feature path at runtime (matches +// the prior ws.HubDeps behaviour exactly). +type Deps struct { + SQLDB *sql.DB + DirMonitorReload Reloader + DownstreamReload Reloader + TranscriberReload TranscriberReloader + FFmpegAvailable bool + FDKAACAvailable bool + WhisperAvailable bool + RecordingsDir string + EncryptionKey string +} + +// Operations owns the admin CRUD business logic. It is transport-agnostic — +// callers wrap its methods in whatever RPC / WS / HTTP envelope they use. +type Operations struct { + Queries *db.Queries + Deps Deps + Events EventSink + + // StartTime is used by activity-stats and uptime calculations. It + // defaults to time.Now() on New() but can be overridden for tests. + StartTime time.Time +} + +// New constructs a new Operations bound to the given queries, deps, and +// event sink. The event sink may be nil for test fixtures that don't +// exercise broadcast-triggering paths. +func New(queries *db.Queries, deps Deps, events EventSink) *Operations { + return &Operations{ + Queries: queries, + Deps: deps, + Events: events, + StartTime: time.Now(), + } +} + +// SetWhisperAvailable updates the cached ffmpeg/whisper capability after a +// transcription hot-reload. Kept for the config-update and import flows +// that mutate the live pool state. +func (o *Operations) SetWhisperAvailable(v bool) { o.Deps.WhisperAvailable = v } + +// broadcastAdminEvent is a nil-safe wrapper around Events.BroadcastAdminEvent. +func (o *Operations) broadcastAdminEvent(topic string, data any) { + if o.Events != nil { + o.Events.BroadcastAdminEvent(topic, data) + } +} + +// broadcastCFG is a nil-safe wrapper around Events.BroadcastCFG. +func (o *Operations) broadcastCFG(ctx context.Context) { + if o.Events != nil { + o.Events.BroadcastCFG(ctx) + } +} + +// disconnectByUser is a nil-safe wrapper around Events.DisconnectByUser. +func (o *Operations) disconnectByUser(userID int64) { + if o.Events != nil { + o.Events.DisconnectByUser(userID) + } +} + +// ── Helpers ── + +func ptrToNullStr(p *string) sql.NullString { + if p == nil { + return sql.NullString{} + } + return sql.NullString{String: *p, Valid: true} +} + +func ptrToNullInt(p *int64) sql.NullInt64 { + if p == nil { + return sql.NullInt64{} + } + return sql.NullInt64{Int64: *p, Valid: true} +} + +func nullStr(n sql.NullString) *string { + if !n.Valid { + return nil + } + return &n.String +} + +func nullInt(n sql.NullInt64) *int64 { + if !n.Valid { + return nil + } + return &n.Int64 +} + +func isUniqueViolation(err error) bool { + return err != nil && strings.Contains(err.Error(), "UNIQUE") +} + +func validHTTPURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil { + return false + } + return u.Scheme == "http" || u.Scheme == "https" +} + +// remapSystemsJSON rewrites the system PKs embedded in a systems_json column +// (used by api_keys, downstreams, webhooks, and users) so that grants +// referring to a system by its old PK end up referring to the freshly +// inserted row's PK after import. Accepts and returns a *string mirroring +// the export shape (nil = "all systems"). Any system PK that doesn't appear +// in the remap is dropped from the grant rather than silently broken. +// +// Shape: `[{"id": , "talkgroups": [...]}]` per +// auth.SystemGrant. +func remapSystemsJSON(in *string, systemRemap map[int64]int64) *string { + if in == nil || strings.TrimSpace(*in) == "" { + return in + } + var grants []auth.SystemGrant + if err := json.Unmarshal([]byte(*in), &grants); err != nil { + // Fall through with nil grants — try the legacy flat-id form. + var ids []int64 + if jerr := json.Unmarshal([]byte(*in), &ids); jerr != nil { + slog.Warn("import config: systems_json not recognised; preserving as-is", + "error", err) + return in + } + mapped := make([]int64, 0, len(ids)) + for _, id := range ids { + if newID, ok := systemRemap[id]; ok { + mapped = append(mapped, newID) + } else { + slog.Warn("import config: dropping unknown system grant", "system_pk", id) + } + } + out, _ := json.Marshal(mapped) + s := string(out) + return &s + } + mapped := make([]auth.SystemGrant, 0, len(grants)) + for _, g := range grants { + newID, ok := systemRemap[g.ID] + if !ok { + slog.Warn("import config: dropping unknown system grant", "system_pk", g.ID) + continue + } + mapped = append(mapped, auth.SystemGrant{ID: newID, Talkgroups: g.Talkgroups}) + } + out, _ := json.Marshal(mapped) + s := string(out) + return &s +} + +// validRoles is the set of allowed user roles. +var validRoles = map[string]bool{ + auth.RoleAdmin: true, + auth.RoleListener: true, +} + +// SensitiveSettingKeys are settings whose values are encrypted at rest. +// Exported because cmd/server/main.go consults it during secret migration. +var SensitiveSettingKeys = map[string]bool{ + "vapidPrivateKey": true, + "jwtSecret": true, +} + +// allowedSettingKeys mirrors the allowed setting keys from config.go. +var allowedSettingKeys = map[string]bool{ + "activityDashboard": true, + "afsSystems": true, + "apiKeyCallRate": true, + "audioConversion": true, + "audioEncodingPreset": true, + "autoPopulateSystems": true, + "branding": true, + "disableDuplicateDetection": true, + "duplicateDetectionTimeFrame": true, + "email": true, + "keypadBeeps": true, + "logLevel": true, + "maxClients": true, + "playbackGoesLive": true, + "pruneDays": true, + "publicAccess": true, + "pushNotifications": true, + "searchPatchedTalkgroups": true, + "shareableLinks": true, + "sharedLinkExpiry": true, + "showListenersCount": true, + "sortTalkgroups": true, + "tagsToggle": true, + "time12hFormat": true, + "transcriptionDiarize": true, + "transcriptionEnabled": true, + "transcriptionLanguage": true, + "liveTranscriptDisplay": true, + "transcriptionModel": true, + "transcriptionUrl": true, + "vapidPrivateKey": true, + "vapidPublicKey": true, + "webhooksEnabled": true, +} + +// AllowedSettingKeys reports whether a settings key is allowed to be mutated +// via the admin API. Exposed for tests. +func AllowedSettingKeys(key string) bool { return allowedSettingKeys[key] } + +// hiddenTopLevelDirs for FS browsing. +var hiddenTopLevelDirs = map[string]bool{ + "bin": true, "boot": true, "dev": true, "lib": true, + "lib32": true, "lib64": true, "libx32": true, + "proc": true, "run": true, "sbin": true, "sys": true, + "usr": true, "etc": true, "snap": true, "lost+found": true, +} + +// ── Response mappers (exported for tests / transport layers) ── + +func mapUser(u db.User) map[string]any { + return map[string]any{ + "id": u.ID, + "username": u.Username, + "role": u.Role, + "disabled": u.Disabled, + "systemsJson": nullStr(u.SystemsJson), + "expiration": nullInt(u.Expiration), + "limit": nullInt(u.Limit), + "createdAt": u.CreatedAt, + "updatedAt": u.UpdatedAt, + } +} + +func mapUsers(users []db.User) []map[string]any { + out := make([]map[string]any, len(users)) + for i, u := range users { + out[i] = mapUser(u) + } + return out +} + +func mapSystem(s db.System) map[string]any { + return map[string]any{ + "id": s.ID, + "systemId": s.SystemID, + "label": s.Label, + "autoPopulateTalkgroups": s.AutoPopulateTalkgroups, + "blacklistsJson": nullStr(s.BlacklistsJson), + "led": nullStr(s.Led), + "order": s.Order, + } +} + +func mapSystems(systems []db.System) []map[string]any { + out := make([]map[string]any, len(systems)) + for i, s := range systems { + out[i] = mapSystem(s) + } + return out +} + +func mapTalkgroup(t db.Talkgroup) map[string]any { + return map[string]any{ + "id": t.ID, + "systemId": t.SystemID, + "talkgroupId": t.TalkgroupID, + "label": nullStr(t.Label), + "name": nullStr(t.Name), + "frequency": nullInt(t.Frequency), + "led": nullStr(t.Led), + "groupId": nullInt(t.GroupID), + "tagId": nullInt(t.TagID), + "order": t.Order, + } +} + +func mapTalkgroups(tgs []db.Talkgroup) []map[string]any { + out := make([]map[string]any, len(tgs)) + for i, t := range tgs { + out[i] = mapTalkgroup(t) + } + return out +} + +func mapUnit(u db.Unit) map[string]any { + return map[string]any{ + "id": u.ID, + "systemId": u.SystemID, + "unitId": u.UnitID, + "label": nullStr(u.Label), + "order": u.Order, + } +} + +func mapUnits(units []db.Unit) []map[string]any { + out := make([]map[string]any, len(units)) + for i, u := range units { + out[i] = mapUnit(u) + } + return out +} + +func mapAPIKey(k db.ApiKey) map[string]any { + fingerprint := auth.HashAPIKey(k.Key) + if len(fingerprint) > 12 { + fingerprint = fingerprint[:12] + } + return map[string]any{ + "id": k.ID, + "fingerprint": fingerprint, + "ident": nullStr(k.Ident), + "disabled": k.Disabled, + "systemsJson": nullStr(k.SystemsJson), + "callRateLimit": nullInt(k.CallRateLimit), + "order": k.Order, + } +} + +func mapAPIKeys(keys []db.ApiKey) []map[string]any { + out := make([]map[string]any, len(keys)) + for i, k := range keys { + out[i] = mapAPIKey(k) + } + return out +} + +func mapDirMonitor(d db.Dirmonitor) map[string]any { + return map[string]any{ + "id": d.ID, + "directory": d.Directory, + "type": d.Type, + "mask": nullStr(d.Mask), + "extension": nullStr(d.Extension), + "frequency": nullInt(d.Frequency), + "delay": nullInt(d.Delay), + "deleteAfter": d.DeleteAfter, + "usePolling": d.UsePolling, + "disabled": d.Disabled, + "systemId": nullInt(d.SystemID), + "talkgroupId": nullInt(d.TalkgroupID), + "order": d.Order, + } +} + +func mapDirMonitors(dms []db.Dirmonitor) []map[string]any { + out := make([]map[string]any, len(dms)) + for i, d := range dms { + out[i] = mapDirMonitor(d) + } + return out +} + +func mapDownstream(d db.Downstream) map[string]any { + return map[string]any{ + "id": d.ID, + "url": d.Url, + "hasApiKey": d.ApiKey != "", + "systemsJson": nullStr(d.SystemsJson), + "disabled": d.Disabled, + "order": d.Order, + } +} + +func mapDownstreams(ds []db.Downstream) []map[string]any { + out := make([]map[string]any, len(ds)) + for i, d := range ds { + out[i] = mapDownstream(d) + } + return out +} + +func mapWebhook(w db.Webhook) map[string]any { + return map[string]any{ + "id": w.ID, + "url": w.Url, + "type": w.Type, + "secret": nullStr(w.Secret), + "systemsJson": nullStr(w.SystemsJson), + "disabled": w.Disabled, + "order": w.Order, + } +} + +func mapWebhooks(ws []db.Webhook) []map[string]any { + out := make([]map[string]any, len(ws)) + for i, w := range ws { + out[i] = mapWebhook(w) + } + return out +} + +func mapSharedLink(r db.ListSharedLinksRow) map[string]any { + m := map[string]any{ + "id": r.ID, + "callId": r.CallID, + "token": r.Token, + "createdAt": r.CreatedAt, + "sharedBy": r.SharedBy, + "dateTime": r.DateTime, + "duration": r.Duration.Int64, + "systemLabel": r.SystemLabel.String, + "talkgroupLabel": r.TalkgroupLabel.String, + "talkgroupName": r.TalkgroupName.String, + } + if r.ExpiresAt.Valid { + m["expiresAt"] = r.ExpiresAt.Int64 + } else { + m["expiresAt"] = nil + } + return m +} diff --git a/backend/internal/admin/radioreference.go b/backend/internal/admin/radioreference.go new file mode 100644 index 0000000..e69f0ff --- /dev/null +++ b/backend/internal/admin/radioreference.go @@ -0,0 +1,233 @@ +package admin + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strings" + + "github.com/openscanner/openscanner/internal/db" +) + +// RadioReferenceApply merges RadioReference-sourced talkgroup metadata into +// the local DB, using either fill-missing or overwrite-selected semantics. +func (o *Operations) RadioReferenceApply(ctx context.Context, params json.RawMessage, _ int64) (any, error) { + type rrCandidate struct { + Row int `json:"row"` + TalkgroupID int64 `json:"talkgroupId"` + Label *string `json:"label,omitempty"` + Name *string `json:"name,omitempty"` + Group *string `json:"group,omitempty"` + Tag *string `json:"tag,omitempty"` + Led *string `json:"led,omitempty"` + Order *int64 `json:"order,omitempty"` + } + + var req struct { + SystemID int64 `json:"systemId"` + Candidates []rrCandidate `json:"candidates"` + MergeMode string `json:"mergeMode"` + SelectedFields []string `json:"selectedFields"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.SystemID <= 0 { + return nil, UserError("systemId is required") + } + if len(req.Candidates) == 0 { + return nil, UserError("candidates are required") + } + if len(req.Candidates) > 100_000 { + return nil, UserError("too many candidates") + } + if req.MergeMode == "" { + req.MergeMode = "fill_missing" + } + if req.MergeMode != "fill_missing" && req.MergeMode != "overwrite_selected" { + return nil, UserError("mergeMode must be 'fill_missing' or 'overwrite_selected'") + } + if _, err := o.Queries.GetSystem(ctx, req.SystemID); err != nil { + return nil, UserError("system not found") + } + + // Sanitize selected fields. + rrUpdatable := map[string]bool{"label": true, "name": true, "group": true, "tag": true, "led": true, "order": true} + selected := make([]string, 0, len(req.SelectedFields)) + for _, f := range req.SelectedFields { + v := strings.ToLower(strings.TrimSpace(f)) + if rrUpdatable[v] { + selected = append(selected, v) + } + } + + type rowErr struct { + Row int `json:"row"` + Reason string `json:"reason"` + } + resp := map[string]any{ + "processed": 0, + "matched": 0, + "updated": 0, + "skipped": 0, + "errors": 0, + "rowErrors": []rowErr{}, + } + processed, matched, updated, skippedCount, errCount := 0, 0, 0, 0, 0 + rowErrors := make([]rowErr, 0) + + for _, candidate := range req.Candidates { + processed++ + + tg, tgErr := o.Queries.GetTalkgroupBySystemAndTGID(ctx, db.GetTalkgroupBySystemAndTGIDParams{ + SystemID: req.SystemID, + TalkgroupID: candidate.TalkgroupID, + }) + if tgErr != nil { + if errors.Is(tgErr, sql.ErrNoRows) { + skippedCount++ + rowErrors = append(rowErrors, rowErr{Row: candidate.Row, Reason: "talkgroup not found in selected system"}) + continue + } + errCount++ + rowErrors = append(rowErrors, rowErr{Row: candidate.Row, Reason: "database error"}) + continue + } + matched++ + + p := db.UpdateTalkgroupParams{ + ID: tg.ID, + TalkgroupID: tg.TalkgroupID, + Label: tg.Label, + Name: tg.Name, + Frequency: tg.Frequency, + Led: tg.Led, + GroupID: tg.GroupID, + TagID: tg.TagID, + Order: tg.Order, + } + + // Determine which fields to apply. + allow := map[string]bool{} + if req.MergeMode == "overwrite_selected" { + for _, f := range selected { + allow[f] = true + } + } + + applyFields := make([]string, 0, 6) + check := func(field string, hasCand bool, targetEmpty bool) { + if !hasCand { + return + } + if req.MergeMode == "overwrite_selected" { + if allow[field] { + applyFields = append(applyFields, field) + } + return + } + if targetEmpty { + applyFields = append(applyFields, field) + } + } + check("label", candidate.Label != nil, !tg.Label.Valid || strings.TrimSpace(tg.Label.String) == "") + check("name", candidate.Name != nil, !tg.Name.Valid || strings.TrimSpace(tg.Name.String) == "") + check("group", candidate.Group != nil, !tg.GroupID.Valid) + check("tag", candidate.Tag != nil, !tg.TagID.Valid) + check("led", candidate.Led != nil, !tg.Led.Valid || strings.TrimSpace(tg.Led.String) == "") + check("order", candidate.Order != nil, tg.Order == 0) + + if len(applyFields) == 0 { + skippedCount++ + continue + } + + // Apply field updates. + applyErr := false + for _, field := range applyFields { + switch field { + case "label": + if candidate.Label != nil { + p.Label = sql.NullString{String: *candidate.Label, Valid: true} + } + case "name": + if candidate.Name != nil { + p.Name = sql.NullString{String: *candidate.Name, Valid: true} + } + case "group": + if candidate.Group != nil { + g, err := o.Queries.GetGroupByLabel(ctx, *candidate.Group) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + newID, createErr := o.Queries.CreateGroup(ctx, *candidate.Group) + if createErr != nil { + errCount++ + rowErrors = append(rowErrors, rowErr{Row: candidate.Row, Reason: "database error"}) + applyErr = true + break + } + p.GroupID = sql.NullInt64{Int64: newID, Valid: true} + } else { + errCount++ + rowErrors = append(rowErrors, rowErr{Row: candidate.Row, Reason: "database error"}) + applyErr = true + break + } + } else { + p.GroupID = sql.NullInt64{Int64: g.ID, Valid: true} + } + } + case "tag": + if candidate.Tag != nil { + t, err := o.Queries.GetTagByLabel(ctx, *candidate.Tag) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + newID, createErr := o.Queries.CreateTag(ctx, *candidate.Tag) + if createErr != nil { + errCount++ + rowErrors = append(rowErrors, rowErr{Row: candidate.Row, Reason: "database error"}) + applyErr = true + break + } + p.TagID = sql.NullInt64{Int64: newID, Valid: true} + } else { + errCount++ + rowErrors = append(rowErrors, rowErr{Row: candidate.Row, Reason: "database error"}) + applyErr = true + break + } + } else { + p.TagID = sql.NullInt64{Int64: t.ID, Valid: true} + } + } + case "led": + if candidate.Led != nil { + p.Led = sql.NullString{String: *candidate.Led, Valid: true} + } + case "order": + if candidate.Order != nil { + p.Order = *candidate.Order + } + } + } + if applyErr { + continue + } + + if err := o.Queries.UpdateTalkgroup(ctx, p); err != nil { + errCount++ + rowErrors = append(rowErrors, rowErr{Row: candidate.Row, Reason: "database error"}) + continue + } + updated++ + } + + resp["processed"] = processed + resp["matched"] = matched + resp["updated"] = updated + resp["skipped"] = skippedCount + resp["errors"] = errCount + resp["rowErrors"] = rowErrors + return resp, nil +} diff --git a/backend/internal/admin/settings.go b/backend/internal/admin/settings.go new file mode 100644 index 0000000..f7c13a9 --- /dev/null +++ b/backend/internal/admin/settings.go @@ -0,0 +1,174 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strconv" + + "github.com/openscanner/openscanner/internal/audio" + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/logging" +) + +// ConfigGet returns the current settings (sensitive values decrypted) along +// with server capabilities. +func (o *Operations) ConfigGet(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + settings, err := o.Queries.ListSettings(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list settings: %w", err) + } + + settingsList := make([]map[string]string, len(settings)) + for i, s := range settings { + val := s.Value + if SensitiveSettingKeys[s.Key] && o.Deps.EncryptionKey != "" { + if plain, err := auth.DecryptString(val, o.Deps.EncryptionKey); err == nil { + val = plain + } + } + settingsList[i] = map[string]string{"key": s.Key, "value": val} + } + + return map[string]any{ + "settings": settingsList, + "capabilities": map[string]bool{ + "ffmpeg": o.Deps.FFmpegAvailable, + "fdkAac": o.Deps.FDKAACAvailable, + "whisper": o.Deps.WhisperAvailable, + }, + }, nil +} + +// ConfigUpdate applies a batch of settings atomically, encrypting sensitive +// values, hot-reloading transcription if touched, and rebroadcasting CFG. +func (o *Operations) ConfigUpdate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var body struct { + Settings []struct { + Key string `json:"key"` + Value string `json:"value"` + } `json:"settings"` + } + if err := json.Unmarshal(params, &body); err != nil { + return nil, UserError("invalid request body") + } + settings := body.Settings + + // Validate all keys first. + for _, s := range settings { + if !allowedSettingKeys[s.Key] { + return nil, UserError("unknown setting key: " + s.Key) + } + if s.Key == "logLevel" { + if _, ok := logging.ParseLevel(s.Value); !ok { + return nil, UserError("invalid logLevel; expected debug, info, warn, or error") + } + } + if s.Key == "audioEncodingPreset" { + if !audio.IsValidEncodingPreset(s.Value) { + return nil, UserError("invalid audioEncodingPreset value") + } + if audio.IsHEEncodingPreset(s.Value) && !o.Deps.FDKAACAvailable { + return nil, UserError("selected HE-AAC preset requires libfdk_aac support in ffmpeg") + } + } + if s.Key == "audioConversion" { + if v, err := strconv.Atoi(s.Value); err == nil && v != 0 && !o.Deps.FFmpegAvailable { + return nil, UserError("ffmpeg is not installed — install it and restart the service to enable audio conversion") + } + } + } + + sqlDB := o.Deps.SQLDB + if sqlDB == nil { + return nil, fmt.Errorf("transaction support not available") + } + + tx, err := sqlDB.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + qtx := o.Queries.WithTx(tx) + for _, s := range settings { + val := s.Value + if SensitiveSettingKeys[s.Key] && o.Deps.EncryptionKey != "" && val != "" { + enc, err := auth.EncryptString(val, o.Deps.EncryptionKey) + if err != nil { + return nil, fmt.Errorf("encrypt setting %q: %w", s.Key, err) + } + val = enc + } + if err := qtx.UpsertSetting(ctx, db.UpsertSettingParams{Key: s.Key, Value: val}); err != nil { + return nil, fmt.Errorf("failed to save config: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("failed to commit config: %w", err) + } + + // Log each changed setting, redacting sensitive keys. + for _, s := range settings { + v := s.Value + if s.Key == "vapidPrivateKey" { + v = "[REDACTED]" + } + slog.Info("admin: config updated", "key", s.Key, "value", v, "by", callerID) + } + + // Apply log level change at runtime. + for _, s := range settings { + if s.Key == "logLevel" { + if err := logging.SetLevel(s.Value); err != nil { + slog.Warn("invalid logLevel setting, keeping previous runtime level", "value", s.Value, "error", err) + } + break + } + } + + // Hot-reload transcription if any transcription setting changed. + if o.Deps.TranscriberReload != nil { + transcriptionKeys := map[string]bool{ + "transcriptionEnabled": true, + "transcriptionUrl": true, + "transcriptionModel": true, + "transcriptionLanguage": true, + "transcriptionDiarize": true, + } + needsReload := false + for _, s := range settings { + if transcriptionKeys[s.Key] { + needsReload = true + break + } + } + if needsReload { + // Read current settings from DB (just committed). + tEnabled, _ := o.Queries.GetSetting(ctx, "transcriptionEnabled") + tURL, _ := o.Queries.GetSetting(ctx, "transcriptionUrl") + tModel, _ := o.Queries.GetSetting(ctx, "transcriptionModel") + tLang, _ := o.Queries.GetSetting(ctx, "transcriptionLanguage") + tDiarize, _ := o.Queries.GetSetting(ctx, "transcriptionDiarize") + + ok := o.Deps.TranscriberReload.Reload( + tEnabled.Value == "true", + tURL.Value, + tModel.Value, + tLang.Value, + tDiarize.Value == "true", + ) + o.Deps.WhisperAvailable = ok && tEnabled.Value == "true" + } + } + + // Broadcast updated config to all WS clients using the safe, + // curated CFG builder (excludes secrets like VAPID keys). + o.broadcastCFG(ctx) + + o.broadcastAdminEvent("config.updated", nil) + return map[string]bool{"ok": true}, nil +} diff --git a/backend/internal/ws/admin_ops_settings_test.go b/backend/internal/admin/settings_test.go similarity index 60% rename from backend/internal/ws/admin_ops_settings_test.go rename to backend/internal/admin/settings_test.go index 5c7474d..2e5967c 100644 --- a/backend/internal/ws/admin_ops_settings_test.go +++ b/backend/internal/admin/settings_test.go @@ -1,14 +1,12 @@ -// Tests for the settings encryption round-trip in opConfigGet / opConfigUpdate. +// Tests for the settings encryption round-trip in ConfigGet / ConfigUpdate. // -// The handlers are methods on *Client; rather than standing up a full -// WebSocket connection, we construct a minimal Client with only the fields -// the handlers touch (hub, userID, isAdmin) and call the methods directly. -// This is an internal test (package ws) so it can reach unexported fields. -package ws +// These live in the admin package (after the Phase-2 restructure) because +// the CRUD semantics belong here; the WebSocket framing layer is tested +// separately in internal/ws/admin_router_test.go. +package admin import ( "context" - "database/sql" "encoding/json" "strings" "testing" @@ -18,9 +16,8 @@ import ( _ "modernc.org/sqlite" ) -// newAdminClientForSettings builds a minimal Client/Hub wired against an -// in-memory SQLite instance and returns everything the tests need. -func newAdminClientForSettings(t *testing.T, encryptionKey string) (*Client, *db.Queries, *sql.DB) { +// newTestOperations builds an Operations bound to an in-memory SQLite DB. +func newTestOperations(t *testing.T, encryptionKey string) (*Operations, *db.Queries) { t.Helper() sqlDB, err := db.Open(":memory:") if err != nil { @@ -29,29 +26,23 @@ func newAdminClientForSettings(t *testing.T, encryptionKey string) (*Client, *db t.Cleanup(func() { _ = sqlDB.Close() }) queries := db.New(sqlDB) - hub := NewHub(queries, "test", HubDeps{ + ops := New(queries, Deps{ SQLDB: sqlDB, EncryptionKey: encryptionKey, - }) - - c := &Client{ - hub: hub, - userID: 1, - isAdmin: true, - } - return c, queries, sqlDB + }, nil) + return ops, queries } -func TestAdminOps_SettingsUpsert_EncryptsSensitiveKey(t *testing.T) { +func TestConfigUpdate_EncryptsSensitiveKey(t *testing.T) { const encKey = "test-encryption-key" - c, queries, _ := newAdminClientForSettings(t, encKey) + ops, queries := newTestOperations(t, encKey) params, _ := json.Marshal(map[string]any{ "settings": []map[string]string{{"key": "vapidPrivateKey", "value": "secret123"}}, }) - if _, err := c.opConfigUpdate(context.Background(), params); err != nil { - t.Fatalf("opConfigUpdate: %v", err) + if _, err := ops.ConfigUpdate(context.Background(), params, 1); err != nil { + t.Fatalf("ConfigUpdate: %v", err) } stored, err := queries.GetSetting(context.Background(), "vapidPrivateKey") @@ -61,7 +52,6 @@ func TestAdminOps_SettingsUpsert_EncryptsSensitiveKey(t *testing.T) { if !auth.IsEncrypted(stored.Value) { t.Errorf("stored value should start with enc::; got %q", stored.Value) } - // Decrypt and confirm round-trip. plain, err := auth.DecryptString(stored.Value, encKey) if err != nil { t.Fatalf("DecryptString: %v", err) @@ -71,44 +61,42 @@ func TestAdminOps_SettingsUpsert_EncryptsSensitiveKey(t *testing.T) { } } -// TestAdminOps_SettingsUpsert_JwtSecret_NotUserMutable asserts that jwtSecret -// is marked sensitive (so ListSettings decrypts it when returning) BUT is not -// in the admin-allowed mutation set — it is managed exclusively by -// auth.InitJWTSecret at startup. -func TestAdminOps_SettingsUpsert_JwtSecret_NotUserMutable(t *testing.T) { +// TestConfigUpdate_JwtSecret_NotUserMutable asserts that jwtSecret is marked +// sensitive (so ConfigGet decrypts it) BUT is not in the admin-allowed +// mutation set — it is managed exclusively by auth.InitJWTSecret at startup. +func TestConfigUpdate_JwtSecret_NotUserMutable(t *testing.T) { const encKey = "another-test-key" - c, _, _ := newAdminClientForSettings(t, encKey) + ops, _ := newTestOperations(t, encKey) if !SensitiveSettingKeys["jwtSecret"] { t.Error("jwtSecret must be in SensitiveSettingKeys") } - if wsAllowedSettingKeys["jwtSecret"] { - t.Error("jwtSecret must NOT be in wsAllowedSettingKeys (managed by InitJWTSecret)") + if AllowedSettingKeys("jwtSecret") { + t.Error("jwtSecret must NOT be in allowedSettingKeys (managed by InitJWTSecret)") } - // Confirm that attempting to mutate it via the admin op is rejected. params, _ := json.Marshal(map[string]any{ "settings": []map[string]string{{"key": "jwtSecret", "value": "raw-signing-secret"}}, }) - _, err := c.opConfigUpdate(context.Background(), params) + _, err := ops.ConfigUpdate(context.Background(), params, 1) if err == nil { - t.Fatal("opConfigUpdate should reject jwtSecret as an unknown key") + t.Fatal("ConfigUpdate should reject jwtSecret as an unknown key") } if !strings.Contains(err.Error(), "jwtSecret") { t.Errorf("error should mention 'jwtSecret'; got: %v", err) } } -func TestAdminOps_SettingsUpsert_NonSensitiveNotEncrypted(t *testing.T) { +func TestConfigUpdate_NonSensitiveNotEncrypted(t *testing.T) { const encKey = "test-encryption-key" - c, queries, _ := newAdminClientForSettings(t, encKey) + ops, queries := newTestOperations(t, encKey) params, _ := json.Marshal(map[string]any{ "settings": []map[string]string{{"key": "logLevel", "value": "debug"}}, }) - if _, err := c.opConfigUpdate(context.Background(), params); err != nil { - t.Fatalf("opConfigUpdate: %v", err) + if _, err := ops.ConfigUpdate(context.Background(), params, 1); err != nil { + t.Fatalf("ConfigUpdate: %v", err) } stored, err := queries.GetSetting(context.Background(), "logLevel") @@ -123,17 +111,16 @@ func TestAdminOps_SettingsUpsert_NonSensitiveNotEncrypted(t *testing.T) { } } -func TestAdminOps_SettingsUpsert_NoEncryptionKey_StoresPlaintext(t *testing.T) { - // Empty encryption key — sensitive keys are stored plaintext (with warning - // logged at runtime; we don't assert on logs here). - c, queries, _ := newAdminClientForSettings(t, "") +func TestConfigUpdate_NoEncryptionKey_StoresPlaintext(t *testing.T) { + // Empty encryption key — sensitive keys are stored plaintext. + ops, queries := newTestOperations(t, "") params, _ := json.Marshal(map[string]any{ "settings": []map[string]string{{"key": "vapidPrivateKey", "value": "plain-secret"}}, }) - if _, err := c.opConfigUpdate(context.Background(), params); err != nil { - t.Fatalf("opConfigUpdate: %v", err) + if _, err := ops.ConfigUpdate(context.Background(), params, 1); err != nil { + t.Fatalf("ConfigUpdate: %v", err) } stored, err := queries.GetSetting(context.Background(), "vapidPrivateKey") @@ -148,11 +135,10 @@ func TestAdminOps_SettingsUpsert_NoEncryptionKey_StoresPlaintext(t *testing.T) { } } -func TestAdminOps_SettingsList_DecryptsSensitiveKey(t *testing.T) { +func TestConfigGet_DecryptsSensitiveKey(t *testing.T) { const encKey = "list-test-key" - c, queries, _ := newAdminClientForSettings(t, encKey) + ops, queries := newTestOperations(t, encKey) - // Seed an already-encrypted sensitive setting and a plaintext normal one. encrypted, err := auth.EncryptString("my-vapid-key", encKey) if err != nil { t.Fatalf("seed EncryptString: %v", err) @@ -168,9 +154,9 @@ func TestAdminOps_SettingsList_DecryptsSensitiveKey(t *testing.T) { t.Fatalf("seed UpsertSetting (logLevel): %v", err) } - result, err := c.opConfigGet(context.Background(), nil) + result, err := ops.ConfigGet(context.Background(), nil, 1) if err != nil { - t.Fatalf("opConfigGet: %v", err) + t.Fatalf("ConfigGet: %v", err) } m, ok := result.(map[string]any) @@ -195,9 +181,7 @@ func TestAdminOps_SettingsList_DecryptsSensitiveKey(t *testing.T) { } } -// TestSensitiveSettingKeys_Documented is a schema-level sanity check: any key -// added to the sensitive list without being wired through the encryption path -// would be a latent bug. +// TestSensitiveSettingKeys_Documented is a schema-level sanity check. func TestSensitiveSettingKeys_Documented(t *testing.T) { want := []string{"vapidPrivateKey", "jwtSecret"} for _, k := range want { diff --git a/backend/internal/admin/shared_links.go b/backend/internal/admin/shared_links.go new file mode 100644 index 0000000..2608810 --- /dev/null +++ b/backend/internal/admin/shared_links.go @@ -0,0 +1,39 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" +) + +// SharedLinksList returns all shared links. +func (o *Operations) SharedLinksList(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + rows, err := o.Queries.ListSharedLinks(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list shared links: %w", err) + } + items := make([]map[string]any, 0, len(rows)) + for _, r := range rows { + items = append(items, mapSharedLink(r)) + } + return items, nil +} + +// SharedLinksDelete deletes a shared link. +func (o *Operations) SharedLinksDelete(ctx context.Context, params json.RawMessage, _ int64) (any, error) { + var req struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + + if err := o.Queries.DeleteSharedLink(ctx, req.ID); err != nil { + return nil, fmt.Errorf("failed to delete shared link: %w", err) + } + o.broadcastAdminEvent("shared-links.updated", nil) + return map[string]bool{"deleted": true}, nil +} diff --git a/backend/internal/admin/systems.go b/backend/internal/admin/systems.go new file mode 100644 index 0000000..86d6d1d --- /dev/null +++ b/backend/internal/admin/systems.go @@ -0,0 +1,131 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/openscanner/openscanner/internal/db" +) + +// SystemsList returns all systems. +func (o *Operations) SystemsList(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + systems, err := o.Queries.ListSystems(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list systems: %w", err) + } + return mapSystems(systems), nil +} + +// SystemsCreate creates a new system. +func (o *Operations) SystemsCreate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + SystemID int64 `json:"systemId"` + Label string `json:"label"` + AutoPopulateTalkgroups int64 `json:"autoPopulateTalkgroups"` + BlacklistsJson *string `json:"blacklistsJson"` + Led *string `json:"led"` + Order int64 `json:"order"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + + id, err := o.Queries.CreateSystem(ctx, db.CreateSystemParams{ + SystemID: req.SystemID, + Label: req.Label, + AutoPopulateTalkgroups: req.AutoPopulateTalkgroups, + BlacklistsJson: ptrToNullStr(req.BlacklistsJson), + Led: ptrToNullStr(req.Led), + Order: req.Order, + }) + if isUniqueViolation(err) { + return nil, UserError("system_id already exists") + } + if err != nil { + return nil, fmt.Errorf("failed to create system: %w", err) + } + + system, err := o.Queries.GetSystem(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to fetch created system: %w", err) + } + slog.Info("admin: system created", "id", system.ID, "system_id", system.SystemID, "label", system.Label, "by", callerID) + o.broadcastAdminEvent("systems.updated", nil) + o.broadcastCFG(ctx) + return mapSystem(system), nil +} + +// SystemsUpdate updates an existing system. +func (o *Operations) SystemsUpdate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + SystemID int64 `json:"systemId"` + Label string `json:"label"` + AutoPopulateTalkgroups int64 `json:"autoPopulateTalkgroups"` + BlacklistsJson *string `json:"blacklistsJson"` + Led *string `json:"led"` + Order int64 `json:"order"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + + if _, err := o.Queries.GetSystem(ctx, req.ID); err != nil { + return nil, UserError("system not found") + } + + err := o.Queries.UpdateSystem(ctx, db.UpdateSystemParams{ + ID: req.ID, + SystemID: req.SystemID, + Label: req.Label, + AutoPopulateTalkgroups: req.AutoPopulateTalkgroups, + BlacklistsJson: ptrToNullStr(req.BlacklistsJson), + Led: ptrToNullStr(req.Led), + Order: req.Order, + }) + if isUniqueViolation(err) { + return nil, UserError("system_id already exists") + } + if err != nil { + return nil, fmt.Errorf("failed to update system: %w", err) + } + + system, err := o.Queries.GetSystem(ctx, req.ID) + if err != nil { + return nil, fmt.Errorf("failed to fetch updated system: %w", err) + } + slog.Info("admin: system updated", "id", system.ID, "system_id", system.SystemID, "by", callerID) + o.broadcastAdminEvent("systems.updated", nil) + o.broadcastCFG(ctx) + return mapSystem(system), nil +} + +// SystemsDelete deletes a system. +func (o *Operations) SystemsDelete(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + + if _, err := o.Queries.GetSystem(ctx, req.ID); err != nil { + return nil, UserError("system not found") + } + + if err := o.Queries.DeleteSystem(ctx, req.ID); err != nil { + return nil, fmt.Errorf("failed to delete system: %w", err) + } + slog.Info("admin: system deleted", "id", req.ID, "by", callerID) + o.broadcastAdminEvent("systems.updated", nil) + o.broadcastCFG(ctx) + return map[string]bool{"ok": true}, nil +} diff --git a/backend/internal/admin/tags.go b/backend/internal/admin/tags.go new file mode 100644 index 0000000..1ba382a --- /dev/null +++ b/backend/internal/admin/tags.go @@ -0,0 +1,112 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/openscanner/openscanner/internal/db" +) + +// TagsList returns all tags. +func (o *Operations) TagsList(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + tags, err := o.Queries.ListTags(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list tags: %w", err) + } + return tags, nil +} + +// TagsCreate creates a new tag. +func (o *Operations) TagsCreate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + Label string `json:"label"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.Label == "" { + return nil, UserError("label is required") + } + + id, err := o.Queries.CreateTag(ctx, req.Label) + if isUniqueViolation(err) { + return nil, UserError("tag label already exists") + } + if err != nil { + return nil, fmt.Errorf("failed to create tag: %w", err) + } + + tag, err := o.Queries.GetTag(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to fetch created tag: %w", err) + } + slog.Info("admin: tag created", "id", tag.ID, "label", tag.Label, "by", callerID) + o.broadcastAdminEvent("tags.updated", nil) + o.broadcastCFG(ctx) + return tag, nil +} + +// TagsUpdate updates an existing tag. +func (o *Operations) TagsUpdate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + Label string `json:"label"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + if req.Label == "" { + return nil, UserError("label is required") + } + + if _, err := o.Queries.GetTag(ctx, req.ID); err != nil { + return nil, UserError("tag not found") + } + + err := o.Queries.UpdateTag(ctx, db.UpdateTagParams{ID: req.ID, Label: req.Label}) + if isUniqueViolation(err) { + return nil, UserError("tag label already exists") + } + if err != nil { + return nil, fmt.Errorf("failed to update tag: %w", err) + } + + tag, err := o.Queries.GetTag(ctx, req.ID) + if err != nil { + return nil, fmt.Errorf("failed to fetch updated tag: %w", err) + } + slog.Info("admin: tag updated", "id", tag.ID, "label", tag.Label, "by", callerID) + o.broadcastAdminEvent("tags.updated", nil) + o.broadcastCFG(ctx) + return tag, nil +} + +// TagsDelete deletes a tag. +func (o *Operations) TagsDelete(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + + if _, err := o.Queries.GetTag(ctx, req.ID); err != nil { + return nil, UserError("tag not found") + } + + if err := o.Queries.DeleteTag(ctx, req.ID); err != nil { + return nil, fmt.Errorf("failed to delete tag: %w", err) + } + slog.Info("admin: tag deleted", "id", req.ID, "by", callerID) + o.broadcastAdminEvent("tags.updated", nil) + o.broadcastCFG(ctx) + return map[string]bool{"ok": true}, nil +} diff --git a/backend/internal/admin/talkgroups.go b/backend/internal/admin/talkgroups.go new file mode 100644 index 0000000..ce621c7 --- /dev/null +++ b/backend/internal/admin/talkgroups.go @@ -0,0 +1,141 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/openscanner/openscanner/internal/db" +) + +// TalkgroupsList returns all talkgroups. +func (o *Operations) TalkgroupsList(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + tgs, err := o.Queries.ListAllTalkgroups(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list talkgroups: %w", err) + } + return mapTalkgroups(tgs), nil +} + +// TalkgroupsCreate creates a new talkgroup. +func (o *Operations) TalkgroupsCreate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + SystemID int64 `json:"systemId"` + TalkgroupID int64 `json:"talkgroupId"` + Label *string `json:"label"` + Name *string `json:"name"` + Frequency *int64 `json:"frequency"` + Led *string `json:"led"` + GroupID *int64 `json:"groupId"` + TagID *int64 `json:"tagId"` + Order int64 `json:"order"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + + id, err := o.Queries.CreateTalkgroup(ctx, db.CreateTalkgroupParams{ + SystemID: req.SystemID, + TalkgroupID: req.TalkgroupID, + Label: ptrToNullStr(req.Label), + Name: ptrToNullStr(req.Name), + Frequency: ptrToNullInt(req.Frequency), + Led: ptrToNullStr(req.Led), + GroupID: ptrToNullInt(req.GroupID), + TagID: ptrToNullInt(req.TagID), + Order: req.Order, + }) + if isUniqueViolation(err) { + return nil, UserError("talkgroup already exists") + } + if err != nil { + return nil, fmt.Errorf("failed to create talkgroup: %w", err) + } + + tg, err := o.Queries.GetTalkgroup(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to fetch created talkgroup: %w", err) + } + slog.Info("admin: talkgroup created", "id", tg.ID, "talkgroup_id", tg.TalkgroupID, "by", callerID) + o.broadcastAdminEvent("talkgroups.updated", nil) + o.broadcastCFG(ctx) + return mapTalkgroup(tg), nil +} + +// TalkgroupsUpdate updates an existing talkgroup. +func (o *Operations) TalkgroupsUpdate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + TalkgroupID int64 `json:"talkgroupId"` + Label *string `json:"label"` + Name *string `json:"name"` + Frequency *int64 `json:"frequency"` + Led *string `json:"led"` + GroupID *int64 `json:"groupId"` + TagID *int64 `json:"tagId"` + Order int64 `json:"order"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + + if _, err := o.Queries.GetTalkgroup(ctx, req.ID); err != nil { + return nil, UserError("talkgroup not found") + } + + err := o.Queries.UpdateTalkgroup(ctx, db.UpdateTalkgroupParams{ + ID: req.ID, + TalkgroupID: req.TalkgroupID, + Label: ptrToNullStr(req.Label), + Name: ptrToNullStr(req.Name), + Frequency: ptrToNullInt(req.Frequency), + Led: ptrToNullStr(req.Led), + GroupID: ptrToNullInt(req.GroupID), + TagID: ptrToNullInt(req.TagID), + Order: req.Order, + }) + if isUniqueViolation(err) { + return nil, UserError("talkgroup already exists") + } + if err != nil { + return nil, fmt.Errorf("failed to update talkgroup: %w", err) + } + + tg, err := o.Queries.GetTalkgroup(ctx, req.ID) + if err != nil { + return nil, fmt.Errorf("failed to fetch updated talkgroup: %w", err) + } + slog.Info("admin: talkgroup updated", "id", tg.ID, "talkgroup_id", tg.TalkgroupID, "by", callerID) + o.broadcastAdminEvent("talkgroups.updated", nil) + o.broadcastCFG(ctx) + return mapTalkgroup(tg), nil +} + +// TalkgroupsDelete deletes a talkgroup. +func (o *Operations) TalkgroupsDelete(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + + if _, err := o.Queries.GetTalkgroup(ctx, req.ID); err != nil { + return nil, UserError("talkgroup not found") + } + + if err := o.Queries.DeleteTalkgroup(ctx, req.ID); err != nil { + return nil, fmt.Errorf("failed to delete talkgroup: %w", err) + } + slog.Info("admin: talkgroup deleted", "id", req.ID, "by", callerID) + o.broadcastAdminEvent("talkgroups.updated", nil) + o.broadcastCFG(ctx) + return map[string]bool{"ok": true}, nil +} diff --git a/backend/internal/admin/transcription.go b/backend/internal/admin/transcription.go new file mode 100644 index 0000000..6e1feb3 --- /dev/null +++ b/backend/internal/admin/transcription.go @@ -0,0 +1,289 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" +) + +// transcriptionBaseURL reads the transcriptionUrl setting from DB. +func (o *Operations) transcriptionBaseURL(ctx context.Context) (string, error) { + s, err := o.Queries.GetSetting(ctx, "transcriptionUrl") + if err == nil && s.Value != "" && validHTTPURL(s.Value) { + return strings.TrimRight(s.Value, "/"), nil + } + // Fall back to the live manager's URL (e.g. when DB setting was just saved + // but the query above fails due to timing). + if tr := o.Deps.TranscriberReload; tr != nil { + if u := tr.BaseURL(); u != "" { + return strings.TrimRight(u, "/"), nil + } + } + return "", UserError("transcriptionUrl setting is not configured") +} + +// TranscriptionStatus returns whether transcription is enabled, the +// configured model/language, and live connectivity to go-whisper. +func (o *Operations) TranscriptionStatus(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + // Read settings from DB. + getVal := func(key string) string { + s, err := o.Queries.GetSetting(ctx, key) + if err != nil { + return "" + } + return s.Value + } + + enabled := getVal("transcriptionEnabled") == "true" + baseURL := getVal("transcriptionUrl") + model := getVal("transcriptionModel") + language := getVal("transcriptionLanguage") + diarize := getVal("transcriptionDiarize") == "true" + liveDisplay := getVal("liveTranscriptDisplay") == "true" + + // Check live connection to go-whisper. + connected := false + if baseURL != "" && validHTTPURL(baseURL) { + trimmed := strings.TrimRight(baseURL, "/") + reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, trimmed+"/api/whisper/model", nil) + if err == nil { + resp, err := http.DefaultClient.Do(req) + if err == nil { + resp.Body.Close() + connected = resp.StatusCode >= 200 && resp.StatusCode < 400 + } + } + } + + return map[string]any{ + "enabled": enabled, + "url": baseURL, + "model": model, + "language": language, + "diarize": diarize, + "liveDisplay": liveDisplay, + "connected": connected, + }, nil +} + +// TranscriptionModels proxies the model list from go-whisper. +func (o *Operations) TranscriptionModels(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + baseURL, err := o.transcriptionBaseURL(ctx) + if err != nil { + return nil, err + } + + reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, baseURL+"/api/whisper/model", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("go-whisper unreachable: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("go-whisper returned status %d", resp.StatusCode) + } + + var result json.RawMessage + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("invalid JSON from go-whisper: %w", err) + } + return result, nil +} + +// TranscriptionDownload triggers a model download on go-whisper. +func (o *Operations) TranscriptionDownload(ctx context.Context, params json.RawMessage, _ int64) (any, error) { + var req struct { + Model string `json:"model"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.Model == "" { + return nil, UserError("model name is required") + } + + // go-whisper expects model names with .bin extension + model := req.Model + if !strings.HasSuffix(model, ".bin") { + model += ".bin" + } + + // tdrz (tinydiarize) models live in a different HuggingFace repo. + // go-whisper's store accepts a full URL as the model path for non-default repos. + if strings.Contains(model, "tdrz") { + model = "https://huggingface.co/akashmjn/tinydiarize-whisper.cpp/resolve/main/ggml-" + strings.TrimPrefix(model, "ggml-") + } + + baseURL, err := o.transcriptionBaseURL(ctx) + if err != nil { + return nil, err + } + + reqBody, _ := json.Marshal(map[string]string{"model": model}) + + // Model downloads can take a long time (500MB+). + reqCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, baseURL+"/api/whisper/model", strings.NewReader(string(reqBody))) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("go-whisper unreachable: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + slog.Warn("go-whisper model download failed", "status", resp.StatusCode, "body", string(body)) + return nil, fmt.Errorf("go-whisper returned status %d", resp.StatusCode) + } + + var result json.RawMessage + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("invalid JSON from go-whisper: %w", err) + } + return result, nil +} + +// TranscriptionDelete deletes a model on go-whisper. +func (o *Operations) TranscriptionDelete(ctx context.Context, params json.RawMessage, _ int64) (any, error) { + var req struct { + ID string `json:"id"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID == "" { + return nil, UserError("model id is required") + } + + // Sanitise: model ID should be alphanumeric + hyphens/dots/underscores only. + for _, ch := range req.ID { + if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '.' || ch == '_') { + return nil, UserError("invalid model id") + } + } + + baseURL, err := o.transcriptionBaseURL(ctx) + if err != nil { + return nil, err + } + + reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodDelete, baseURL+"/api/whisper/model/"+req.ID, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("go-whisper unreachable: %w", err) + } + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) //nolint:errcheck + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("go-whisper returned status %d", resp.StatusCode) + } + + return map[string]any{"deleted": true}, nil +} + +// TranscriptionStats aggregates transcription DB stats and live pool status. +func (o *Operations) TranscriptionStats(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + // DB aggregate stats — "recent" = last 24 hours. + since := time.Now().Add(-24 * time.Hour).Unix() + stats, err := o.Queries.TranscriptionStats(ctx, since) + if err != nil { + return nil, fmt.Errorf("query transcription stats: %w", err) + } + + byLang, err := o.Queries.TranscriptionsByLanguage(ctx) + if err != nil { + return nil, fmt.Errorf("query transcriptions by language: %w", err) + } + + byModel, err := o.Queries.TranscriptionsByModel(ctx) + if err != nil { + return nil, fmt.Errorf("query transcriptions by model: %w", err) + } + + // Pool stats (live). + queueDepth := 0 + poolEnabled := false + if tr := o.Deps.TranscriberReload; tr != nil { + poolEnabled = tr.Enabled() + queueDepth = tr.QueueDepth() + } + + // Convert interface{} values from COALESCE/AVG to int64. + toInt64 := func(v interface{}) int64 { + switch n := v.(type) { + case int64: + return n + case float64: + return int64(n) + default: + return 0 + } + } + + langBreakdown := make([]map[string]any, 0, len(byLang)) + for _, l := range byLang { + langBreakdown = append(langBreakdown, map[string]any{ + "language": l.Lang, + "count": l.Cnt, + }) + } + + modelBreakdown := make([]map[string]any, 0, len(byModel)) + for _, m := range byModel { + modelBreakdown = append(modelBreakdown, map[string]any{ + "model": m.ModelName, + "count": m.Cnt, + }) + } + + return map[string]any{ + "total": stats.Total, + "recent24h": stats.RecentCount, + "avgDurationMs": toInt64(stats.AvgDurationMs), + "minDurationMs": toInt64(stats.MinDurationMs), + "maxDurationMs": toInt64(stats.MaxDurationMs), + "queueDepth": queueDepth, + "poolEnabled": poolEnabled, + "byLanguage": langBreakdown, + "byModel": modelBreakdown, + }, nil +} diff --git a/backend/internal/admin/units.go b/backend/internal/admin/units.go new file mode 100644 index 0000000..9dedf47 --- /dev/null +++ b/backend/internal/admin/units.go @@ -0,0 +1,146 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strconv" + "strings" + + "github.com/openscanner/openscanner/internal/db" +) + +// UnitsList returns units filtered by optional systemId + unitIdPattern. +func (o *Operations) UnitsList(ctx context.Context, params json.RawMessage, _ int64) (any, error) { + var req struct { + SystemID *int64 `json:"systemId"` + UnitIDPattern *string `json:"unitIdPattern"` + } + if params != nil { + _ = json.Unmarshal(params, &req) // ignore parse errors — treat as no filter + } + + var units []db.Unit + var err error + if req.SystemID != nil { + units, err = o.Queries.ListUnitsBySystem(ctx, *req.SystemID) + } else { + units, err = o.Queries.ListAllUnits(ctx) + } + if err != nil { + return nil, fmt.Errorf("failed to list units: %w", err) + } + + // Apply unit_id pattern filter if provided (prefix matching). + if req.UnitIDPattern != nil && *req.UnitIDPattern != "" { + filtered := make([]db.Unit, 0, len(units)) + for _, u := range units { + if strings.HasPrefix(strconv.FormatInt(u.UnitID, 10), *req.UnitIDPattern) { + filtered = append(filtered, u) + } + } + units = filtered + } + + return mapUnits(units), nil +} + +// UnitsCreate creates a new unit. +func (o *Operations) UnitsCreate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + SystemID int64 `json:"systemId"` + UnitID int64 `json:"unitId"` + Label *string `json:"label"` + Order int64 `json:"order"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + + id, err := o.Queries.CreateUnit(ctx, db.CreateUnitParams{ + SystemID: req.SystemID, + UnitID: req.UnitID, + Label: ptrToNullStr(req.Label), + Order: req.Order, + }) + if isUniqueViolation(err) { + return nil, UserError("unit already exists") + } + if err != nil { + return nil, fmt.Errorf("failed to create unit: %w", err) + } + + unit, err := o.Queries.GetUnit(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to fetch created unit: %w", err) + } + slog.Info("admin: unit created", "id", unit.ID, "unit_id", unit.UnitID, "by", callerID) + o.broadcastAdminEvent("units.updated", nil) + return mapUnit(unit), nil +} + +// UnitsUpdate updates an existing unit. +func (o *Operations) UnitsUpdate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + UnitID int64 `json:"unitId"` + Label *string `json:"label"` + Order int64 `json:"order"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + + if _, err := o.Queries.GetUnit(ctx, req.ID); err != nil { + return nil, UserError("unit not found") + } + + err := o.Queries.UpdateUnit(ctx, db.UpdateUnitParams{ + ID: req.ID, + UnitID: req.UnitID, + Label: ptrToNullStr(req.Label), + Order: req.Order, + }) + if isUniqueViolation(err) { + return nil, UserError("unit already exists") + } + if err != nil { + return nil, fmt.Errorf("failed to update unit: %w", err) + } + + unit, err := o.Queries.GetUnit(ctx, req.ID) + if err != nil { + return nil, fmt.Errorf("failed to fetch updated unit: %w", err) + } + slog.Info("admin: unit updated", "id", unit.ID, "unit_id", unit.UnitID, "by", callerID) + o.broadcastAdminEvent("units.updated", nil) + return mapUnit(unit), nil +} + +// UnitsDelete deletes a unit. +func (o *Operations) UnitsDelete(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + + if _, err := o.Queries.GetUnit(ctx, req.ID); err != nil { + return nil, UserError("unit not found") + } + + if err := o.Queries.DeleteUnit(ctx, req.ID); err != nil { + return nil, fmt.Errorf("failed to delete unit: %w", err) + } + slog.Info("admin: unit deleted", "id", req.ID, "by", callerID) + o.broadcastAdminEvent("units.updated", nil) + return map[string]bool{"ok": true}, nil +} diff --git a/backend/internal/admin/users.go b/backend/internal/admin/users.go new file mode 100644 index 0000000..ece74ba --- /dev/null +++ b/backend/internal/admin/users.go @@ -0,0 +1,203 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" +) + +// UsersList returns all users. +func (o *Operations) UsersList(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + users, err := o.Queries.ListUsers(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list users: %w", err) + } + return mapUsers(users), nil +} + +// UsersCreate creates a new user. +func (o *Operations) UsersCreate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + Disabled int64 `json:"disabled"` + SystemsJson *string `json:"systemsJson"` + Expiration *int64 `json:"expiration"` + Limit *int64 `json:"limit"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.Username == "" { + return nil, UserError("username is required") + } + if len(req.Username) > 64 { + return nil, UserError("username must be at most 64 characters") + } + if len(req.Password) < 8 { + return nil, UserError("password must be at least 8 characters") + } + if len(req.Password) > 128 { + return nil, UserError("password must be at most 128 characters") + } + if req.Role == "" { + req.Role = "listener" + } + if !validRoles[req.Role] { + return nil, UserError("role must be 'admin' or 'listener'") + } + + hash, err := auth.HashPassword(req.Password) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + now := time.Now().Unix() + id, err := o.Queries.CreateUser(ctx, db.CreateUserParams{ + Username: req.Username, + PasswordHash: hash, + Role: req.Role, + Disabled: req.Disabled, + SystemsJson: ptrToNullStr(req.SystemsJson), + Expiration: ptrToNullInt(req.Expiration), + Limit: ptrToNullInt(req.Limit), + PasswordNeedChange: 1, + CreatedAt: now, + UpdatedAt: now, + }) + if isUniqueViolation(err) { + return nil, UserError("username already exists") + } + if err != nil { + return nil, fmt.Errorf("failed to create user: %w", err) + } + + user, err := o.Queries.GetUser(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to fetch created user: %w", err) + } + slog.Info("admin: user created", "id", user.ID, "username", user.Username, "role", user.Role, "by", callerID) + o.broadcastAdminEvent("users.updated", nil) + return mapUser(user), nil +} + +// UsersUpdate updates an existing user. +func (o *Operations) UsersUpdate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + Disabled int64 `json:"disabled"` + SystemsJson *string `json:"systemsJson"` + Expiration *int64 `json:"expiration"` + Limit *int64 `json:"limit"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + if req.Username == "" { + return nil, UserError("username is required") + } + if len(req.Username) > 64 { + return nil, UserError("username must be at most 64 characters") + } + if req.Role == "" { + return nil, UserError("role is required") + } + if !validRoles[req.Role] { + return nil, UserError("role must be 'admin' or 'listener'") + } + + if _, err := o.Queries.GetUser(ctx, req.ID); err != nil { + return nil, UserError("user not found") + } + + // Prevent disabling the bootstrap admin (id=1). + if req.ID == 1 && req.Disabled != 0 { + return nil, UserError("cannot disable the primary admin account") + } + // Protect bootstrap admin role/expiration/limit. + if req.ID == 1 { + req.Role = "admin" + req.Expiration = nil + req.Limit = nil + } + + err := o.Queries.UpdateUser(ctx, db.UpdateUserParams{ + ID: req.ID, + Username: req.Username, + Role: req.Role, + Disabled: req.Disabled, + SystemsJson: ptrToNullStr(req.SystemsJson), + Expiration: ptrToNullInt(req.Expiration), + Limit: ptrToNullInt(req.Limit), + UpdatedAt: time.Now().Unix(), + }) + if isUniqueViolation(err) { + return nil, UserError("username already exists") + } + if err != nil { + return nil, fmt.Errorf("failed to update user: %w", err) + } + + // Revoke all tokens so stale claims are not trusted after update. + auth.Tokens.RevokeAllForUser(req.ID) + + // Immediately disconnect all active WS sessions for the updated user. + o.disconnectByUser(req.ID) + + user, err := o.Queries.GetUser(ctx, req.ID) + if err != nil { + return nil, fmt.Errorf("failed to fetch updated user: %w", err) + } + slog.Info("admin: user updated", "id", user.ID, "username", user.Username, "role", user.Role, "disabled", user.Disabled, "by", callerID) + o.broadcastAdminEvent("users.updated", nil) + return mapUser(user), nil +} + +// UsersDelete deletes a user. +func (o *Operations) UsersDelete(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + + // Cannot delete your own account. + if callerID == req.ID { + return nil, UserError("cannot delete your own account") + } + // Cannot delete bootstrap admin. + if req.ID == 1 { + return nil, UserError("cannot delete the primary admin account") + } + + if _, err := o.Queries.GetUser(ctx, req.ID); err != nil { + return nil, UserError("user not found") + } + + if err := o.Queries.DeleteUser(ctx, req.ID); err != nil { + return nil, fmt.Errorf("failed to delete user: %w", err) + } + + // Revoke tokens and disconnect active WS sessions for the deleted user. + auth.Tokens.RevokeAllForUser(req.ID) + o.disconnectByUser(req.ID) + + slog.Info("admin: user deleted", "id", req.ID, "by", callerID) + o.broadcastAdminEvent("users.updated", nil) + return map[string]bool{"ok": true}, nil +} diff --git a/backend/internal/admin/webhooks.go b/backend/internal/admin/webhooks.go new file mode 100644 index 0000000..6345b79 --- /dev/null +++ b/backend/internal/admin/webhooks.go @@ -0,0 +1,130 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/openscanner/openscanner/internal/db" +) + +// WebhooksList returns all webhooks. +func (o *Operations) WebhooksList(ctx context.Context, _ json.RawMessage, _ int64) (any, error) { + whs, err := o.Queries.ListWebhooks(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list webhooks: %w", err) + } + return mapWebhooks(whs), nil +} + +// WebhooksCreate creates a new webhook. +func (o *Operations) WebhooksCreate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + Url string `json:"url"` + Type string `json:"type"` + Secret *string `json:"secret"` + SystemsJson *string `json:"systemsJson"` + Disabled int64 `json:"disabled"` + Order int64 `json:"order"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.Url == "" { + return nil, UserError("url is required") + } + if !validHTTPURL(req.Url) { + return nil, UserError("url must use http or https scheme") + } + + id, err := o.Queries.CreateWebhook(ctx, db.CreateWebhookParams{ + Url: req.Url, + Type: req.Type, + Secret: ptrToNullStr(req.Secret), + SystemsJson: ptrToNullStr(req.SystemsJson), + Disabled: req.Disabled, + Order: req.Order, + }) + if err != nil { + return nil, fmt.Errorf("failed to create webhook: %w", err) + } + + wh, err := o.Queries.GetWebhook(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to fetch created webhook: %w", err) + } + slog.Info("admin: webhook created", "id", wh.ID, "url", wh.Url, "by", callerID) + o.broadcastAdminEvent("webhooks.updated", nil) + return mapWebhook(wh), nil +} + +// WebhooksUpdate updates an existing webhook. +func (o *Operations) WebhooksUpdate(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + Url string `json:"url"` + Type string `json:"type"` + Secret *string `json:"secret"` + SystemsJson *string `json:"systemsJson"` + Disabled int64 `json:"disabled"` + Order int64 `json:"order"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + if req.Url != "" && !validHTTPURL(req.Url) { + return nil, UserError("url must use http or https scheme") + } + + if _, err := o.Queries.GetWebhook(ctx, req.ID); err != nil { + return nil, UserError("webhook not found") + } + + if err := o.Queries.UpdateWebhook(ctx, db.UpdateWebhookParams{ + ID: req.ID, + Url: req.Url, + Type: req.Type, + Secret: ptrToNullStr(req.Secret), + SystemsJson: ptrToNullStr(req.SystemsJson), + Disabled: req.Disabled, + Order: req.Order, + }); err != nil { + return nil, fmt.Errorf("failed to update webhook: %w", err) + } + + wh, err := o.Queries.GetWebhook(ctx, req.ID) + if err != nil { + return nil, fmt.Errorf("failed to fetch updated webhook: %w", err) + } + slog.Info("admin: webhook updated", "id", wh.ID, "url", wh.Url, "by", callerID) + o.broadcastAdminEvent("webhooks.updated", nil) + return mapWebhook(wh), nil +} + +// WebhooksDelete deletes a webhook. +func (o *Operations) WebhooksDelete(ctx context.Context, params json.RawMessage, callerID int64) (any, error) { + var req struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(params, &req); err != nil { + return nil, UserError("invalid request body") + } + if req.ID <= 0 { + return nil, UserError("id is required") + } + + if _, err := o.Queries.GetWebhook(ctx, req.ID); err != nil { + return nil, UserError("webhook not found") + } + + if err := o.Queries.DeleteWebhook(ctx, req.ID); err != nil { + return nil, fmt.Errorf("failed to delete webhook: %w", err) + } + slog.Info("admin: webhook deleted", "id", req.ID, "by", callerID) + o.broadcastAdminEvent("webhooks.updated", nil) + return map[string]bool{"ok": true}, nil +} diff --git a/backend/internal/ws/admin_ops.go b/backend/internal/ws/admin_ops.go deleted file mode 100644 index 0bd53bd..0000000 --- a/backend/internal/ws/admin_ops.go +++ /dev/null @@ -1,3201 +0,0 @@ -// Package ws — admin CRUD operation handlers for the WebSocket protocol. -package ws - -import ( - "context" - "database/sql" - "encoding/csv" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "net/url" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" - - "github.com/google/uuid" - "github.com/openscanner/openscanner/internal/audio" - "github.com/openscanner/openscanner/internal/auth" - "github.com/openscanner/openscanner/internal/db" - "github.com/openscanner/openscanner/internal/logging" -) - -// ── Helpers ── - -func wsPtrToNullStr(p *string) sql.NullString { - if p == nil { - return sql.NullString{} - } - return sql.NullString{String: *p, Valid: true} -} - -func wsPtrToNullInt(p *int64) sql.NullInt64 { - if p == nil { - return sql.NullInt64{} - } - return sql.NullInt64{Int64: *p, Valid: true} -} - -func wsNullStr(n sql.NullString) *string { - if !n.Valid { - return nil - } - return &n.String -} - -func wsNullInt(n sql.NullInt64) *int64 { - if !n.Valid { - return nil - } - return &n.Int64 -} - -func wsIsUniqueViolation(err error) bool { - return err != nil && strings.Contains(err.Error(), "UNIQUE") -} - -// remapSystemsJSON rewrites the system PKs embedded in a systems_json column -// (used by api_keys, downstreams, webhooks, and users) so that grants -// referring to a system by its old PK end up referring to the freshly -// inserted row's PK after import. Accepts and returns a *string mirroring -// the export shape (nil = "all systems"). Any system PK that doesn't appear -// in the remap is dropped from the grant rather than silently broken. -// -// Shape: `[{"id": , "talkgroups": [...]}]` per -// auth.SystemGrant. -func remapSystemsJSON(in *string, systemRemap map[int64]int64) *string { - if in == nil || strings.TrimSpace(*in) == "" { - return in - } - var grants []auth.SystemGrant - if err := json.Unmarshal([]byte(*in), &grants); err != nil { - // Fall through with nil grants — try the legacy flat-id form. - var ids []int64 - if jerr := json.Unmarshal([]byte(*in), &ids); jerr != nil { - slog.Warn("import config: systems_json not recognised; preserving as-is", - "error", err) - return in - } - mapped := make([]int64, 0, len(ids)) - for _, id := range ids { - if newID, ok := systemRemap[id]; ok { - mapped = append(mapped, newID) - } else { - slog.Warn("import config: dropping unknown system grant", "system_pk", id) - } - } - out, _ := json.Marshal(mapped) - s := string(out) - return &s - } - mapped := make([]auth.SystemGrant, 0, len(grants)) - for _, g := range grants { - newID, ok := systemRemap[g.ID] - if !ok { - slog.Warn("import config: dropping unknown system grant", "system_pk", g.ID) - continue - } - mapped = append(mapped, auth.SystemGrant{ID: newID, Talkgroups: g.Talkgroups}) - } - out, _ := json.Marshal(mapped) - s := string(out) - return &s -} - -func wsValidHTTPURL(raw string) bool { - u, err := url.Parse(raw) - if err != nil { - return false - } - return u.Scheme == "http" || u.Scheme == "https" -} - -// validRoles is the set of allowed user roles. -var wsValidRoles = map[string]bool{ - auth.RoleAdmin: true, - auth.RoleListener: true, -} - -// SensitiveSettingKeys are settings whose values are encrypted at rest. -var SensitiveSettingKeys = map[string]bool{ - "vapidPrivateKey": true, - "jwtSecret": true, -} - -// wsAllowedSettingKeys mirrors the allowed setting keys from config.go. -var wsAllowedSettingKeys = map[string]bool{ - "activityDashboard": true, - "afsSystems": true, - "apiKeyCallRate": true, - "audioConversion": true, - "audioEncodingPreset": true, - "autoPopulateSystems": true, - "branding": true, - "disableDuplicateDetection": true, - "duplicateDetectionTimeFrame": true, - "email": true, - "keypadBeeps": true, - "logLevel": true, - "maxClients": true, - "playbackGoesLive": true, - "pruneDays": true, - "publicAccess": true, - "pushNotifications": true, - "searchPatchedTalkgroups": true, - "shareableLinks": true, - "sharedLinkExpiry": true, - "showListenersCount": true, - "sortTalkgroups": true, - "tagsToggle": true, - "time12hFormat": true, - "transcriptionDiarize": true, - "transcriptionEnabled": true, - "transcriptionLanguage": true, - "liveTranscriptDisplay": true, - "transcriptionModel": true, - "transcriptionUrl": true, - "vapidPrivateKey": true, - "vapidPublicKey": true, - "webhooksEnabled": true, -} - -// hiddenTopLevelDirs for FS browsing. -var wsHiddenTopLevelDirs = map[string]bool{ - "bin": true, "boot": true, "dev": true, "lib": true, - "lib32": true, "lib64": true, "libx32": true, - "proc": true, "run": true, "sbin": true, "sys": true, - "usr": true, "etc": true, "snap": true, "lost+found": true, -} - -// ── Response mappers ── - -func mapUser(u db.User) map[string]any { - return map[string]any{ - "id": u.ID, - "username": u.Username, - "role": u.Role, - "disabled": u.Disabled, - "systemsJson": wsNullStr(u.SystemsJson), - "expiration": wsNullInt(u.Expiration), - "limit": wsNullInt(u.Limit), - "createdAt": u.CreatedAt, - "updatedAt": u.UpdatedAt, - } -} - -func mapUsers(users []db.User) []map[string]any { - out := make([]map[string]any, len(users)) - for i, u := range users { - out[i] = mapUser(u) - } - return out -} - -func mapSystem(s db.System) map[string]any { - return map[string]any{ - "id": s.ID, - "systemId": s.SystemID, - "label": s.Label, - "autoPopulateTalkgroups": s.AutoPopulateTalkgroups, - "blacklistsJson": wsNullStr(s.BlacklistsJson), - "led": wsNullStr(s.Led), - "order": s.Order, - } -} - -func mapSystems(systems []db.System) []map[string]any { - out := make([]map[string]any, len(systems)) - for i, s := range systems { - out[i] = mapSystem(s) - } - return out -} - -func mapTalkgroup(t db.Talkgroup) map[string]any { - return map[string]any{ - "id": t.ID, - "systemId": t.SystemID, - "talkgroupId": t.TalkgroupID, - "label": wsNullStr(t.Label), - "name": wsNullStr(t.Name), - "frequency": wsNullInt(t.Frequency), - "led": wsNullStr(t.Led), - "groupId": wsNullInt(t.GroupID), - "tagId": wsNullInt(t.TagID), - "order": t.Order, - } -} - -func mapTalkgroups(tgs []db.Talkgroup) []map[string]any { - out := make([]map[string]any, len(tgs)) - for i, t := range tgs { - out[i] = mapTalkgroup(t) - } - return out -} - -func mapUnit(u db.Unit) map[string]any { - return map[string]any{ - "id": u.ID, - "systemId": u.SystemID, - "unitId": u.UnitID, - "label": wsNullStr(u.Label), - "order": u.Order, - } -} - -func mapUnits(units []db.Unit) []map[string]any { - out := make([]map[string]any, len(units)) - for i, u := range units { - out[i] = mapUnit(u) - } - return out -} - -func mapAPIKey(k db.ApiKey) map[string]any { - fingerprint := auth.HashAPIKey(k.Key) - if len(fingerprint) > 12 { - fingerprint = fingerprint[:12] - } - return map[string]any{ - "id": k.ID, - "fingerprint": fingerprint, - "ident": wsNullStr(k.Ident), - "disabled": k.Disabled, - "systemsJson": wsNullStr(k.SystemsJson), - "callRateLimit": wsNullInt(k.CallRateLimit), - "order": k.Order, - } -} - -func mapAPIKeys(keys []db.ApiKey) []map[string]any { - out := make([]map[string]any, len(keys)) - for i, k := range keys { - out[i] = mapAPIKey(k) - } - return out -} - -func mapDirMonitor(d db.Dirmonitor) map[string]any { - return map[string]any{ - "id": d.ID, - "directory": d.Directory, - "type": d.Type, - "mask": wsNullStr(d.Mask), - "extension": wsNullStr(d.Extension), - "frequency": wsNullInt(d.Frequency), - "delay": wsNullInt(d.Delay), - "deleteAfter": d.DeleteAfter, - "usePolling": d.UsePolling, - "disabled": d.Disabled, - "systemId": wsNullInt(d.SystemID), - "talkgroupId": wsNullInt(d.TalkgroupID), - "order": d.Order, - } -} - -func mapDirMonitors(dms []db.Dirmonitor) []map[string]any { - out := make([]map[string]any, len(dms)) - for i, d := range dms { - out[i] = mapDirMonitor(d) - } - return out -} - -func mapDownstream(d db.Downstream) map[string]any { - return map[string]any{ - "id": d.ID, - "url": d.Url, - "hasApiKey": d.ApiKey != "", - "systemsJson": wsNullStr(d.SystemsJson), - "disabled": d.Disabled, - "order": d.Order, - } -} - -func mapDownstreams(ds []db.Downstream) []map[string]any { - out := make([]map[string]any, len(ds)) - for i, d := range ds { - out[i] = mapDownstream(d) - } - return out -} - -func mapWebhook(w db.Webhook) map[string]any { - return map[string]any{ - "id": w.ID, - "url": w.Url, - "type": w.Type, - "secret": wsNullStr(w.Secret), - "systemsJson": wsNullStr(w.SystemsJson), - "disabled": w.Disabled, - "order": w.Order, - } -} - -func mapWebhooks(ws []db.Webhook) []map[string]any { - out := make([]map[string]any, len(ws)) - for i, w := range ws { - out[i] = mapWebhook(w) - } - return out -} - -func mapSharedLink(r db.ListSharedLinksRow) map[string]any { - m := map[string]any{ - "id": r.ID, - "callId": r.CallID, - "token": r.Token, - "createdAt": r.CreatedAt, - "sharedBy": r.SharedBy, - "dateTime": r.DateTime, - "duration": r.Duration.Int64, - "systemLabel": r.SystemLabel.String, - "talkgroupLabel": r.TalkgroupLabel.String, - "talkgroupName": r.TalkgroupName.String, - } - if r.ExpiresAt.Valid { - m["expiresAt"] = r.ExpiresAt.Int64 - } else { - m["expiresAt"] = nil - } - return m -} - -// ── Handler map ── - -// adminOpHandlers returns the complete map of supported admin WS operations. -func (c *Client) adminOpHandlers() map[string]adminOpHandler { - return map[string]adminOpHandler{ - // Activity & Logs (existing handlers in client.go) - "activity.stats": c.opActivityStats, - "activity.chart": c.opActivityChart, - "activity.top-talkgroups": c.opTopTalkgroups, - "logs.query": c.opLogsQuery, - "logs.level": c.opLogsLevel, - - // Users - "users.list": c.opUsersList, - "users.create": c.opUsersCreate, - "users.update": c.opUsersUpdate, - "users.delete": c.opUsersDelete, - - // Systems - "systems.list": c.opSystemsList, - "systems.create": c.opSystemsCreate, - "systems.update": c.opSystemsUpdate, - "systems.delete": c.opSystemsDelete, - - // Talkgroups - "talkgroups.list": c.opTalkgroupsList, - "talkgroups.create": c.opTalkgroupsCreate, - "talkgroups.update": c.opTalkgroupsUpdate, - "talkgroups.delete": c.opTalkgroupsDelete, - - // Units - "units.list": c.opUnitsList, - "units.create": c.opUnitsCreate, - "units.update": c.opUnitsUpdate, - "units.delete": c.opUnitsDelete, - - // Groups - "groups.list": c.opGroupsList, - "groups.create": c.opGroupsCreate, - "groups.update": c.opGroupsUpdate, - "groups.delete": c.opGroupsDelete, - - // Tags - "tags.list": c.opTagsList, - "tags.create": c.opTagsCreate, - "tags.update": c.opTagsUpdate, - "tags.delete": c.opTagsDelete, - - // API Keys - "apikeys.list": c.opAPIKeysList, - "apikeys.create": c.opAPIKeysCreate, - "apikeys.update": c.opAPIKeysUpdate, - "apikeys.delete": c.opAPIKeysDelete, - - // DirMonitors - "dirmonitors.list": c.opDirMonitorsList, - "dirmonitors.create": c.opDirMonitorsCreate, - "dirmonitors.update": c.opDirMonitorsUpdate, - "dirmonitors.delete": c.opDirMonitorsDelete, - - // Downstreams - "downstreams.list": c.opDownstreamsList, - "downstreams.create": c.opDownstreamsCreate, - "downstreams.update": c.opDownstreamsUpdate, - "downstreams.delete": c.opDownstreamsDelete, - - // Webhooks - "webhooks.list": c.opWebhooksList, - "webhooks.create": c.opWebhooksCreate, - "webhooks.update": c.opWebhooksUpdate, - "webhooks.delete": c.opWebhooksDelete, - - // Shared Links - "shared-links.list": c.opSharedLinksList, - "shared-links.delete": c.opSharedLinksDelete, - - // Config - "config.get": c.opConfigGet, - "config.update": c.opConfigUpdate, - - // Filesystem - "fs.directories": c.opFSDirectories, - - // Export - "export.config": c.opExportConfig, - "export.talkgroups": c.opExportTalkgroups, - "export.units": c.opExportUnits, - "export.groups": c.opExportGroups, - "export.tags": c.opExportTags, - - // Import - "import.config": c.opImportConfig, - - // RadioReference - "radioreference.apply": c.opRadioReferenceApply, - - // Transcription model management - "transcription.status": c.opTranscriptionStatus, - "transcription.models": c.opTranscriptionModels, - "transcription.download": c.opTranscriptionDownload, - "transcription.delete": c.opTranscriptionDelete, - "transcription.stats": c.opTranscriptionStats, - } -} - -// ── Logs level (moved from inline in client.go) ── - -func (c *Client) opLogsLevel(_ context.Context, _ json.RawMessage) (any, error) { - return map[string]string{"level": logging.GetLevel()}, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// USERS -// ══════════════════════════════════════════════════════════════════════════════ - -func (c *Client) opUsersList(ctx context.Context, _ json.RawMessage) (any, error) { - users, err := c.hub.queries.ListUsers(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list users: %w", err) - } - return mapUsers(users), nil -} - -func (c *Client) opUsersCreate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - Username string `json:"username"` - Password string `json:"password"` - Role string `json:"role"` - Disabled int64 `json:"disabled"` - SystemsJson *string `json:"systemsJson"` - Expiration *int64 `json:"expiration"` - Limit *int64 `json:"limit"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.Username == "" { - return nil, userError("username is required") - } - if len(req.Username) > 64 { - return nil, userError("username must be at most 64 characters") - } - if len(req.Password) < 8 { - return nil, userError("password must be at least 8 characters") - } - if len(req.Password) > 128 { - return nil, userError("password must be at most 128 characters") - } - if req.Role == "" { - req.Role = "listener" - } - if !wsValidRoles[req.Role] { - return nil, userError("role must be 'admin' or 'listener'") - } - - hash, err := auth.HashPassword(req.Password) - if err != nil { - return nil, fmt.Errorf("failed to hash password: %w", err) - } - - now := time.Now().Unix() - id, err := c.hub.queries.CreateUser(ctx, db.CreateUserParams{ - Username: req.Username, - PasswordHash: hash, - Role: req.Role, - Disabled: req.Disabled, - SystemsJson: wsPtrToNullStr(req.SystemsJson), - Expiration: wsPtrToNullInt(req.Expiration), - Limit: wsPtrToNullInt(req.Limit), - PasswordNeedChange: 1, - CreatedAt: now, - UpdatedAt: now, - }) - if wsIsUniqueViolation(err) { - return nil, userError("username already exists") - } - if err != nil { - return nil, fmt.Errorf("failed to create user: %w", err) - } - - user, err := c.hub.queries.GetUser(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to fetch created user: %w", err) - } - slog.Info("admin: user created", "id", user.ID, "username", user.Username, "role", user.Role, "by", c.userID) - c.hub.BroadcastAdminEvent("users.updated", nil) - return mapUser(user), nil -} - -func (c *Client) opUsersUpdate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - Username string `json:"username"` - Role string `json:"role"` - Disabled int64 `json:"disabled"` - SystemsJson *string `json:"systemsJson"` - Expiration *int64 `json:"expiration"` - Limit *int64 `json:"limit"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - if req.Username == "" { - return nil, userError("username is required") - } - if len(req.Username) > 64 { - return nil, userError("username must be at most 64 characters") - } - if req.Role == "" { - return nil, userError("role is required") - } - if !wsValidRoles[req.Role] { - return nil, userError("role must be 'admin' or 'listener'") - } - - if _, err := c.hub.queries.GetUser(ctx, req.ID); err != nil { - return nil, userError("user not found") - } - - // Prevent disabling the bootstrap admin (id=1). - if req.ID == 1 && req.Disabled != 0 { - return nil, userError("cannot disable the primary admin account") - } - // Protect bootstrap admin role/expiration/limit. - if req.ID == 1 { - req.Role = "admin" - req.Expiration = nil - req.Limit = nil - } - - err := c.hub.queries.UpdateUser(ctx, db.UpdateUserParams{ - ID: req.ID, - Username: req.Username, - Role: req.Role, - Disabled: req.Disabled, - SystemsJson: wsPtrToNullStr(req.SystemsJson), - Expiration: wsPtrToNullInt(req.Expiration), - Limit: wsPtrToNullInt(req.Limit), - UpdatedAt: time.Now().Unix(), - }) - if wsIsUniqueViolation(err) { - return nil, userError("username already exists") - } - if err != nil { - return nil, fmt.Errorf("failed to update user: %w", err) - } - - // Revoke all tokens so stale claims are not trusted after update. - auth.Tokens.RevokeAllForUser(req.ID) - - // Immediately disconnect all active WS sessions for the updated user. - c.hub.DisconnectByUser(req.ID) - - user, err := c.hub.queries.GetUser(ctx, req.ID) - if err != nil { - return nil, fmt.Errorf("failed to fetch updated user: %w", err) - } - slog.Info("admin: user updated", "id", user.ID, "username", user.Username, "role", user.Role, "disabled", user.Disabled, "by", c.userID) - c.hub.BroadcastAdminEvent("users.updated", nil) - return mapUser(user), nil -} - -func (c *Client) opUsersDelete(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - - // Cannot delete your own account. - if c.userID == req.ID { - return nil, userError("cannot delete your own account") - } - // Cannot delete bootstrap admin. - if req.ID == 1 { - return nil, userError("cannot delete the primary admin account") - } - - if _, err := c.hub.queries.GetUser(ctx, req.ID); err != nil { - return nil, userError("user not found") - } - - if err := c.hub.queries.DeleteUser(ctx, req.ID); err != nil { - return nil, fmt.Errorf("failed to delete user: %w", err) - } - - // Revoke tokens and disconnect active WS sessions for the deleted user. - auth.Tokens.RevokeAllForUser(req.ID) - c.hub.DisconnectByUser(req.ID) - - slog.Info("admin: user deleted", "id", req.ID, "by", c.userID) - c.hub.BroadcastAdminEvent("users.updated", nil) - return map[string]bool{"ok": true}, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// SYSTEMS -// ══════════════════════════════════════════════════════════════════════════════ - -func (c *Client) opSystemsList(ctx context.Context, _ json.RawMessage) (any, error) { - systems, err := c.hub.queries.ListSystems(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list systems: %w", err) - } - return mapSystems(systems), nil -} - -func (c *Client) opSystemsCreate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - SystemID int64 `json:"systemId"` - Label string `json:"label"` - AutoPopulateTalkgroups int64 `json:"autoPopulateTalkgroups"` - BlacklistsJson *string `json:"blacklistsJson"` - Led *string `json:"led"` - Order int64 `json:"order"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - - id, err := c.hub.queries.CreateSystem(ctx, db.CreateSystemParams{ - SystemID: req.SystemID, - Label: req.Label, - AutoPopulateTalkgroups: req.AutoPopulateTalkgroups, - BlacklistsJson: wsPtrToNullStr(req.BlacklistsJson), - Led: wsPtrToNullStr(req.Led), - Order: req.Order, - }) - if wsIsUniqueViolation(err) { - return nil, userError("system_id already exists") - } - if err != nil { - return nil, fmt.Errorf("failed to create system: %w", err) - } - - system, err := c.hub.queries.GetSystem(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to fetch created system: %w", err) - } - slog.Info("admin: system created", "id", system.ID, "system_id", system.SystemID, "label", system.Label, "by", c.userID) - c.hub.BroadcastAdminEvent("systems.updated", nil) - c.hub.BroadcastCFG(ctx) - return mapSystem(system), nil -} - -func (c *Client) opSystemsUpdate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - SystemID int64 `json:"systemId"` - Label string `json:"label"` - AutoPopulateTalkgroups int64 `json:"autoPopulateTalkgroups"` - BlacklistsJson *string `json:"blacklistsJson"` - Led *string `json:"led"` - Order int64 `json:"order"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - - if _, err := c.hub.queries.GetSystem(ctx, req.ID); err != nil { - return nil, userError("system not found") - } - - err := c.hub.queries.UpdateSystem(ctx, db.UpdateSystemParams{ - ID: req.ID, - SystemID: req.SystemID, - Label: req.Label, - AutoPopulateTalkgroups: req.AutoPopulateTalkgroups, - BlacklistsJson: wsPtrToNullStr(req.BlacklistsJson), - Led: wsPtrToNullStr(req.Led), - Order: req.Order, - }) - if wsIsUniqueViolation(err) { - return nil, userError("system_id already exists") - } - if err != nil { - return nil, fmt.Errorf("failed to update system: %w", err) - } - - system, err := c.hub.queries.GetSystem(ctx, req.ID) - if err != nil { - return nil, fmt.Errorf("failed to fetch updated system: %w", err) - } - slog.Info("admin: system updated", "id", system.ID, "system_id", system.SystemID, "by", c.userID) - c.hub.BroadcastAdminEvent("systems.updated", nil) - c.hub.BroadcastCFG(ctx) - return mapSystem(system), nil -} - -func (c *Client) opSystemsDelete(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - - if _, err := c.hub.queries.GetSystem(ctx, req.ID); err != nil { - return nil, userError("system not found") - } - - if err := c.hub.queries.DeleteSystem(ctx, req.ID); err != nil { - return nil, fmt.Errorf("failed to delete system: %w", err) - } - slog.Info("admin: system deleted", "id", req.ID, "by", c.userID) - c.hub.BroadcastAdminEvent("systems.updated", nil) - c.hub.BroadcastCFG(ctx) - return map[string]bool{"ok": true}, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// TALKGROUPS -// ══════════════════════════════════════════════════════════════════════════════ - -func (c *Client) opTalkgroupsList(ctx context.Context, _ json.RawMessage) (any, error) { - tgs, err := c.hub.queries.ListAllTalkgroups(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list talkgroups: %w", err) - } - return mapTalkgroups(tgs), nil -} - -func (c *Client) opTalkgroupsCreate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - SystemID int64 `json:"systemId"` - TalkgroupID int64 `json:"talkgroupId"` - Label *string `json:"label"` - Name *string `json:"name"` - Frequency *int64 `json:"frequency"` - Led *string `json:"led"` - GroupID *int64 `json:"groupId"` - TagID *int64 `json:"tagId"` - Order int64 `json:"order"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - - id, err := c.hub.queries.CreateTalkgroup(ctx, db.CreateTalkgroupParams{ - SystemID: req.SystemID, - TalkgroupID: req.TalkgroupID, - Label: wsPtrToNullStr(req.Label), - Name: wsPtrToNullStr(req.Name), - Frequency: wsPtrToNullInt(req.Frequency), - Led: wsPtrToNullStr(req.Led), - GroupID: wsPtrToNullInt(req.GroupID), - TagID: wsPtrToNullInt(req.TagID), - Order: req.Order, - }) - if wsIsUniqueViolation(err) { - return nil, userError("talkgroup already exists") - } - if err != nil { - return nil, fmt.Errorf("failed to create talkgroup: %w", err) - } - - tg, err := c.hub.queries.GetTalkgroup(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to fetch created talkgroup: %w", err) - } - slog.Info("admin: talkgroup created", "id", tg.ID, "talkgroup_id", tg.TalkgroupID, "by", c.userID) - c.hub.BroadcastAdminEvent("talkgroups.updated", nil) - c.hub.BroadcastCFG(ctx) - return mapTalkgroup(tg), nil -} - -func (c *Client) opTalkgroupsUpdate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - TalkgroupID int64 `json:"talkgroupId"` - Label *string `json:"label"` - Name *string `json:"name"` - Frequency *int64 `json:"frequency"` - Led *string `json:"led"` - GroupID *int64 `json:"groupId"` - TagID *int64 `json:"tagId"` - Order int64 `json:"order"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - - if _, err := c.hub.queries.GetTalkgroup(ctx, req.ID); err != nil { - return nil, userError("talkgroup not found") - } - - err := c.hub.queries.UpdateTalkgroup(ctx, db.UpdateTalkgroupParams{ - ID: req.ID, - TalkgroupID: req.TalkgroupID, - Label: wsPtrToNullStr(req.Label), - Name: wsPtrToNullStr(req.Name), - Frequency: wsPtrToNullInt(req.Frequency), - Led: wsPtrToNullStr(req.Led), - GroupID: wsPtrToNullInt(req.GroupID), - TagID: wsPtrToNullInt(req.TagID), - Order: req.Order, - }) - if wsIsUniqueViolation(err) { - return nil, userError("talkgroup already exists") - } - if err != nil { - return nil, fmt.Errorf("failed to update talkgroup: %w", err) - } - - tg, err := c.hub.queries.GetTalkgroup(ctx, req.ID) - if err != nil { - return nil, fmt.Errorf("failed to fetch updated talkgroup: %w", err) - } - slog.Info("admin: talkgroup updated", "id", tg.ID, "talkgroup_id", tg.TalkgroupID, "by", c.userID) - c.hub.BroadcastAdminEvent("talkgroups.updated", nil) - c.hub.BroadcastCFG(ctx) - return mapTalkgroup(tg), nil -} - -func (c *Client) opTalkgroupsDelete(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - - if _, err := c.hub.queries.GetTalkgroup(ctx, req.ID); err != nil { - return nil, userError("talkgroup not found") - } - - if err := c.hub.queries.DeleteTalkgroup(ctx, req.ID); err != nil { - return nil, fmt.Errorf("failed to delete talkgroup: %w", err) - } - slog.Info("admin: talkgroup deleted", "id", req.ID, "by", c.userID) - c.hub.BroadcastAdminEvent("talkgroups.updated", nil) - c.hub.BroadcastCFG(ctx) - return map[string]bool{"ok": true}, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// UNITS -// ══════════════════════════════════════════════════════════════════════════════ - -func (c *Client) opUnitsList(ctx context.Context, params json.RawMessage) (any, error) { - // Optional filter by systemId and unitId pattern. - var req struct { - SystemID *int64 `json:"systemId"` - UnitIDPattern *string `json:"unitIdPattern"` - } - if params != nil { - _ = json.Unmarshal(params, &req) // ignore parse errors — treat as no filter - } - - var units []db.Unit - var err error - if req.SystemID != nil { - units, err = c.hub.queries.ListUnitsBySystem(ctx, *req.SystemID) - } else { - units, err = c.hub.queries.ListAllUnits(ctx) - } - if err != nil { - return nil, fmt.Errorf("failed to list units: %w", err) - } - - // Apply unit_id pattern filter if provided (prefix matching). - if req.UnitIDPattern != nil && *req.UnitIDPattern != "" { - filtered := make([]db.Unit, 0, len(units)) - for _, u := range units { - if strings.HasPrefix(strconv.FormatInt(u.UnitID, 10), *req.UnitIDPattern) { - filtered = append(filtered, u) - } - } - units = filtered - } - - return mapUnits(units), nil -} - -func (c *Client) opUnitsCreate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - SystemID int64 `json:"systemId"` - UnitID int64 `json:"unitId"` - Label *string `json:"label"` - Order int64 `json:"order"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - - id, err := c.hub.queries.CreateUnit(ctx, db.CreateUnitParams{ - SystemID: req.SystemID, - UnitID: req.UnitID, - Label: wsPtrToNullStr(req.Label), - Order: req.Order, - }) - if wsIsUniqueViolation(err) { - return nil, userError("unit already exists") - } - if err != nil { - return nil, fmt.Errorf("failed to create unit: %w", err) - } - - unit, err := c.hub.queries.GetUnit(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to fetch created unit: %w", err) - } - slog.Info("admin: unit created", "id", unit.ID, "unit_id", unit.UnitID, "by", c.userID) - c.hub.BroadcastAdminEvent("units.updated", nil) - return mapUnit(unit), nil -} - -func (c *Client) opUnitsUpdate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - UnitID int64 `json:"unitId"` - Label *string `json:"label"` - Order int64 `json:"order"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - - if _, err := c.hub.queries.GetUnit(ctx, req.ID); err != nil { - return nil, userError("unit not found") - } - - err := c.hub.queries.UpdateUnit(ctx, db.UpdateUnitParams{ - ID: req.ID, - UnitID: req.UnitID, - Label: wsPtrToNullStr(req.Label), - Order: req.Order, - }) - if wsIsUniqueViolation(err) { - return nil, userError("unit already exists") - } - if err != nil { - return nil, fmt.Errorf("failed to update unit: %w", err) - } - - unit, err := c.hub.queries.GetUnit(ctx, req.ID) - if err != nil { - return nil, fmt.Errorf("failed to fetch updated unit: %w", err) - } - slog.Info("admin: unit updated", "id", unit.ID, "unit_id", unit.UnitID, "by", c.userID) - c.hub.BroadcastAdminEvent("units.updated", nil) - return mapUnit(unit), nil -} - -func (c *Client) opUnitsDelete(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - - if _, err := c.hub.queries.GetUnit(ctx, req.ID); err != nil { - return nil, userError("unit not found") - } - - if err := c.hub.queries.DeleteUnit(ctx, req.ID); err != nil { - return nil, fmt.Errorf("failed to delete unit: %w", err) - } - slog.Info("admin: unit deleted", "id", req.ID, "by", c.userID) - c.hub.BroadcastAdminEvent("units.updated", nil) - return map[string]bool{"ok": true}, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// GROUPS -// ══════════════════════════════════════════════════════════════════════════════ - -func (c *Client) opGroupsList(ctx context.Context, _ json.RawMessage) (any, error) { - groups, err := c.hub.queries.ListGroups(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list groups: %w", err) - } - return groups, nil -} - -func (c *Client) opGroupsCreate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - Label string `json:"label"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.Label == "" { - return nil, userError("label is required") - } - - id, err := c.hub.queries.CreateGroup(ctx, req.Label) - if wsIsUniqueViolation(err) { - return nil, userError("group label already exists") - } - if err != nil { - return nil, fmt.Errorf("failed to create group: %w", err) - } - - group, err := c.hub.queries.GetGroup(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to fetch created group: %w", err) - } - slog.Info("admin: group created", "id", group.ID, "label", group.Label, "by", c.userID) - c.hub.BroadcastAdminEvent("groups.updated", nil) - c.hub.BroadcastCFG(ctx) - return group, nil -} - -func (c *Client) opGroupsUpdate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - Label string `json:"label"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - if req.Label == "" { - return nil, userError("label is required") - } - - if _, err := c.hub.queries.GetGroup(ctx, req.ID); err != nil { - return nil, userError("group not found") - } - - err := c.hub.queries.UpdateGroup(ctx, db.UpdateGroupParams{ID: req.ID, Label: req.Label}) - if wsIsUniqueViolation(err) { - return nil, userError("group label already exists") - } - if err != nil { - return nil, fmt.Errorf("failed to update group: %w", err) - } - - group, err := c.hub.queries.GetGroup(ctx, req.ID) - if err != nil { - return nil, fmt.Errorf("failed to fetch updated group: %w", err) - } - slog.Info("admin: group updated", "id", group.ID, "label", group.Label, "by", c.userID) - c.hub.BroadcastAdminEvent("groups.updated", nil) - c.hub.BroadcastCFG(ctx) - return group, nil -} - -func (c *Client) opGroupsDelete(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - - if _, err := c.hub.queries.GetGroup(ctx, req.ID); err != nil { - return nil, userError("group not found") - } - - if err := c.hub.queries.DeleteGroup(ctx, req.ID); err != nil { - return nil, fmt.Errorf("failed to delete group: %w", err) - } - slog.Info("admin: group deleted", "id", req.ID, "by", c.userID) - c.hub.BroadcastAdminEvent("groups.updated", nil) - c.hub.BroadcastCFG(ctx) - return map[string]bool{"ok": true}, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// TAGS -// ══════════════════════════════════════════════════════════════════════════════ - -func (c *Client) opTagsList(ctx context.Context, _ json.RawMessage) (any, error) { - tags, err := c.hub.queries.ListTags(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list tags: %w", err) - } - return tags, nil -} - -func (c *Client) opTagsCreate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - Label string `json:"label"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.Label == "" { - return nil, userError("label is required") - } - - id, err := c.hub.queries.CreateTag(ctx, req.Label) - if wsIsUniqueViolation(err) { - return nil, userError("tag label already exists") - } - if err != nil { - return nil, fmt.Errorf("failed to create tag: %w", err) - } - - tag, err := c.hub.queries.GetTag(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to fetch created tag: %w", err) - } - slog.Info("admin: tag created", "id", tag.ID, "label", tag.Label, "by", c.userID) - c.hub.BroadcastAdminEvent("tags.updated", nil) - c.hub.BroadcastCFG(ctx) - return tag, nil -} - -func (c *Client) opTagsUpdate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - Label string `json:"label"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - if req.Label == "" { - return nil, userError("label is required") - } - - if _, err := c.hub.queries.GetTag(ctx, req.ID); err != nil { - return nil, userError("tag not found") - } - - err := c.hub.queries.UpdateTag(ctx, db.UpdateTagParams{ID: req.ID, Label: req.Label}) - if wsIsUniqueViolation(err) { - return nil, userError("tag label already exists") - } - if err != nil { - return nil, fmt.Errorf("failed to update tag: %w", err) - } - - tag, err := c.hub.queries.GetTag(ctx, req.ID) - if err != nil { - return nil, fmt.Errorf("failed to fetch updated tag: %w", err) - } - slog.Info("admin: tag updated", "id", tag.ID, "label", tag.Label, "by", c.userID) - c.hub.BroadcastAdminEvent("tags.updated", nil) - c.hub.BroadcastCFG(ctx) - return tag, nil -} - -func (c *Client) opTagsDelete(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - - if _, err := c.hub.queries.GetTag(ctx, req.ID); err != nil { - return nil, userError("tag not found") - } - - if err := c.hub.queries.DeleteTag(ctx, req.ID); err != nil { - return nil, fmt.Errorf("failed to delete tag: %w", err) - } - slog.Info("admin: tag deleted", "id", req.ID, "by", c.userID) - c.hub.BroadcastAdminEvent("tags.updated", nil) - c.hub.BroadcastCFG(ctx) - return map[string]bool{"ok": true}, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// API KEYS -// ══════════════════════════════════════════════════════════════════════════════ - -func (c *Client) opAPIKeysList(ctx context.Context, _ json.RawMessage) (any, error) { - keys, err := c.hub.queries.ListAPIKeys(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list API keys: %w", err) - } - return mapAPIKeys(keys), nil -} - -func (c *Client) opAPIKeysCreate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - Key *string `json:"key"` - Ident *string `json:"ident"` - Disabled int64 `json:"disabled"` - SystemsJson *string `json:"systemsJson"` - CallRateLimit *int64 `json:"callRateLimit"` - Order int64 `json:"order"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - - plainKey := uuid.New().String() - if req.Key != nil && *req.Key != "" { - plainKey = *req.Key - } - hashedKey := auth.HashAPIKey(plainKey) - - id, err := c.hub.queries.CreateAPIKey(ctx, db.CreateAPIKeyParams{ - Key: hashedKey, - Ident: wsPtrToNullStr(req.Ident), - Disabled: req.Disabled, - SystemsJson: wsPtrToNullStr(req.SystemsJson), - CallRateLimit: wsPtrToNullInt(req.CallRateLimit), - Order: req.Order, - }) - if wsIsUniqueViolation(err) { - return nil, userError("API key already exists") - } - if err != nil { - return nil, fmt.Errorf("failed to create API key: %w", err) - } - - key, err := c.hub.queries.GetAPIKey(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to fetch created API key: %w", err) - } - slog.Info("admin: api key created", "id", key.ID, "ident", key.Ident.String, "by", c.userID) - c.hub.BroadcastAdminEvent("apikeys.updated", nil) - - resp := mapAPIKey(key) - resp["createdKey"] = plainKey // Return plain key once on creation. - return resp, nil -} - -func (c *Client) opAPIKeysUpdate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - Key *string `json:"key"` - Ident *string `json:"ident"` - Disabled int64 `json:"disabled"` - SystemsJson *string `json:"systemsJson"` - CallRateLimit *int64 `json:"callRateLimit"` - Order int64 `json:"order"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - - current, err := c.hub.queries.GetAPIKey(ctx, req.ID) - if err != nil { - return nil, userError("API key not found") - } - - keyHash := current.Key - if req.Key != nil && *req.Key != "" { - keyHash = auth.HashAPIKey(*req.Key) - } - - err = c.hub.queries.UpdateAPIKey(ctx, db.UpdateAPIKeyParams{ - ID: req.ID, - Key: keyHash, - Ident: wsPtrToNullStr(req.Ident), - Disabled: req.Disabled, - SystemsJson: wsPtrToNullStr(req.SystemsJson), - CallRateLimit: wsPtrToNullInt(req.CallRateLimit), - Order: req.Order, - }) - if wsIsUniqueViolation(err) { - return nil, userError("API key already exists") - } - if err != nil { - return nil, fmt.Errorf("failed to update API key: %w", err) - } - - key, err := c.hub.queries.GetAPIKey(ctx, req.ID) - if err != nil { - return nil, fmt.Errorf("failed to fetch updated API key: %w", err) - } - slog.Info("admin: api key updated", "id", key.ID, "ident", key.Ident.String, "by", c.userID) - c.hub.BroadcastAdminEvent("apikeys.updated", nil) - return mapAPIKey(key), nil -} - -func (c *Client) opAPIKeysDelete(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - - if _, err := c.hub.queries.GetAPIKey(ctx, req.ID); err != nil { - return nil, userError("API key not found") - } - - if err := c.hub.queries.DeleteAPIKey(ctx, req.ID); err != nil { - return nil, fmt.Errorf("failed to delete API key: %w", err) - } - slog.Info("admin: api key deleted", "id", req.ID, "by", c.userID) - c.hub.BroadcastAdminEvent("apikeys.updated", nil) - return map[string]bool{"ok": true}, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// DIRMONITORS -// ══════════════════════════════════════════════════════════════════════════════ - -func (c *Client) opDirMonitorsList(ctx context.Context, _ json.RawMessage) (any, error) { - dms, err := c.hub.queries.ListDirMonitors(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list dirmonitors: %w", err) - } - return mapDirMonitors(dms), nil -} - -func (c *Client) opDirMonitorsCreate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - Directory string `json:"directory"` - Type string `json:"type"` - Mask *string `json:"mask"` - Extension *string `json:"extension"` - Frequency *int64 `json:"frequency"` - Delay *int64 `json:"delay"` - DeleteAfter int64 `json:"deleteAfter"` - UsePolling int64 `json:"usePolling"` - Disabled int64 `json:"disabled"` - SystemID *int64 `json:"systemId"` - TalkgroupID *int64 `json:"talkgroupId"` - Order int64 `json:"order"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.Directory == "" { - return nil, userError("directory is required") - } - if info, statErr := os.Stat(req.Directory); statErr != nil { - return nil, userError("directory does not exist or is not accessible: " + statErr.Error()) - } else if !info.IsDir() { - return nil, userError("path is not a directory: " + req.Directory) - } - - id, err := c.hub.queries.CreateDirMonitor(ctx, db.CreateDirMonitorParams{ - Directory: req.Directory, - Type: req.Type, - Mask: wsPtrToNullStr(req.Mask), - Extension: wsPtrToNullStr(req.Extension), - Frequency: wsPtrToNullInt(req.Frequency), - Delay: wsPtrToNullInt(req.Delay), - DeleteAfter: req.DeleteAfter, - UsePolling: req.UsePolling, - Disabled: req.Disabled, - SystemID: wsPtrToNullInt(req.SystemID), - TalkgroupID: wsPtrToNullInt(req.TalkgroupID), - Order: req.Order, - }) - if err != nil { - return nil, fmt.Errorf("failed to create dirmonitor: %w", err) - } - - dm, err := c.hub.queries.GetDirMonitor(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to fetch created dirmonitor: %w", err) - } - if c.hub.deps.DirMonitorReload != nil { - c.hub.deps.DirMonitorReload.Reload() - } - slog.Info("admin: dirmonitor created", "id", dm.ID, "dir", dm.Directory, "by", c.userID) - c.hub.BroadcastAdminEvent("dirmonitors.updated", nil) - return mapDirMonitor(dm), nil -} - -func (c *Client) opDirMonitorsUpdate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - Directory string `json:"directory"` - Type string `json:"type"` - Mask *string `json:"mask"` - Extension *string `json:"extension"` - Frequency *int64 `json:"frequency"` - Delay *int64 `json:"delay"` - DeleteAfter int64 `json:"deleteAfter"` - UsePolling int64 `json:"usePolling"` - Disabled int64 `json:"disabled"` - SystemID *int64 `json:"systemId"` - TalkgroupID *int64 `json:"talkgroupId"` - Order int64 `json:"order"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - if req.Directory == "" { - return nil, userError("directory is required") - } - if info, statErr := os.Stat(req.Directory); statErr != nil { - return nil, userError("directory does not exist or is not accessible: " + statErr.Error()) - } else if !info.IsDir() { - return nil, userError("path is not a directory: " + req.Directory) - } - - if _, err := c.hub.queries.GetDirMonitor(ctx, req.ID); err != nil { - return nil, userError("dirmonitor not found") - } - - if err := c.hub.queries.UpdateDirMonitor(ctx, db.UpdateDirMonitorParams{ - ID: req.ID, - Directory: req.Directory, - Type: req.Type, - Mask: wsPtrToNullStr(req.Mask), - Extension: wsPtrToNullStr(req.Extension), - Frequency: wsPtrToNullInt(req.Frequency), - Delay: wsPtrToNullInt(req.Delay), - DeleteAfter: req.DeleteAfter, - UsePolling: req.UsePolling, - Disabled: req.Disabled, - SystemID: wsPtrToNullInt(req.SystemID), - TalkgroupID: wsPtrToNullInt(req.TalkgroupID), - Order: req.Order, - }); err != nil { - return nil, fmt.Errorf("failed to update dirmonitor: %w", err) - } - - dm, err := c.hub.queries.GetDirMonitor(ctx, req.ID) - if err != nil { - return nil, fmt.Errorf("failed to fetch updated dirmonitor: %w", err) - } - if c.hub.deps.DirMonitorReload != nil { - c.hub.deps.DirMonitorReload.Reload() - } - slog.Info("admin: dirmonitor updated", "id", dm.ID, "dir", dm.Directory, "by", c.userID) - c.hub.BroadcastAdminEvent("dirmonitors.updated", nil) - return mapDirMonitor(dm), nil -} - -func (c *Client) opDirMonitorsDelete(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - - if _, err := c.hub.queries.GetDirMonitor(ctx, req.ID); err != nil { - return nil, userError("dirmonitor not found") - } - - if err := c.hub.queries.DeleteDirMonitor(ctx, req.ID); err != nil { - return nil, fmt.Errorf("failed to delete dirmonitor: %w", err) - } - if c.hub.deps.DirMonitorReload != nil { - c.hub.deps.DirMonitorReload.Reload() - } - slog.Info("admin: dirmonitor deleted", "id", req.ID, "by", c.userID) - c.hub.BroadcastAdminEvent("dirmonitors.updated", nil) - return map[string]bool{"ok": true}, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// DOWNSTREAMS -// ══════════════════════════════════════════════════════════════════════════════ - -func (c *Client) opDownstreamsList(ctx context.Context, _ json.RawMessage) (any, error) { - ds, err := c.hub.queries.ListDownstreams(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list downstreams: %w", err) - } - return mapDownstreams(ds), nil -} - -func (c *Client) opDownstreamsCreate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - Url string `json:"url"` - ApiKey string `json:"apiKey"` - SystemsJson *string `json:"systemsJson"` - Disabled int64 `json:"disabled"` - Order int64 `json:"order"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.Url == "" { - return nil, userError("url is required") - } - if !wsValidHTTPURL(req.Url) { - return nil, userError("url must use http or https scheme") - } - - apiKey := req.ApiKey - if c.hub.deps.EncryptionKey != "" && apiKey != "" { - enc, err := auth.EncryptString(apiKey, c.hub.deps.EncryptionKey) - if err != nil { - return nil, fmt.Errorf("encrypt downstream API key: %w", err) - } - apiKey = enc - } - - id, err := c.hub.queries.CreateDownstream(ctx, db.CreateDownstreamParams{ - Url: req.Url, - ApiKey: apiKey, - SystemsJson: wsPtrToNullStr(req.SystemsJson), - Disabled: req.Disabled, - Order: req.Order, - }) - if err != nil { - return nil, fmt.Errorf("failed to create downstream: %w", err) - } - - ds, err := c.hub.queries.GetDownstream(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to fetch created downstream: %w", err) - } - if c.hub.deps.DownstreamReload != nil { - c.hub.deps.DownstreamReload.Reload() - } - slog.Info("admin: downstream created", "id", ds.ID, "url", ds.Url, "by", c.userID) - c.hub.BroadcastAdminEvent("downstreams.updated", nil) - return mapDownstream(ds), nil -} - -func (c *Client) opDownstreamsUpdate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - Url string `json:"url"` - ApiKey string `json:"apiKey"` - SystemsJson *string `json:"systemsJson"` - Disabled int64 `json:"disabled"` - Order int64 `json:"order"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - if req.Url != "" && !wsValidHTTPURL(req.Url) { - return nil, userError("url must use http or https scheme") - } - - existing, err := c.hub.queries.GetDownstream(ctx, req.ID) - if err != nil { - return nil, userError("downstream not found") - } - - // Preserve existing API key if none provided (key is never sent to clients). - apiKey := existing.ApiKey - if req.ApiKey != "" { - if c.hub.deps.EncryptionKey != "" { - enc, err := auth.EncryptString(req.ApiKey, c.hub.deps.EncryptionKey) - if err != nil { - return nil, fmt.Errorf("encrypt downstream API key: %w", err) - } - apiKey = enc - } else { - apiKey = req.ApiKey - } - } - - if err := c.hub.queries.UpdateDownstream(ctx, db.UpdateDownstreamParams{ - ID: req.ID, - Url: req.Url, - ApiKey: apiKey, - SystemsJson: wsPtrToNullStr(req.SystemsJson), - Disabled: req.Disabled, - Order: req.Order, - }); err != nil { - return nil, fmt.Errorf("failed to update downstream: %w", err) - } - - ds, err := c.hub.queries.GetDownstream(ctx, req.ID) - if err != nil { - return nil, fmt.Errorf("failed to fetch updated downstream: %w", err) - } - if c.hub.deps.DownstreamReload != nil { - c.hub.deps.DownstreamReload.Reload() - } - slog.Info("admin: downstream updated", "id", ds.ID, "url", ds.Url, "by", c.userID) - c.hub.BroadcastAdminEvent("downstreams.updated", nil) - return mapDownstream(ds), nil -} - -func (c *Client) opDownstreamsDelete(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - - if _, err := c.hub.queries.GetDownstream(ctx, req.ID); err != nil { - return nil, userError("downstream not found") - } - - if err := c.hub.queries.DeleteDownstream(ctx, req.ID); err != nil { - return nil, fmt.Errorf("failed to delete downstream: %w", err) - } - if c.hub.deps.DownstreamReload != nil { - c.hub.deps.DownstreamReload.Reload() - } - slog.Info("admin: downstream deleted", "id", req.ID, "by", c.userID) - c.hub.BroadcastAdminEvent("downstreams.updated", nil) - return map[string]bool{"ok": true}, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// WEBHOOKS -// ══════════════════════════════════════════════════════════════════════════════ - -func (c *Client) opWebhooksList(ctx context.Context, _ json.RawMessage) (any, error) { - whs, err := c.hub.queries.ListWebhooks(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list webhooks: %w", err) - } - return mapWebhooks(whs), nil -} - -func (c *Client) opWebhooksCreate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - Url string `json:"url"` - Type string `json:"type"` - Secret *string `json:"secret"` - SystemsJson *string `json:"systemsJson"` - Disabled int64 `json:"disabled"` - Order int64 `json:"order"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.Url == "" { - return nil, userError("url is required") - } - if !wsValidHTTPURL(req.Url) { - return nil, userError("url must use http or https scheme") - } - - id, err := c.hub.queries.CreateWebhook(ctx, db.CreateWebhookParams{ - Url: req.Url, - Type: req.Type, - Secret: wsPtrToNullStr(req.Secret), - SystemsJson: wsPtrToNullStr(req.SystemsJson), - Disabled: req.Disabled, - Order: req.Order, - }) - if err != nil { - return nil, fmt.Errorf("failed to create webhook: %w", err) - } - - wh, err := c.hub.queries.GetWebhook(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to fetch created webhook: %w", err) - } - slog.Info("admin: webhook created", "id", wh.ID, "url", wh.Url, "by", c.userID) - c.hub.BroadcastAdminEvent("webhooks.updated", nil) - return mapWebhook(wh), nil -} - -func (c *Client) opWebhooksUpdate(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - Url string `json:"url"` - Type string `json:"type"` - Secret *string `json:"secret"` - SystemsJson *string `json:"systemsJson"` - Disabled int64 `json:"disabled"` - Order int64 `json:"order"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - if req.Url != "" && !wsValidHTTPURL(req.Url) { - return nil, userError("url must use http or https scheme") - } - - if _, err := c.hub.queries.GetWebhook(ctx, req.ID); err != nil { - return nil, userError("webhook not found") - } - - if err := c.hub.queries.UpdateWebhook(ctx, db.UpdateWebhookParams{ - ID: req.ID, - Url: req.Url, - Type: req.Type, - Secret: wsPtrToNullStr(req.Secret), - SystemsJson: wsPtrToNullStr(req.SystemsJson), - Disabled: req.Disabled, - Order: req.Order, - }); err != nil { - return nil, fmt.Errorf("failed to update webhook: %w", err) - } - - wh, err := c.hub.queries.GetWebhook(ctx, req.ID) - if err != nil { - return nil, fmt.Errorf("failed to fetch updated webhook: %w", err) - } - slog.Info("admin: webhook updated", "id", wh.ID, "url", wh.Url, "by", c.userID) - c.hub.BroadcastAdminEvent("webhooks.updated", nil) - return mapWebhook(wh), nil -} - -func (c *Client) opWebhooksDelete(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - - if _, err := c.hub.queries.GetWebhook(ctx, req.ID); err != nil { - return nil, userError("webhook not found") - } - - if err := c.hub.queries.DeleteWebhook(ctx, req.ID); err != nil { - return nil, fmt.Errorf("failed to delete webhook: %w", err) - } - slog.Info("admin: webhook deleted", "id", req.ID, "by", c.userID) - c.hub.BroadcastAdminEvent("webhooks.updated", nil) - return map[string]bool{"ok": true}, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// SHARED LINKS -// ══════════════════════════════════════════════════════════════════════════════ - -func (c *Client) opSharedLinksList(ctx context.Context, _ json.RawMessage) (any, error) { - rows, err := c.hub.queries.ListSharedLinks(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list shared links: %w", err) - } - items := make([]map[string]any, 0, len(rows)) - for _, r := range rows { - items = append(items, mapSharedLink(r)) - } - return items, nil -} - -func (c *Client) opSharedLinksDelete(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID int64 `json:"id"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID <= 0 { - return nil, userError("id is required") - } - - if err := c.hub.queries.DeleteSharedLink(ctx, req.ID); err != nil { - return nil, fmt.Errorf("failed to delete shared link: %w", err) - } - c.hub.BroadcastAdminEvent("shared-links.updated", nil) - return map[string]bool{"deleted": true}, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// CONFIG -// ══════════════════════════════════════════════════════════════════════════════ - -func (c *Client) opConfigGet(ctx context.Context, _ json.RawMessage) (any, error) { - settings, err := c.hub.queries.ListSettings(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list settings: %w", err) - } - - settingsList := make([]map[string]string, len(settings)) - for i, s := range settings { - val := s.Value - if SensitiveSettingKeys[s.Key] && c.hub.deps.EncryptionKey != "" { - if plain, err := auth.DecryptString(val, c.hub.deps.EncryptionKey); err == nil { - val = plain - } - } - settingsList[i] = map[string]string{"key": s.Key, "value": val} - } - - return map[string]any{ - "settings": settingsList, - "capabilities": map[string]bool{ - "ffmpeg": c.hub.deps.FFmpegAvailable, - "fdkAac": c.hub.deps.FDKAACAvailable, - "whisper": c.hub.deps.WhisperAvailable, - }, - }, nil -} - -func (c *Client) opConfigUpdate(ctx context.Context, params json.RawMessage) (any, error) { - var body struct { - Settings []struct { - Key string `json:"key"` - Value string `json:"value"` - } `json:"settings"` - } - if err := json.Unmarshal(params, &body); err != nil { - return nil, userError("invalid request body") - } - settings := body.Settings - - // Validate all keys first. - for _, s := range settings { - if !wsAllowedSettingKeys[s.Key] { - return nil, userError("unknown setting key: " + s.Key) - } - if s.Key == "logLevel" { - if _, ok := logging.ParseLevel(s.Value); !ok { - return nil, userError("invalid logLevel; expected debug, info, warn, or error") - } - } - if s.Key == "audioEncodingPreset" { - if !audio.IsValidEncodingPreset(s.Value) { - return nil, userError("invalid audioEncodingPreset value") - } - if audio.IsHEEncodingPreset(s.Value) && !c.hub.deps.FDKAACAvailable { - return nil, userError("selected HE-AAC preset requires libfdk_aac support in ffmpeg") - } - } - if s.Key == "audioConversion" { - if v, err := strconv.Atoi(s.Value); err == nil && v != 0 && !c.hub.deps.FFmpegAvailable { - return nil, userError("ffmpeg is not installed — install it and restart the service to enable audio conversion") - } - } - } - - sqlDB := c.hub.deps.SQLDB - if sqlDB == nil { - return nil, fmt.Errorf("transaction support not available") - } - - tx, err := sqlDB.BeginTx(ctx, nil) - if err != nil { - return nil, fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback() //nolint:errcheck - - qtx := c.hub.queries.WithTx(tx) - for _, s := range settings { - val := s.Value - if SensitiveSettingKeys[s.Key] && c.hub.deps.EncryptionKey != "" && val != "" { - enc, err := auth.EncryptString(val, c.hub.deps.EncryptionKey) - if err != nil { - return nil, fmt.Errorf("encrypt setting %q: %w", s.Key, err) - } - val = enc - } - if err := qtx.UpsertSetting(ctx, db.UpsertSettingParams{Key: s.Key, Value: val}); err != nil { - return nil, fmt.Errorf("failed to save config: %w", err) - } - } - - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("failed to commit config: %w", err) - } - - // Log each changed setting, redacting sensitive keys. - for _, s := range settings { - v := s.Value - if s.Key == "vapidPrivateKey" { - v = "[REDACTED]" - } - slog.Info("admin: config updated", "key", s.Key, "value", v, "by", c.userID) - } - - // Apply log level change at runtime. - for _, s := range settings { - if s.Key == "logLevel" { - if err := logging.SetLevel(s.Value); err != nil { - slog.Warn("invalid logLevel setting, keeping previous runtime level", "value", s.Value, "error", err) - } - break - } - } - - // Hot-reload transcription if any transcription setting changed. - if c.hub.deps.TranscriberReload != nil { - transcriptionKeys := map[string]bool{ - "transcriptionEnabled": true, - "transcriptionUrl": true, - "transcriptionModel": true, - "transcriptionLanguage": true, - "transcriptionDiarize": true, - } - needsReload := false - for _, s := range settings { - if transcriptionKeys[s.Key] { - needsReload = true - break - } - } - if needsReload { - // Read current settings from DB (just committed). - tEnabled, _ := c.hub.queries.GetSetting(ctx, "transcriptionEnabled") - tURL, _ := c.hub.queries.GetSetting(ctx, "transcriptionUrl") - tModel, _ := c.hub.queries.GetSetting(ctx, "transcriptionModel") - tLang, _ := c.hub.queries.GetSetting(ctx, "transcriptionLanguage") - tDiarize, _ := c.hub.queries.GetSetting(ctx, "transcriptionDiarize") - - ok := c.hub.deps.TranscriberReload.Reload( - tEnabled.Value == "true", - tURL.Value, - tModel.Value, - tLang.Value, - tDiarize.Value == "true", - ) - c.hub.deps.WhisperAvailable = ok && tEnabled.Value == "true" - } - } - - // Broadcast updated config to all WS clients using the safe, - // curated CFG builder (excludes secrets like VAPID keys). - c.hub.BroadcastCFG(ctx) - - c.hub.BroadcastAdminEvent("config.updated", nil) - return map[string]bool{"ok": true}, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// FILESYSTEM -// ══════════════════════════════════════════════════════════════════════════════ - -func (c *Client) opFSDirectories(_ context.Context, params json.RawMessage) (any, error) { - var req struct { - Path string `json:"path"` - } - if params != nil { - _ = json.Unmarshal(params, &req) - } - if req.Path == "" { - req.Path = "/" - } - - clean := filepath.Clean(req.Path) - if !filepath.IsAbs(clean) { - return nil, userError("path must be absolute") - } - - info, err := os.Stat(clean) - if err != nil { - return nil, userError("directory does not exist or is not accessible: " + err.Error()) - } - if !info.IsDir() { - return nil, userError("path is not a directory: " + clean) - } - - entries, err := os.ReadDir(clean) - if err != nil { - return nil, userError("failed to read directory: " + err.Error()) - } - - type dirEntry struct { - Name string `json:"name"` - Path string `json:"path"` - } - - dirs := make([]dirEntry, 0, len(entries)) - for _, e := range entries { - if !e.IsDir() { - continue - } - name := e.Name() - if clean == "/" && wsHiddenTopLevelDirs[name] { - continue - } - if strings.HasPrefix(name, ".") { - continue - } - dirs = append(dirs, dirEntry{Name: name, Path: filepath.Join(clean, name)}) - } - sort.Slice(dirs, func(i, j int) bool { - return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) - }) - - var parent *string - if clean != "/" { - p := filepath.Dir(clean) - parent = &p - } - - return map[string]any{ - "path": clean, - "parent": parent, - "directories": dirs, - }, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// EXPORT -// ══════════════════════════════════════════════════════════════════════════════ - -func (c *Client) opExportConfig(ctx context.Context, _ json.RawMessage) (any, error) { - settings, err := c.hub.queries.ListSettings(ctx) - if err != nil { - return nil, fmt.Errorf("failed to export settings: %w", err) - } - users, err := c.hub.queries.ListUsers(ctx) - if err != nil { - return nil, fmt.Errorf("failed to export users: %w", err) - } - systems, err := c.hub.queries.ListSystems(ctx) - if err != nil { - return nil, fmt.Errorf("failed to export systems: %w", err) - } - talkgroups, err := c.hub.queries.ListAllTalkgroups(ctx) - if err != nil { - return nil, fmt.Errorf("failed to export talkgroups: %w", err) - } - units, err := c.hub.queries.ListAllUnits(ctx) - if err != nil { - return nil, fmt.Errorf("failed to export units: %w", err) - } - groups, err := c.hub.queries.ListGroups(ctx) - if err != nil { - return nil, fmt.Errorf("failed to export groups: %w", err) - } - tags, err := c.hub.queries.ListTags(ctx) - if err != nil { - return nil, fmt.Errorf("failed to export tags: %w", err) - } - apiKeys, err := c.hub.queries.ListAPIKeys(ctx) - if err != nil { - return nil, fmt.Errorf("failed to export api keys: %w", err) - } - dirmonitors, err := c.hub.queries.ListDirMonitors(ctx) - if err != nil { - return nil, fmt.Errorf("failed to export dirmonitors: %w", err) - } - downstreams, err := c.hub.queries.ListDownstreams(ctx) - if err != nil { - return nil, fmt.Errorf("failed to export downstreams: %w", err) - } - webhooks, err := c.hub.queries.ListWebhooks(ctx) - if err != nil { - return nil, fmt.Errorf("failed to export webhooks: %w", err) - } - - // Export all fields — use snake_case keys to match db struct JSON tags. - // API keys include the hashed key so import can restore authentication. - // Downstream API keys and webhook secrets are included for full backup. - // The exported JSON file should be treated as sensitive. - exportAPIKeys := make([]map[string]any, len(apiKeys)) - for i, k := range apiKeys { - exportAPIKeys[i] = map[string]any{ - "id": k.ID, - "key": k.Key, - "ident": wsNullStr(k.Ident), - "disabled": k.Disabled, - "systems_json": wsNullStr(k.SystemsJson), - "call_rate_limit": wsNullInt(k.CallRateLimit), - "order": k.Order, - } - } - exportDownstreams := make([]map[string]any, len(downstreams)) - for i, d := range downstreams { - exportDownstreams[i] = map[string]any{ - "id": d.ID, - "url": d.Url, - "api_key": d.ApiKey, - "systems_json": wsNullStr(d.SystemsJson), - "disabled": d.Disabled, - "order": d.Order, - } - } - exportWebhooks := make([]map[string]any, len(webhooks)) - for i, w := range webhooks { - exportWebhooks[i] = map[string]any{ - "id": w.ID, - "url": w.Url, - "type": w.Type, - "secret": wsNullStr(w.Secret), - "systems_json": wsNullStr(w.SystemsJson), - "disabled": w.Disabled, - "order": w.Order, - } - } - - return map[string]any{ - "settings": settings, - "users": users, - "systems": systems, - "talkgroups": talkgroups, - "units": units, - "groups": groups, - "tags": tags, - "apiKeys": exportAPIKeys, - "dirmonitors": dirmonitors, - "downstreams": exportDownstreams, - "webhooks": exportWebhooks, - }, nil -} - -func (c *Client) opExportTalkgroups(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - SystemID *int64 `json:"systemId"` - } - if params != nil { - _ = json.Unmarshal(params, &req) - } - if req.SystemID == nil { - return nil, fmt.Errorf("systemId is required") - } - - talkgroups, err := c.hub.queries.ListTalkgroupsBySystem(ctx, *req.SystemID) - if err != nil { - return nil, fmt.Errorf("failed to list talkgroups: %w", err) - } - - // Build ID→label maps so we can emit portable text names instead of - // PK integers (PKs are not stable across instances). - groupMap := make(map[int64]string) - if gs, err := c.hub.queries.ListGroups(ctx); err == nil { - for _, g := range gs { - groupMap[g.ID] = g.Label - } - } - tagMap := make(map[int64]string) - if ts, err := c.hub.queries.ListTags(ctx); err == nil { - for _, t := range ts { - tagMap[t.ID] = t.Label - } - } - - var buf strings.Builder - w := csv.NewWriter(&buf) - _ = w.Write([]string{"talkgroup_id", "label", "name", "tag", "group", "frequency", "led", "order"}) - for _, tg := range talkgroups { - freq := "" - if tg.Frequency.Valid { - freq = strconv.FormatInt(tg.Frequency.Int64, 10) - } - groupLabel := "" - if tg.GroupID.Valid { - groupLabel = groupMap[tg.GroupID.Int64] - } - tagLabel := "" - if tg.TagID.Valid { - tagLabel = tagMap[tg.TagID.Int64] - } - _ = w.Write([]string{ - strconv.FormatInt(tg.TalkgroupID, 10), - tg.Label.String, - tg.Name.String, - tagLabel, - groupLabel, - freq, - tg.Led.String, - strconv.FormatInt(tg.Order, 10), - }) - } - w.Flush() - - return buf.String(), nil -} - -func (c *Client) opExportUnits(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - SystemID *int64 `json:"systemId"` - } - if params != nil { - _ = json.Unmarshal(params, &req) - } - if req.SystemID == nil { - return nil, fmt.Errorf("systemId is required") - } - - units, err := c.hub.queries.ListUnitsBySystem(ctx, *req.SystemID) - if err != nil { - return nil, fmt.Errorf("failed to list units: %w", err) - } - - var buf strings.Builder - w := csv.NewWriter(&buf) - _ = w.Write([]string{"unit_id", "label", "order"}) - for _, u := range units { - _ = w.Write([]string{ - strconv.FormatInt(u.UnitID, 10), - u.Label.String, - strconv.FormatInt(u.Order, 10), - }) - } - w.Flush() - - return buf.String(), nil -} - -func (c *Client) opExportGroups(ctx context.Context, _ json.RawMessage) (any, error) { - groups, err := c.hub.queries.ListGroups(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list groups: %w", err) - } - var buf strings.Builder - w := csv.NewWriter(&buf) - _ = w.Write([]string{"label"}) - for _, g := range groups { - _ = w.Write([]string{g.Label}) - } - w.Flush() - return buf.String(), nil -} - -func (c *Client) opExportTags(ctx context.Context, _ json.RawMessage) (any, error) { - tags, err := c.hub.queries.ListTags(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list tags: %w", err) - } - var buf strings.Builder - w := csv.NewWriter(&buf) - _ = w.Write([]string{"label"}) - for _, t := range tags { - _ = w.Write([]string{t.Label}) - } - w.Flush() - return buf.String(), nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// IMPORT -// ══════════════════════════════════════════════════════════════════════════════ - -// importAPIKey, importDownstream, and importWebhook mirror the flat shape -// emitted by opExportConfig (plain string/null instead of {String,Valid} -// blobs). Unmarshalling directly into the db.* structs would fail for any -// non-null nullable field because sql.NullString has no JSON unmarshaler. -type importAPIKey struct { - Key string `json:"key"` - Ident *string `json:"ident"` - Disabled int64 `json:"disabled"` - SystemsJson *string `json:"systems_json"` - CallRateLimit *int64 `json:"call_rate_limit"` - Order int64 `json:"order"` -} - -type importDownstream struct { - Url string `json:"url"` - ApiKey string `json:"api_key"` - SystemsJson *string `json:"systems_json"` - Disabled int64 `json:"disabled"` - Order int64 `json:"order"` -} - -type importWebhook struct { - Url string `json:"url"` - Type string `json:"type"` - Secret *string `json:"secret"` - SystemsJson *string `json:"systems_json"` - Disabled int64 `json:"disabled"` - Order int64 `json:"order"` -} - -func (c *Client) opImportConfig(ctx context.Context, params json.RawMessage) (any, error) { - var data struct { - Settings []db.Setting `json:"settings"` - Groups []db.Group `json:"groups"` - Tags []db.Tag `json:"tags"` - Systems []db.System `json:"systems"` - Talkgroups []db.Talkgroup `json:"talkgroups"` - Units []db.Unit `json:"units"` - APIKeys []importAPIKey `json:"apiKeys"` - DirMonitors []db.Dirmonitor `json:"dirmonitors"` - Downstreams []importDownstream `json:"downstreams"` - Webhooks []importWebhook `json:"webhooks"` - } - if err := json.Unmarshal(params, &data); err != nil { - slog.Warn("import config: failed to parse payload", "error", err) - return nil, userError("invalid backup file: " + err.Error()) - } - - // Validate encrypted values: reject if no key configured, or if the wrong key is configured. - encKey := c.hub.deps.EncryptionKey - for _, s := range data.Settings { - if SensitiveSettingKeys[s.Key] && auth.IsEncrypted(s.Value) { - if encKey == "" { - return nil, userError("backup contains encrypted settings but no encryption key is configured — set --encryption-key before importing") - } - if _, err := auth.DecryptString(s.Value, encKey); err != nil { - return nil, userError("backup contains encrypted settings that cannot be decrypted with the current encryption key — check that --encryption-key matches the key used when the backup was created") - } - } - } - for _, d := range data.Downstreams { - if auth.IsEncrypted(d.ApiKey) { - if encKey == "" { - return nil, userError("backup contains encrypted downstream API keys but no encryption key is configured — set --encryption-key before importing") - } - if _, err := auth.DecryptString(d.ApiKey, encKey); err != nil { - return nil, userError("backup contains encrypted downstream API keys that cannot be decrypted with the current encryption key — check that --encryption-key matches the key used when the backup was created") - } - } - } - - sqlDB := c.hub.deps.SQLDB - if sqlDB == nil { - return nil, fmt.Errorf("transaction support not available") - } - - tx, err := sqlDB.BeginTx(ctx, nil) - if err != nil { - return nil, fmt.Errorf("database error: %w", err) - } - defer tx.Rollback() //nolint:errcheck - - qtx := c.hub.queries.WithTx(tx) - - // Settings - for _, s := range data.Settings { - if !wsAllowedSettingKeys[s.Key] { - slog.Warn("import config: skipping unknown setting key", "key", s.Key) - continue - } - if err := qtx.UpsertSetting(ctx, db.UpsertSettingParams(s)); err != nil { - return nil, fmt.Errorf("failed to import settings: %w", err) - } - } - - // Groups — capture old→new id remap so talkgroups can rewrite their - // group_id FKs (the export carries the source DB's PKs, but on a fresh - // install those PKs don't exist yet). - groupRemap := make(map[int64]int64, len(data.Groups)) - for _, g := range data.Groups { - newID, err := qtx.CreateGroup(ctx, g.Label) - if err != nil { - if !wsIsUniqueViolation(err) { - return nil, fmt.Errorf("failed to import groups: %w", err) - } - existing, gerr := qtx.GetGroupByLabel(ctx, g.Label) - if gerr != nil { - return nil, fmt.Errorf("failed to look up existing group %q: %w", g.Label, gerr) - } - newID = existing.ID - } - groupRemap[g.ID] = newID - } - - // Tags — same remap pattern as groups. - tagRemap := make(map[int64]int64, len(data.Tags)) - for _, t := range data.Tags { - newID, err := qtx.CreateTag(ctx, t.Label) - if err != nil { - if !wsIsUniqueViolation(err) { - return nil, fmt.Errorf("failed to import tags: %w", err) - } - existing, gerr := qtx.GetTagByLabel(ctx, t.Label) - if gerr != nil { - return nil, fmt.Errorf("failed to look up existing tag %q: %w", t.Label, gerr) - } - newID = existing.ID - } - tagRemap[t.ID] = newID - } - - // Systems — remap by old PK → new PK. The natural key is SystemID - // (the radio-system ID, e.g. 1, 100), which sqlc enforces UNIQUE. - systemRemap := make(map[int64]int64, len(data.Systems)) - for _, s := range data.Systems { - newID, err := qtx.CreateSystem(ctx, db.CreateSystemParams{ - SystemID: s.SystemID, - Label: s.Label, - AutoPopulateTalkgroups: s.AutoPopulateTalkgroups, - BlacklistsJson: s.BlacklistsJson, - Led: s.Led, - Order: s.Order, - }) - if err != nil { - if !wsIsUniqueViolation(err) { - return nil, fmt.Errorf("failed to import systems: %w", err) - } - existing, gerr := qtx.GetSystemBySystemID(ctx, s.SystemID) - if gerr != nil { - return nil, fmt.Errorf("failed to look up existing system %d: %w", s.SystemID, gerr) - } - newID = existing.ID - } - systemRemap[s.ID] = newID - } - - // Talkgroups — translate FKs (system_id, group_id, tag_id) through the - // remaps built above, then upsert. Capture the new PK so dirmonitors - // can rewrite their talkgroup_id FKs. - tgRemap := make(map[int64]int64, len(data.Talkgroups)) - for _, tg := range data.Talkgroups { - newSystemID, ok := systemRemap[tg.SystemID] - if !ok { - slog.Warn("import config: skipping talkgroup with unknown system_id", - "talkgroup_id", tg.TalkgroupID, "system_id", tg.SystemID) - continue - } - groupID := tg.GroupID - if groupID.Valid { - if mapped, ok := groupRemap[groupID.Int64]; ok { - groupID.Int64 = mapped - } else { - // Group wasn't in the export — drop the FK rather than fail. - groupID = sql.NullInt64{} - } - } - tagID := tg.TagID - if tagID.Valid { - if mapped, ok := tagRemap[tagID.Int64]; ok { - tagID.Int64 = mapped - } else { - tagID = sql.NullInt64{} - } - } - if err := qtx.UpsertTalkgroup(ctx, db.UpsertTalkgroupParams{ - SystemID: newSystemID, - TalkgroupID: tg.TalkgroupID, - Label: tg.Label, - Name: tg.Name, - Frequency: tg.Frequency, - Led: tg.Led, - GroupID: groupID, - TagID: tagID, - Order: tg.Order, - }); err != nil { - return nil, fmt.Errorf("failed to import talkgroups: %w", err) - } - row, err := qtx.GetTalkgroupBySystemAndTGID(ctx, db.GetTalkgroupBySystemAndTGIDParams{ - SystemID: newSystemID, - TalkgroupID: tg.TalkgroupID, - }) - if err != nil { - return nil, fmt.Errorf("failed to look up imported talkgroup (system=%d tg=%d): %w", - newSystemID, tg.TalkgroupID, err) - } - tgRemap[tg.ID] = row.ID - } - - // Units — translate system_id. - for _, u := range data.Units { - newSystemID, ok := systemRemap[u.SystemID] - if !ok { - slog.Warn("import config: skipping unit with unknown system_id", - "unit_id", u.UnitID, "system_id", u.SystemID) - continue - } - if err := qtx.UpsertUnit(ctx, db.UpsertUnitParams{ - SystemID: newSystemID, - UnitID: u.UnitID, - Label: u.Label, - Order: u.Order, - }); err != nil { - return nil, fmt.Errorf("failed to import units: %w", err) - } - } - - // API Keys — remap any system PKs embedded in systems_json. - for _, k := range data.APIKeys { - if _, err := qtx.CreateAPIKey(ctx, db.CreateAPIKeyParams{ - Key: k.Key, - Ident: wsPtrToNullStr(k.Ident), - Disabled: k.Disabled, - SystemsJson: wsPtrToNullStr(remapSystemsJSON(k.SystemsJson, systemRemap)), - CallRateLimit: wsPtrToNullInt(k.CallRateLimit), - Order: k.Order, - }); err != nil && !wsIsUniqueViolation(err) { - return nil, fmt.Errorf("failed to import api keys: %w", err) - } - } - - // DirMonitors — translate system_id and talkgroup_id FKs. - for _, d := range data.DirMonitors { - sysID := d.SystemID - if sysID.Valid { - if mapped, ok := systemRemap[sysID.Int64]; ok { - sysID.Int64 = mapped - } else { - slog.Warn("import config: dirmonitor system_id not found in import; dropping FK", - "directory", d.Directory, "system_id", sysID.Int64) - sysID = sql.NullInt64{} - } - } - tgID := d.TalkgroupID - if tgID.Valid { - if mapped, ok := tgRemap[tgID.Int64]; ok { - tgID.Int64 = mapped - } else { - slog.Warn("import config: dirmonitor talkgroup_id not found in import; dropping FK", - "directory", d.Directory, "talkgroup_id", tgID.Int64) - tgID = sql.NullInt64{} - } - } - if _, err := qtx.CreateDirMonitor(ctx, db.CreateDirMonitorParams{ - Directory: d.Directory, - Type: d.Type, - Mask: d.Mask, - Extension: d.Extension, - Frequency: d.Frequency, - Delay: d.Delay, - DeleteAfter: d.DeleteAfter, - UsePolling: d.UsePolling, - Disabled: d.Disabled, - SystemID: sysID, - TalkgroupID: tgID, - Order: d.Order, - }); err != nil && !wsIsUniqueViolation(err) { - return nil, fmt.Errorf("failed to import dirmonitors: %w", err) - } - } - - // Downstreams — remap embedded system PKs. - for _, d := range data.Downstreams { - if !wsValidHTTPURL(d.Url) { - slog.Warn("import config: skipping downstream with invalid URL", "url", d.Url) - continue - } - if _, err := qtx.CreateDownstream(ctx, db.CreateDownstreamParams{ - Url: d.Url, - ApiKey: d.ApiKey, - SystemsJson: wsPtrToNullStr(remapSystemsJSON(d.SystemsJson, systemRemap)), - Disabled: d.Disabled, - Order: d.Order, - }); err != nil && !wsIsUniqueViolation(err) { - return nil, fmt.Errorf("failed to import downstreams: %w", err) - } - } - - // Webhooks — remap embedded system PKs. - for _, w := range data.Webhooks { - if !wsValidHTTPURL(w.Url) { - slog.Warn("import config: skipping webhook with invalid URL", "url", w.Url) - continue - } - if _, err := qtx.CreateWebhook(ctx, db.CreateWebhookParams{ - Url: w.Url, - Type: w.Type, - Secret: wsPtrToNullStr(w.Secret), - SystemsJson: wsPtrToNullStr(remapSystemsJSON(w.SystemsJson, systemRemap)), - Disabled: w.Disabled, - Order: w.Order, - }); err != nil && !wsIsUniqueViolation(err) { - return nil, fmt.Errorf("failed to import webhooks: %w", err) - } - } - - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("failed to commit import: %w", err) - } - - // Hot-reload subsystems whose live state derives from the rows or - // settings we just rewrote. Without these, the in-process worker - // pools, downstream forwarders, and dirmonitor watchers keep using - // their pre-import config — symptom: transcription stops, downstream - // forwarding goes silent, dirmonitors don't pick up new directories - // until the operator restarts the server. - if c.hub.deps.TranscriberReload != nil && len(data.Settings) > 0 { - tEnabled, _ := c.hub.queries.GetSetting(ctx, "transcriptionEnabled") - tURL, _ := c.hub.queries.GetSetting(ctx, "transcriptionUrl") - tModel, _ := c.hub.queries.GetSetting(ctx, "transcriptionModel") - tLang, _ := c.hub.queries.GetSetting(ctx, "transcriptionLanguage") - tDiarize, _ := c.hub.queries.GetSetting(ctx, "transcriptionDiarize") - - ok := c.hub.deps.TranscriberReload.Reload( - tEnabled.Value == "true", - tURL.Value, - tModel.Value, - tLang.Value, - tDiarize.Value == "true", - ) - c.hub.deps.WhisperAvailable = ok && tEnabled.Value == "true" - } - if c.hub.deps.DirMonitorReload != nil && len(data.DirMonitors) > 0 { - c.hub.deps.DirMonitorReload.Reload() - } - if c.hub.deps.DownstreamReload != nil && len(data.Downstreams) > 0 { - c.hub.deps.DownstreamReload.Reload() - } - - // Notify all admin/listener clients to refetch — without these the - // admin UI shows stale (empty) lists and the user thinks the import - // silently failed. Order doesn't matter; events are fire-and-forget. - for _, topic := range []string{ - "groups.updated", - "tags.updated", - "systems.updated", - "talkgroups.updated", - "units.updated", - "apikeys.updated", - "dirmonitors.updated", - "downstreams.updated", - "webhooks.updated", - } { - c.hub.BroadcastAdminEvent(topic, nil) - } - c.hub.BroadcastCFG(ctx) - - slog.Info("config imported successfully via WS", - "by", c.userID, - "settings", len(data.Settings), - "groups", len(data.Groups), - "tags", len(data.Tags), - "systems", len(data.Systems), - "talkgroups", len(data.Talkgroups), - "units", len(data.Units), - "apiKeys", len(data.APIKeys), - "dirmonitors", len(data.DirMonitors), - "downstreams", len(data.Downstreams), - "webhooks", len(data.Webhooks), - ) - return map[string]bool{"ok": true}, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// RADIOREFERENCE -// ══════════════════════════════════════════════════════════════════════════════ - -func (c *Client) opRadioReferenceApply(ctx context.Context, params json.RawMessage) (any, error) { - type rrCandidate struct { - Row int `json:"row"` - TalkgroupID int64 `json:"talkgroupId"` - Label *string `json:"label,omitempty"` - Name *string `json:"name,omitempty"` - Group *string `json:"group,omitempty"` - Tag *string `json:"tag,omitempty"` - Led *string `json:"led,omitempty"` - Order *int64 `json:"order,omitempty"` - } - - var req struct { - SystemID int64 `json:"systemId"` - Candidates []rrCandidate `json:"candidates"` - MergeMode string `json:"mergeMode"` - SelectedFields []string `json:"selectedFields"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.SystemID <= 0 { - return nil, userError("systemId is required") - } - if len(req.Candidates) == 0 { - return nil, userError("candidates are required") - } - if len(req.Candidates) > 100_000 { - return nil, userError("too many candidates") - } - if req.MergeMode == "" { - req.MergeMode = "fill_missing" - } - if req.MergeMode != "fill_missing" && req.MergeMode != "overwrite_selected" { - return nil, userError("mergeMode must be 'fill_missing' or 'overwrite_selected'") - } - if _, err := c.hub.queries.GetSystem(ctx, req.SystemID); err != nil { - return nil, userError("system not found") - } - - // Sanitize selected fields. - rrUpdatable := map[string]bool{"label": true, "name": true, "group": true, "tag": true, "led": true, "order": true} - selected := make([]string, 0, len(req.SelectedFields)) - for _, f := range req.SelectedFields { - v := strings.ToLower(strings.TrimSpace(f)) - if rrUpdatable[v] { - selected = append(selected, v) - } - } - - type rowErr struct { - Row int `json:"row"` - Reason string `json:"reason"` - } - resp := map[string]any{ - "processed": 0, - "matched": 0, - "updated": 0, - "skipped": 0, - "errors": 0, - "rowErrors": []rowErr{}, - } - processed, matched, updated, skippedCount, errCount := 0, 0, 0, 0, 0 - rowErrors := make([]rowErr, 0) - - for _, candidate := range req.Candidates { - processed++ - - tg, tgErr := c.hub.queries.GetTalkgroupBySystemAndTGID(ctx, db.GetTalkgroupBySystemAndTGIDParams{ - SystemID: req.SystemID, - TalkgroupID: candidate.TalkgroupID, - }) - if tgErr != nil { - if errors.Is(tgErr, sql.ErrNoRows) { - skippedCount++ - rowErrors = append(rowErrors, rowErr{Row: candidate.Row, Reason: "talkgroup not found in selected system"}) - continue - } - errCount++ - rowErrors = append(rowErrors, rowErr{Row: candidate.Row, Reason: "database error"}) - continue - } - matched++ - - p := db.UpdateTalkgroupParams{ - ID: tg.ID, - TalkgroupID: tg.TalkgroupID, - Label: tg.Label, - Name: tg.Name, - Frequency: tg.Frequency, - Led: tg.Led, - GroupID: tg.GroupID, - TagID: tg.TagID, - Order: tg.Order, - } - - // Determine which fields to apply. - allow := map[string]bool{} - if req.MergeMode == "overwrite_selected" { - for _, f := range selected { - allow[f] = true - } - } - - applyFields := make([]string, 0, 6) - check := func(field string, hasCand bool, targetEmpty bool) { - if !hasCand { - return - } - if req.MergeMode == "overwrite_selected" { - if allow[field] { - applyFields = append(applyFields, field) - } - return - } - if targetEmpty { - applyFields = append(applyFields, field) - } - } - check("label", candidate.Label != nil, !tg.Label.Valid || strings.TrimSpace(tg.Label.String) == "") - check("name", candidate.Name != nil, !tg.Name.Valid || strings.TrimSpace(tg.Name.String) == "") - check("group", candidate.Group != nil, !tg.GroupID.Valid) - check("tag", candidate.Tag != nil, !tg.TagID.Valid) - check("led", candidate.Led != nil, !tg.Led.Valid || strings.TrimSpace(tg.Led.String) == "") - check("order", candidate.Order != nil, tg.Order == 0) - - if len(applyFields) == 0 { - skippedCount++ - continue - } - - // Apply field updates. - applyErr := false - for _, field := range applyFields { - switch field { - case "label": - if candidate.Label != nil { - p.Label = sql.NullString{String: *candidate.Label, Valid: true} - } - case "name": - if candidate.Name != nil { - p.Name = sql.NullString{String: *candidate.Name, Valid: true} - } - case "group": - if candidate.Group != nil { - g, err := c.hub.queries.GetGroupByLabel(ctx, *candidate.Group) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - newID, createErr := c.hub.queries.CreateGroup(ctx, *candidate.Group) - if createErr != nil { - errCount++ - rowErrors = append(rowErrors, rowErr{Row: candidate.Row, Reason: "database error"}) - applyErr = true - break - } - p.GroupID = sql.NullInt64{Int64: newID, Valid: true} - } else { - errCount++ - rowErrors = append(rowErrors, rowErr{Row: candidate.Row, Reason: "database error"}) - applyErr = true - break - } - } else { - p.GroupID = sql.NullInt64{Int64: g.ID, Valid: true} - } - } - case "tag": - if candidate.Tag != nil { - t, err := c.hub.queries.GetTagByLabel(ctx, *candidate.Tag) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - newID, createErr := c.hub.queries.CreateTag(ctx, *candidate.Tag) - if createErr != nil { - errCount++ - rowErrors = append(rowErrors, rowErr{Row: candidate.Row, Reason: "database error"}) - applyErr = true - break - } - p.TagID = sql.NullInt64{Int64: newID, Valid: true} - } else { - errCount++ - rowErrors = append(rowErrors, rowErr{Row: candidate.Row, Reason: "database error"}) - applyErr = true - break - } - } else { - p.TagID = sql.NullInt64{Int64: t.ID, Valid: true} - } - } - case "led": - if candidate.Led != nil { - p.Led = sql.NullString{String: *candidate.Led, Valid: true} - } - case "order": - if candidate.Order != nil { - p.Order = *candidate.Order - } - } - } - if applyErr { - continue - } - - if err := c.hub.queries.UpdateTalkgroup(ctx, p); err != nil { - errCount++ - rowErrors = append(rowErrors, rowErr{Row: candidate.Row, Reason: "database error"}) - continue - } - updated++ - } - - resp["processed"] = processed - resp["matched"] = matched - resp["updated"] = updated - resp["skipped"] = skippedCount - resp["errors"] = errCount - resp["rowErrors"] = rowErrors - return resp, nil -} - -// ══════════════════════════════════════════════════════════════════════════════ -// TRANSCRIPTION MODEL MANAGEMENT -// ══════════════════════════════════════════════════════════════════════════════ - -// transcriptionBaseURL reads the transcriptionUrl setting from DB. -func (c *Client) transcriptionBaseURL(ctx context.Context) (string, error) { - s, err := c.hub.queries.GetSetting(ctx, "transcriptionUrl") - if err == nil && s.Value != "" && wsValidHTTPURL(s.Value) { - return strings.TrimRight(s.Value, "/"), nil - } - // Fall back to the live manager's URL (e.g. when DB setting was just saved - // but the query above fails due to timing). - if tr := c.hub.deps.TranscriberReload; tr != nil { - if u := tr.BaseURL(); u != "" { - return strings.TrimRight(u, "/"), nil - } - } - return "", userError("transcriptionUrl setting is not configured") -} - -func (c *Client) opTranscriptionStatus(ctx context.Context, _ json.RawMessage) (any, error) { - // Read settings from DB. - getVal := func(key string) string { - s, err := c.hub.queries.GetSetting(ctx, key) - if err != nil { - return "" - } - return s.Value - } - - enabled := getVal("transcriptionEnabled") == "true" - baseURL := getVal("transcriptionUrl") - model := getVal("transcriptionModel") - language := getVal("transcriptionLanguage") - diarize := getVal("transcriptionDiarize") == "true" - liveDisplay := getVal("liveTranscriptDisplay") == "true" - - // Check live connection to go-whisper. - connected := false - if baseURL != "" && wsValidHTTPURL(baseURL) { - trimmed := strings.TrimRight(baseURL, "/") - reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, trimmed+"/api/whisper/model", nil) - if err == nil { - resp, err := http.DefaultClient.Do(req) - if err == nil { - resp.Body.Close() - connected = resp.StatusCode >= 200 && resp.StatusCode < 400 - } - } - } - - return map[string]any{ - "enabled": enabled, - "url": baseURL, - "model": model, - "language": language, - "diarize": diarize, - "liveDisplay": liveDisplay, - "connected": connected, - }, nil -} - -func (c *Client) opTranscriptionModels(ctx context.Context, _ json.RawMessage) (any, error) { - baseURL, err := c.transcriptionBaseURL(ctx) - if err != nil { - return nil, err - } - - reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, baseURL+"/api/whisper/model", nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, fmt.Errorf("go-whisper unreachable: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("go-whisper returned status %d", resp.StatusCode) - } - - var result json.RawMessage - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("invalid JSON from go-whisper: %w", err) - } - return result, nil -} - -func (c *Client) opTranscriptionDownload(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - Model string `json:"model"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.Model == "" { - return nil, userError("model name is required") - } - - // go-whisper expects model names with .bin extension - model := req.Model - if !strings.HasSuffix(model, ".bin") { - model += ".bin" - } - - // tdrz (tinydiarize) models live in a different HuggingFace repo. - // go-whisper's store accepts a full URL as the model path for non-default repos. - if strings.Contains(model, "tdrz") { - model = "https://huggingface.co/akashmjn/tinydiarize-whisper.cpp/resolve/main/ggml-" + strings.TrimPrefix(model, "ggml-") - } - - baseURL, err := c.transcriptionBaseURL(ctx) - if err != nil { - return nil, err - } - - reqBody, _ := json.Marshal(map[string]string{"model": model}) - - // Model downloads can take a long time (500MB+). - reqCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) - defer cancel() - - httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, baseURL+"/api/whisper/model", strings.NewReader(string(reqBody))) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("go-whisper unreachable: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - slog.Warn("go-whisper model download failed", "status", resp.StatusCode, "body", string(body)) - return nil, fmt.Errorf("go-whisper returned status %d", resp.StatusCode) - } - - var result json.RawMessage - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("invalid JSON from go-whisper: %w", err) - } - return result, nil -} - -func (c *Client) opTranscriptionDelete(ctx context.Context, params json.RawMessage) (any, error) { - var req struct { - ID string `json:"id"` - } - if err := json.Unmarshal(params, &req); err != nil { - return nil, userError("invalid request body") - } - if req.ID == "" { - return nil, userError("model id is required") - } - - // Sanitise: model ID should be alphanumeric + hyphens/dots/underscores only. - for _, ch := range req.ID { - if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '.' || ch == '_') { - return nil, userError("invalid model id") - } - } - - baseURL, err := c.transcriptionBaseURL(ctx) - if err != nil { - return nil, err - } - - reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodDelete, baseURL+"/api/whisper/model/"+req.ID, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := http.DefaultClient.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("go-whisper unreachable: %w", err) - } - defer resp.Body.Close() - io.Copy(io.Discard, resp.Body) //nolint:errcheck - - if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("go-whisper returned status %d", resp.StatusCode) - } - - return map[string]any{"deleted": true}, nil -} - -func (c *Client) opTranscriptionStats(ctx context.Context, _ json.RawMessage) (any, error) { - // DB aggregate stats — "recent" = last 24 hours. - since := time.Now().Add(-24 * time.Hour).Unix() - stats, err := c.hub.queries.TranscriptionStats(ctx, since) - if err != nil { - return nil, fmt.Errorf("query transcription stats: %w", err) - } - - byLang, err := c.hub.queries.TranscriptionsByLanguage(ctx) - if err != nil { - return nil, fmt.Errorf("query transcriptions by language: %w", err) - } - - byModel, err := c.hub.queries.TranscriptionsByModel(ctx) - if err != nil { - return nil, fmt.Errorf("query transcriptions by model: %w", err) - } - - // Pool stats (live). - queueDepth := 0 - poolEnabled := false - if tr := c.hub.deps.TranscriberReload; tr != nil { - poolEnabled = tr.Enabled() - queueDepth = tr.QueueDepth() - } - - // Convert interface{} values from COALESCE/AVG to int64. - toInt64 := func(v interface{}) int64 { - switch n := v.(type) { - case int64: - return n - case float64: - return int64(n) - default: - return 0 - } - } - - langBreakdown := make([]map[string]any, 0, len(byLang)) - for _, l := range byLang { - langBreakdown = append(langBreakdown, map[string]any{ - "language": l.Lang, - "count": l.Cnt, - }) - } - - modelBreakdown := make([]map[string]any, 0, len(byModel)) - for _, m := range byModel { - modelBreakdown = append(modelBreakdown, map[string]any{ - "model": m.ModelName, - "count": m.Cnt, - }) - } - - return map[string]any{ - "total": stats.Total, - "recent24h": stats.RecentCount, - "avgDurationMs": toInt64(stats.AvgDurationMs), - "minDurationMs": toInt64(stats.MinDurationMs), - "maxDurationMs": toInt64(stats.MaxDurationMs), - "queueDepth": queueDepth, - "poolEnabled": poolEnabled, - "byLanguage": langBreakdown, - "byModel": modelBreakdown, - }, nil -} diff --git a/backend/internal/ws/admin_router.go b/backend/internal/ws/admin_router.go new file mode 100644 index 0000000..5d4f0a4 --- /dev/null +++ b/backend/internal/ws/admin_router.go @@ -0,0 +1,162 @@ +// Package ws — admin WS request router. +// +// This file is the thin transport adapter between the WebSocket admin +// protocol (ADM_REQ / ADM_RES frames) and the transport-agnostic business +// logic in internal/admin. It preserves the wire protocol byte-for-byte: +// no op renames, no payload reshaping, no new error envelopes. +// +// Live-state ops that read from the hub's in-memory state (activity stats, +// log ring buffer) still live on *Client — they need hub/logging access +// the admin package explicitly does not have. +package ws + +import ( + "context" + "encoding/json" + "errors" + + "github.com/openscanner/openscanner/internal/admin" + "github.com/openscanner/openscanner/internal/logging" +) + +// adminOp is the generic admin.Operations method signature. Every handler +// in adminOpHandlers returns one of these (or a thin local wrapper) so the +// router can call them uniformly. +type adminOp func(ctx context.Context, params json.RawMessage, callerID int64) (any, error) + +// adminOpHandlers returns the complete map of supported admin WS operations. +// The keys are the wire-protocol op names (e.g. "users.list"); changing them +// breaks the frontend — don't. +func (c *Client) adminOpHandlers() map[string]adminOp { + o := c.hub.admin + return map[string]adminOp{ + // Activity & Logs — live hub state, stay on *Client. + "activity.stats": c.adaptClientOp(c.opActivityStats), + "activity.chart": c.adaptClientOp(c.opActivityChart), + "activity.top-talkgroups": c.adaptClientOp(c.opTopTalkgroups), + "logs.query": c.adaptClientOp(c.opLogsQuery), + "logs.level": c.adaptClientOp(c.opLogsLevel), + + // Users + "users.list": o.UsersList, + "users.create": o.UsersCreate, + "users.update": o.UsersUpdate, + "users.delete": o.UsersDelete, + + // Systems + "systems.list": o.SystemsList, + "systems.create": o.SystemsCreate, + "systems.update": o.SystemsUpdate, + "systems.delete": o.SystemsDelete, + + // Talkgroups + "talkgroups.list": o.TalkgroupsList, + "talkgroups.create": o.TalkgroupsCreate, + "talkgroups.update": o.TalkgroupsUpdate, + "talkgroups.delete": o.TalkgroupsDelete, + + // Units + "units.list": o.UnitsList, + "units.create": o.UnitsCreate, + "units.update": o.UnitsUpdate, + "units.delete": o.UnitsDelete, + + // Groups + "groups.list": o.GroupsList, + "groups.create": o.GroupsCreate, + "groups.update": o.GroupsUpdate, + "groups.delete": o.GroupsDelete, + + // Tags + "tags.list": o.TagsList, + "tags.create": o.TagsCreate, + "tags.update": o.TagsUpdate, + "tags.delete": o.TagsDelete, + + // API Keys + "apikeys.list": o.APIKeysList, + "apikeys.create": o.APIKeysCreate, + "apikeys.update": o.APIKeysUpdate, + "apikeys.delete": o.APIKeysDelete, + + // DirMonitors + "dirmonitors.list": o.DirMonitorsList, + "dirmonitors.create": o.DirMonitorsCreate, + "dirmonitors.update": o.DirMonitorsUpdate, + "dirmonitors.delete": o.DirMonitorsDelete, + + // Downstreams + "downstreams.list": o.DownstreamsList, + "downstreams.create": o.DownstreamsCreate, + "downstreams.update": o.DownstreamsUpdate, + "downstreams.delete": o.DownstreamsDelete, + + // Webhooks + "webhooks.list": o.WebhooksList, + "webhooks.create": o.WebhooksCreate, + "webhooks.update": o.WebhooksUpdate, + "webhooks.delete": o.WebhooksDelete, + + // Shared Links + "shared-links.list": o.SharedLinksList, + "shared-links.delete": o.SharedLinksDelete, + + // Config + "config.get": o.ConfigGet, + "config.update": o.ConfigUpdate, + + // Filesystem + "fs.directories": o.FSDirectories, + + // Export + "export.config": o.ExportConfig, + "export.talkgroups": o.ExportTalkgroups, + "export.units": o.ExportUnits, + "export.groups": o.ExportGroups, + "export.tags": o.ExportTags, + + // Import + "import.config": o.ImportConfig, + + // RadioReference + "radioreference.apply": o.RadioReferenceApply, + + // Transcription model management + "transcription.status": o.TranscriptionStatus, + "transcription.models": o.TranscriptionModels, + "transcription.download": o.TranscriptionDownload, + "transcription.delete": o.TranscriptionDelete, + "transcription.stats": o.TranscriptionStats, + } +} + +// clientOp is the legacy signature used by the live-state handlers in +// client.go (they don't need callerID). +type clientOp func(ctx context.Context, params json.RawMessage) (any, error) + +// adaptClientOp wraps a client-scoped op into the adminOp signature by +// dropping the callerID argument. The hub's live state functions don't +// need it — they're read-only. +func (c *Client) adaptClientOp(fn clientOp) adminOp { + return func(ctx context.Context, params json.RawMessage, _ int64) (any, error) { + return fn(ctx, params) + } +} + +// opLogsLevel returns the current runtime log level. Kept here (vs +// client.go) because it's a tiny admin-only query with no other natural +// home. +func (c *Client) opLogsLevel(_ context.Context, _ json.RawMessage) (any, error) { + return map[string]string{"level": logging.GetLevel()}, nil +} + +// errorString unwraps admin.UserError into the byte-identical envelope the +// old dispatcher sent: the raw message string for validation errors, and +// "internal error" for anything else. +func errorString(err error) (msg string, isUser bool) { + var uerr admin.UserError + if errors.As(err, &uerr) { + return err.Error(), true + } + return "internal error", false +} diff --git a/backend/internal/ws/admin_router_test.go b/backend/internal/ws/admin_router_test.go new file mode 100644 index 0000000..0071a0a --- /dev/null +++ b/backend/internal/ws/admin_router_test.go @@ -0,0 +1,131 @@ +// Tests for the WS admin request router — specifically the framing / +// dispatch layer (unknown op → error envelope, known op → delegated to +// admin.Operations). Business logic for each op is covered in +// internal/admin's own test files. +package ws + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/openscanner/openscanner/internal/admin" + "github.com/openscanner/openscanner/internal/db" + _ "modernc.org/sqlite" +) + +func TestAdminOpHandlers_CoversEveryWireOp(t *testing.T) { + // If a new admin op is added to admin.Operations but not wired into + // adminOpHandlers, the WS layer silently drops it. This sanity check + // catches that before it hits production. + sqlDB, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open DB: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + queries := db.New(sqlDB) + + hub := NewHub(queries, "test") + c := &Client{hub: hub, userID: 1, isAdmin: true} + + handlers := c.adminOpHandlers() + + // The 58 ops expected on the wire. Keep this list sorted so diffs are + // readable when an op is intentionally added. + want := []string{ + "activity.chart", "activity.stats", "activity.top-talkgroups", + "apikeys.create", "apikeys.delete", "apikeys.list", "apikeys.update", + "config.get", "config.update", + "dirmonitors.create", "dirmonitors.delete", "dirmonitors.list", "dirmonitors.update", + "downstreams.create", "downstreams.delete", "downstreams.list", "downstreams.update", + "export.config", "export.groups", "export.tags", "export.talkgroups", "export.units", + "fs.directories", + "groups.create", "groups.delete", "groups.list", "groups.update", + "import.config", + "logs.level", "logs.query", + "radioreference.apply", + "shared-links.delete", "shared-links.list", + "systems.create", "systems.delete", "systems.list", "systems.update", + "tags.create", "tags.delete", "tags.list", "tags.update", + "talkgroups.create", "talkgroups.delete", "talkgroups.list", "talkgroups.update", + "transcription.delete", "transcription.download", "transcription.models", + "transcription.stats", "transcription.status", + "units.create", "units.delete", "units.list", "units.update", + "users.create", "users.delete", "users.list", "users.update", + "webhooks.create", "webhooks.delete", "webhooks.list", "webhooks.update", + } + for _, op := range want { + if _, ok := handlers[op]; !ok { + t.Errorf("adminOpHandlers missing wire op %q", op) + } + } + if got := len(handlers); got != len(want) { + t.Errorf("adminOpHandlers has %d entries, want %d", got, len(want)) + } +} + +func TestHandleAdminRequest_UnknownOp_ReturnsErrorEnvelope(t *testing.T) { + sqlDB, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open DB: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + queries := db.New(sqlDB) + hub := NewHub(queries, "test") + + // Capture anything the router tries to send. + sendCh := make(chan []byte, 1) + c := &Client{ + hub: hub, + userID: 1, + isAdmin: true, + send: sendCh, + } + + c.handleAdminRequest(context.Background(), adminRequest{ReqID: "r1", Op: "does.not.exist"}) + + select { + case msg := <-sendCh: + // Must be a valid ADM_RES error envelope referencing reqId "r1". + var frame []json.RawMessage + if err := json.Unmarshal(msg, &frame); err != nil { + t.Fatalf("response is not JSON array: %v", err) + } + var cmd string + if err := json.Unmarshal(frame[0], &cmd); err != nil || cmd != "ADM_RES" { + t.Fatalf("cmd = %q (err %v), want ADM_RES", cmd, err) + } + if !containsSub(string(msg), `"ok":false`) { + t.Errorf("expected error envelope ok:false; got %s", msg) + } + if !containsSub(string(msg), "unknown op") { + t.Errorf("expected 'unknown op' in error; got %s", msg) + } + default: + t.Fatal("no ADM_RES frame was sent") + } +} + +func TestErrorString_DistinguishesUserAndInternal(t *testing.T) { + uerr := admin.UserError("bad input") + msg, isUser := errorString(uerr) + if !isUser || msg != "bad input" { + t.Errorf("UserError path: got (%q, %v), want (\"bad input\", true)", msg, isUser) + } + + other := errors.New("boom") + msg, isUser = errorString(other) + if isUser || msg != "internal error" { + t.Errorf("internal path: got (%q, %v), want (\"internal error\", false)", msg, isUser) + } +} + +func containsSub(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/backend/internal/ws/client.go b/backend/internal/ws/client.go index 8340933..a067d45 100644 --- a/backend/internal/ws/client.go +++ b/backend/internal/ws/client.go @@ -105,15 +105,6 @@ type adminRequest struct { Params json.RawMessage `json:"params,omitempty"` } -// adminOpHandler is the function signature for all admin WS operation handlers. -type adminOpHandler func(ctx context.Context, params json.RawMessage) (any, error) - -// userError is returned by op handlers for validation errors that should be -// shown verbatim to the client. Other errors are treated as internal. -type userError string - -func (e userError) Error() string { return string(e) } - // CanReceive reports whether this client is authorized to receive a call for // the given system and talkgroup. If grants is nil/empty, everything is allowed. func (c *Client) CanReceive(systemID, talkgroupID int64) bool { @@ -484,15 +475,14 @@ func (c *Client) handleAdminRequest(ctx context.Context, req adminRequest) { return } - data, err := handler(ctx, req.Params) + data, err := handler(ctx, req.Params, c.userID) var msg []byte if err != nil { - var uerr userError - if errors.As(err, &uerr) { - msg, _ = NewADMRESErrorMessage(req.ReqID, err.Error()) + if errMsg, isUser := errorString(err); isUser { + msg, _ = NewADMRESErrorMessage(req.ReqID, errMsg) } else { slog.Error("ws: admin op failed", "op", req.Op, "reqId", req.ReqID, "error", err) - msg, _ = NewADMRESErrorMessage(req.ReqID, "internal error") + msg, _ = NewADMRESErrorMessage(req.ReqID, errMsg) } } else { msg, _ = NewADMRESMessage(req.ReqID, data) diff --git a/backend/internal/ws/hub.go b/backend/internal/ws/hub.go index e47cbbd..95b8f7d 100644 --- a/backend/internal/ws/hub.go +++ b/backend/internal/ws/hub.go @@ -3,39 +3,26 @@ package ws import ( "context" - "database/sql" "log/slog" "sync" "time" + "github.com/openscanner/openscanner/internal/admin" "github.com/openscanner/openscanner/internal/db" ) // Reloader triggers a service config reload (e.g. dirmonitor, downstream). -type Reloader interface { - Reload() -} +// Kept as a ws-local alias to admin.Reloader so external callers that +// reference ws.Reloader continue to compile. +type Reloader = admin.Reloader // TranscriberReloader can hot-reload the transcription subsystem. -type TranscriberReloader interface { - Reload(enabled bool, baseURL, model, language string, diarize bool) bool - Enabled() bool - BaseURL() string - QueueDepth() int -} +type TranscriberReloader = admin.TranscriberReloader -// HubDeps holds optional dependencies injected into the Hub for admin WS operations. -type HubDeps struct { - SQLDB *sql.DB - DirMonitorReload Reloader - DownstreamReload Reloader - TranscriberReload TranscriberReloader - FFmpegAvailable bool - FDKAACAvailable bool - WhisperAvailable bool - RecordingsDir string - EncryptionKey string -} +// HubDeps holds optional dependencies injected into the Hub for admin WS +// operations. It is an alias for admin.Deps so callers can keep using +// ws.HubDeps{...} while the underlying fields live in the admin package. +type HubDeps = admin.Deps // StartTime is the process start time, used for uptime calculations. var StartTime = time.Now() @@ -44,7 +31,7 @@ var StartTime = time.Now() type Hub struct { queries *db.Queries version string - deps HubDeps + admin *admin.Operations // transport-agnostic admin ops mu sync.RWMutex clients map[*Client]struct{} @@ -73,16 +60,17 @@ func NewHub(queries *db.Queries, version string, deps ...HubDeps) *Hub { if len(deps) > 0 { d = deps[0] } - return &Hub{ + h := &Hub{ queries: queries, version: version, - deps: d, clients: make(map[*Client]struct{}), register: make(chan *Client), unregister: make(chan *Client), broadcast: make(chan broadcastMsg, 256), done: make(chan struct{}), } + h.admin = admin.New(queries, d, h) + return h } // Run starts the hub's event loop. It blocks until ctx is cancelled. @@ -274,7 +262,9 @@ func (h *Hub) DisconnectByJTI(jti string) { // This handles the circular dependency where dwService needs hub but hub // needs dwService's Reloader. func (h *Hub) SetDirMonitorReloader(r Reloader) { - h.deps.DirMonitorReload = r + if h.admin != nil { + h.admin.Deps.DirMonitorReload = r + } } // debounceLSC schedules an LSC broadcast, resetting the timer if one is already