Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 3 additions & 2 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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 == "" {
Expand Down
145 changes: 145 additions & 0 deletions backend/internal/admin/api_keys.go
Original file line number Diff line number Diff line change
@@ -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
}
171 changes: 171 additions & 0 deletions backend/internal/admin/dirmonitors.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading