mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
CodeQL flagged 34 go/path-injection alerts across datastore.go, marge.go, recorder.go, handlers_docs.go and mirror_middleware.go. The existing defences (DataStore.safeJoin's post-join prefix check, handlers_docs's HasPrefix(filepath.Clean(...))) are functionally correct but sit downstream of the join, so CodeQL's interprocedural taint tracking treats every os.* sink that consumes them as still tainted. Move the validation up-front using filepath.IsLocal, which CodeQL recognises as a path-traversal sanitiser. IsLocal rejects absolute paths, ".." segments, and (on Windows) reserved device names — the same set the existing checks intended to block, just expressed in the shape the analyser understands. Changes: * DataStore.safeJoin (datastore.go) — pre-validates each non-empty element with filepath.IsLocal before joining. Existing post-join prefix check stays as belt-and-suspenders. ~30 of the 34 alerts flow through this helper. * Recorder (recorder.go) — adds a new (*Recorder).safeJoin method with the same sanitiser. getRecordingDir, DeleteSession, GetInteractionContent and ArchiveSession route through it; their signatures already returned error so plumbing it through is local. * HandleDocs (handlers_docs.go) — replaces the post-join HasPrefix check with an up-front filepath.IsLocal gate. * Mirror parity recorder (mirror_middleware.go) — also strips backslash separators (Windows) and gates the resulting filename component on filepath.IsLocal, falling back to "invalid" rather than letting malformed paths reach os.WriteFile. No behaviour change for legitimate inputs (account IDs, device IDs, session IDs, doc paths all satisfy IsLocal). Datastore and proxy test suites pass; handler suite's pre-existing TestDocsConsistency failure is unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2147 lines
60 KiB
Go
2147 lines
60 KiB
Go
// Package datastore provides a simple XML-based datastore for SoundTouch devices.
|
|
package datastore
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
|
)
|
|
|
|
// ErrGroupNotFound is returned when no group is found for a given device.
|
|
var ErrGroupNotFound = errors.New("group not found")
|
|
|
|
func exists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
// isSafeIdentifier returns true if the given identifier is safe to use
|
|
// as a single path component (for account IDs, device IDs, etc.).
|
|
// It rejects empty strings, path separators, and parent directory references.
|
|
func isSafeIdentifier(id string) bool {
|
|
if id == "" {
|
|
return false
|
|
}
|
|
|
|
// Disallow obvious path traversal / multi-component paths.
|
|
if strings.Contains(id, "/") || strings.Contains(id, "\\") || strings.Contains(id, "..") {
|
|
return false
|
|
}
|
|
|
|
// Allow a conservative set of characters commonly found in IDs:
|
|
// letters, digits, underscore, dash, dot, and colon (for MAC-like IDs).
|
|
for i := 0; i < len(id); i++ {
|
|
c := id[i]
|
|
if (c >= 'a' && c <= 'z') ||
|
|
(c >= 'A' && c <= 'Z') ||
|
|
(c >= '0' && c <= '9') ||
|
|
c == '_' || c == '-' || c == '.' || c == ':' {
|
|
continue
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// DataStore represents the device and configuration storage.
|
|
type DataStore struct {
|
|
// DataDir is the (possibly relative) base directory for all datastore files.
|
|
DataDir string
|
|
// baseDir is the absolute, normalized base directory used for path safety checks.
|
|
baseDir string
|
|
|
|
eventMutex sync.RWMutex
|
|
deviceEvents map[string][]models.DeviceEvent
|
|
idMutex sync.RWMutex
|
|
deviceMappings map[string]string
|
|
fileMutex sync.RWMutex
|
|
}
|
|
|
|
// normalizeMAC normalizes a MAC address to a consistent format
|
|
func normalizeMAC(mac string) string {
|
|
if mac == "" {
|
|
return ""
|
|
}
|
|
// Remove spaces and common separators, then convert to uppercase
|
|
mac = strings.TrimSpace(mac)
|
|
mac = strings.ReplaceAll(mac, " ", "")
|
|
mac = strings.ReplaceAll(mac, ":", "")
|
|
mac = strings.ReplaceAll(mac, "-", "")
|
|
mac = strings.ToUpper(mac)
|
|
|
|
return mac
|
|
}
|
|
|
|
// NewDataStore creates a new DataStore.
|
|
// NewDataStore creates a new DataStore instance with the specified data directory.
|
|
func NewDataStore(dataDir string) *DataStore {
|
|
if dataDir == "" {
|
|
dataDir = "data"
|
|
}
|
|
|
|
absBase, err := filepath.Abs(dataDir)
|
|
if err != nil {
|
|
// Fallback to the provided dataDir if Abs fails; this preserves existing behavior.
|
|
absBase = dataDir
|
|
}
|
|
|
|
return &DataStore{
|
|
DataDir: dataDir,
|
|
baseDir: absBase,
|
|
deviceEvents: make(map[string][]models.DeviceEvent),
|
|
deviceMappings: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
// safeJoin joins the given path elements to the datastore baseDir and ensures
|
|
// that the resulting absolute path stays within baseDir. If any element would
|
|
// escape baseDir (absolute path, "..", or — on Windows — a drive/colon), the
|
|
// function falls back to baseDir to prevent directory traversal.
|
|
//
|
|
// The validation up-front uses filepath.IsLocal, which CodeQL recognises as a
|
|
// path-traversal sanitiser, so taint analysis at call sites that subsequently
|
|
// hand the result to os.ReadFile / os.Open / os.Remove etc. propagates safely.
|
|
// The post-join prefix check below stays as belt-and-suspenders for any
|
|
// unusual platform behaviour IsLocal does not cover.
|
|
func (ds *DataStore) safeJoin(elem ...string) string {
|
|
for _, e := range elem {
|
|
if e == "" {
|
|
// filepath.Join silently skips empty elements, but IsLocal
|
|
// returns false for "" — treat empties as a no-op.
|
|
continue
|
|
}
|
|
|
|
if !filepath.IsLocal(e) {
|
|
// Element is absolute, contains ".." or a reserved Windows
|
|
// component. Refuse to join.
|
|
return ds.baseDir
|
|
}
|
|
}
|
|
|
|
// Join the base directory with the (now sanitised) elements.
|
|
path := filepath.Join(append([]string{ds.baseDir}, elem...)...)
|
|
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
// On error, fall back to baseDir to avoid using an unexpected path.
|
|
return ds.baseDir
|
|
}
|
|
|
|
base := ds.baseDir
|
|
if base == "" {
|
|
// If baseDir is not set for some reason, fall back to original path.
|
|
return absPath
|
|
}
|
|
|
|
// Belt-and-suspenders: ensure the resolved path is within the base
|
|
// directory even if filepath.IsLocal somehow misjudged a component.
|
|
baseWithSep := base
|
|
if !strings.HasSuffix(baseWithSep, string(os.PathSeparator)) {
|
|
baseWithSep += string(os.PathSeparator)
|
|
}
|
|
|
|
if absPath == base || strings.HasPrefix(absPath, baseWithSep) {
|
|
return absPath
|
|
}
|
|
|
|
// If the path would escape the base directory, return baseDir as a safe default.
|
|
return base
|
|
}
|
|
|
|
// SafeJoin returns a safe joined path relative to the datastore base directory.
|
|
func (ds *DataStore) SafeJoin(elem ...string) string {
|
|
return ds.safeJoin(elem...)
|
|
}
|
|
|
|
// ListAccounts returns a list of all account IDs (directories in the data root).
|
|
func (ds *DataStore) ListAccounts() ([]string, error) {
|
|
ds.fileMutex.RLock()
|
|
defer ds.fileMutex.RUnlock()
|
|
|
|
// Account data is stored in 'accounts' subdirectory within the data root.
|
|
accountsDir := filepath.Join(ds.baseDir, "accounts")
|
|
if !exists(accountsDir) {
|
|
return []string{"default"}, nil
|
|
}
|
|
|
|
entries, err := os.ReadDir(accountsDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
accounts := make([]string, 0)
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
// Basic filter to ignore common hidden/system dirs
|
|
if entry.Name() != ".git" && entry.Name() != "logs" {
|
|
accounts = append(accounts, entry.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(accounts) == 0 {
|
|
accounts = append(accounts, "default")
|
|
}
|
|
|
|
return accounts, nil
|
|
}
|
|
|
|
// AccountDir returns the directory path for a specific account.
|
|
func (ds *DataStore) AccountDir(account string) string {
|
|
return ds.safeJoin("accounts", account)
|
|
}
|
|
|
|
// AccountDevicesDir returns the devices directory path for a specific account.
|
|
func (ds *DataStore) AccountDevicesDir(account string) string {
|
|
return ds.safeJoin("accounts", account, constants.DevicesDir)
|
|
}
|
|
|
|
// AccountDeviceDir returns the directory path for a specific device within an account.
|
|
func (ds *DataStore) AccountDeviceDir(account, device string) string {
|
|
// First, check if the device directory exists directly with the given deviceID
|
|
// This prioritizes MAC-based deviceIDs over legacy mappings
|
|
directPath := ds.safeJoin("accounts", account, constants.DevicesDir, device)
|
|
if _, err := os.Stat(directPath); err == nil {
|
|
// Directory exists, use the direct deviceID (preferred for MAC-based IDs)
|
|
return directPath
|
|
}
|
|
|
|
// If direct path doesn't exist, check device mappings for backward compatibility
|
|
ds.idMutex.RLock()
|
|
|
|
mappedDevice, ok := ds.deviceMappings[device]
|
|
if !ok {
|
|
// Try with normalized MAC address
|
|
normalizedDevice := normalizeMAC(device)
|
|
mappedDevice, ok = ds.deviceMappings[normalizedDevice]
|
|
}
|
|
|
|
ds.idMutex.RUnlock()
|
|
|
|
if ok {
|
|
// Use the mapped device only if it exists and the direct path doesn't
|
|
mappedPath := ds.safeJoin("accounts", account, constants.DevicesDir, mappedDevice)
|
|
if _, err := os.Stat(mappedPath); err == nil {
|
|
return mappedPath
|
|
}
|
|
}
|
|
|
|
// If neither direct path nor mapping work, return the direct path
|
|
// (this allows new devices to be created with MAC-based IDs)
|
|
return directPath
|
|
}
|
|
|
|
// GetDeviceInfo retrieves device information for the specified account and device.
|
|
func (ds *DataStore) GetDeviceInfo(account, device string) (*models.ServiceDeviceInfo, error) {
|
|
ds.fileMutex.RLock()
|
|
defer ds.fileMutex.RUnlock()
|
|
|
|
return ds.getDeviceInfoNoLock(account, device)
|
|
}
|
|
|
|
func (ds *DataStore) getDeviceInfoNoLock(account, device string) (*models.ServiceDeviceInfo, error) {
|
|
path := ds.AccountDeviceDir(account, device)
|
|
deviceInfoPath := filepath.Join(path, constants.DeviceInfoFile)
|
|
|
|
data, err := os.ReadFile(deviceInfoPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var info struct {
|
|
XMLName xml.Name `xml:"info"`
|
|
DeviceID string `xml:"deviceID,attr"`
|
|
Name string `xml:"name"`
|
|
Type string `xml:"type"`
|
|
ModuleType string `xml:"moduleType"`
|
|
Components []struct {
|
|
Category string `xml:"componentCategory"`
|
|
SoftwareVersion string `xml:"softwareVersion"`
|
|
SerialNumber string `xml:"serialNumber"`
|
|
} `xml:"components>component"`
|
|
NetworkInfo []struct {
|
|
Type string `xml:"type,attr"`
|
|
IPAddress string `xml:"ipAddress"`
|
|
MacAddress string `xml:"macAddress"`
|
|
} `xml:"networkInfo"`
|
|
DiscoveryMethod string `xml:"discoveryMethod"`
|
|
}
|
|
|
|
if err := xml.Unmarshal(data, &info); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
deviceInfo := &models.ServiceDeviceInfo{
|
|
DeviceID: info.DeviceID,
|
|
AccountID: account, // Set AccountID from parameter
|
|
ProductCode: fmt.Sprintf("%s %s", info.Type, info.ModuleType),
|
|
Name: info.Name,
|
|
DiscoveryMethod: info.DiscoveryMethod,
|
|
}
|
|
|
|
for _, comp := range info.Components {
|
|
deviceInfo.Components = append(deviceInfo.Components, models.ServiceComponent{
|
|
Category: comp.Category,
|
|
SoftwareVersion: comp.SoftwareVersion,
|
|
SerialNumber: comp.SerialNumber,
|
|
})
|
|
|
|
switch comp.Category {
|
|
case "SCM":
|
|
deviceInfo.FirmwareVersion = comp.SoftwareVersion
|
|
deviceInfo.DeviceSerialNumber = comp.SerialNumber
|
|
case "PackagedProduct":
|
|
deviceInfo.ProductSerialNumber = comp.SerialNumber
|
|
}
|
|
}
|
|
|
|
for _, net := range info.NetworkInfo {
|
|
if net.Type == "SCM" {
|
|
deviceInfo.IPAddress = net.IPAddress
|
|
deviceInfo.MacAddress = net.MacAddress
|
|
}
|
|
}
|
|
|
|
return deviceInfo, nil
|
|
}
|
|
|
|
// ListAllDevices returns a list of all devices in all accounts.
|
|
func (ds *DataStore) ListAllDevices() ([]models.ServiceDeviceInfo, error) {
|
|
dirs := ds.getPossibleDataDirs()
|
|
if len(dirs) == 0 {
|
|
return []models.ServiceDeviceInfo{}, nil
|
|
}
|
|
|
|
devices := []models.ServiceDeviceInfo{}
|
|
seenIDs := make(map[string]bool)
|
|
|
|
for _, dir := range dirs {
|
|
accounts, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, acc := range accounts {
|
|
if !acc.IsDir() {
|
|
continue
|
|
}
|
|
|
|
accDevices := ds.listDevicesInAccount(dir, acc.Name())
|
|
for i := range accDevices {
|
|
info := accDevices[i]
|
|
info.AccountID = acc.Name()
|
|
|
|
key := info.DeviceID
|
|
if key == "" {
|
|
key = info.IPAddress
|
|
}
|
|
|
|
if !seenIDs[key] || info.Name != "" {
|
|
if seenIDs[key] && info.Name != "" {
|
|
// Replace previous empty-named entry with one that has a name
|
|
for j := range devices {
|
|
existing := &devices[j]
|
|
if (existing.DeviceID != "" && existing.DeviceID == info.DeviceID) ||
|
|
(existing.IPAddress != "" && existing.IPAddress == info.IPAddress) {
|
|
devices[j] = info
|
|
break
|
|
}
|
|
}
|
|
} else if !seenIDs[key] {
|
|
devices = append(devices, info)
|
|
seenIDs[key] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return devices, nil
|
|
}
|
|
|
|
func (ds *DataStore) getPossibleDataDirs() []string {
|
|
dirs := []string{}
|
|
// Check primary data directory
|
|
if ds.DataDir != "" {
|
|
if exists(filepath.Join(ds.DataDir, "accounts")) {
|
|
dirs = append(dirs, filepath.Join(ds.DataDir, "accounts"))
|
|
}
|
|
// Also check the DataDir itself as a base for account directories
|
|
if exists(ds.DataDir) && ds.DataDir != "." {
|
|
dirs = append(dirs, ds.DataDir)
|
|
}
|
|
}
|
|
|
|
// Also check st-go/data/accounts if it's different and exists
|
|
altDir := "st-go/data/accounts"
|
|
if exists(altDir) {
|
|
dirs = append(dirs, altDir)
|
|
}
|
|
// And st-go/data/accounts/default
|
|
altDir2 := "st-go/data"
|
|
if exists(altDir2) {
|
|
dirs = append(dirs, altDir2)
|
|
}
|
|
// And repro_data
|
|
altDir3 := "repro_data"
|
|
if exists(altDir3) {
|
|
dirs = append(dirs, altDir3)
|
|
}
|
|
|
|
// Add special handling for test environments where we might have account directories
|
|
// directly in the current working directory or a temp dir.
|
|
// Walk up from DataDir to find any 'accounts' directory.
|
|
curr := ds.DataDir
|
|
for i := 0; i < 3; i++ {
|
|
absCurr, _ := filepath.Abs(curr)
|
|
if exists(filepath.Join(absCurr, "accounts")) {
|
|
dirs = append(dirs, filepath.Join(absCurr, "accounts"))
|
|
}
|
|
|
|
if exists(absCurr) {
|
|
dirs = append(dirs, absCurr)
|
|
}
|
|
|
|
if curr == "." || curr == "/" || curr == "" {
|
|
break
|
|
}
|
|
|
|
curr = filepath.Dir(curr)
|
|
}
|
|
|
|
// Remove duplicates and ensure unique directories
|
|
uniqueDirs := make(map[string]bool)
|
|
result := []string{}
|
|
|
|
for _, dir := range dirs {
|
|
absDir, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
absDir = dir
|
|
}
|
|
|
|
if !uniqueDirs[absDir] {
|
|
uniqueDirs[absDir] = true
|
|
|
|
result = append(result, dir)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func (ds *DataStore) listDevicesInAccount(baseDir, accountName string) []models.ServiceDeviceInfo {
|
|
devices := []models.ServiceDeviceInfo{}
|
|
devicesDir := filepath.Join(baseDir, accountName, constants.DevicesDir)
|
|
|
|
deviceEntries, err := os.ReadDir(devicesDir)
|
|
if err != nil {
|
|
return devices
|
|
}
|
|
|
|
for _, dev := range deviceEntries {
|
|
var (
|
|
info *models.ServiceDeviceInfo
|
|
err error
|
|
)
|
|
|
|
if !dev.IsDir() {
|
|
if dev.Name() == constants.DeviceInfoFile {
|
|
// Special case for DeviceInfo.xml directly in devicesDir
|
|
path := filepath.Join(devicesDir, constants.DeviceInfoFile)
|
|
info, err = ds.parseDeviceInfoFile(path)
|
|
}
|
|
} else {
|
|
path := filepath.Join(devicesDir, dev.Name(), constants.DeviceInfoFile)
|
|
info, err = ds.parseDeviceInfoFile(path)
|
|
}
|
|
|
|
if err == nil && info != nil {
|
|
// Update bidirectional device mappings for resolution
|
|
ds.updateDeviceMappings(*info)
|
|
|
|
devices = append(devices, *info)
|
|
}
|
|
}
|
|
|
|
return devices
|
|
}
|
|
|
|
func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var info struct {
|
|
XMLName xml.Name `xml:"info"`
|
|
DeviceID string `xml:"deviceID,attr"`
|
|
Name string `xml:"name"`
|
|
Type string `xml:"type"`
|
|
ModuleType string `xml:"moduleType"`
|
|
Components []struct {
|
|
Category string `xml:"componentCategory"`
|
|
SoftwareVersion string `xml:"softwareVersion"`
|
|
SerialNumber string `xml:"serialNumber"`
|
|
} `xml:"components>component"`
|
|
NetworkInfo []struct {
|
|
Type string `xml:"type,attr"`
|
|
IPAddress string `xml:"ipAddress"`
|
|
MacAddress string `xml:"macAddress"`
|
|
} `xml:"networkInfo"`
|
|
DiscoveryMethod string `xml:"discoveryMethod"`
|
|
}
|
|
|
|
if err := xml.Unmarshal(data, &info); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
deviceInfo := &models.ServiceDeviceInfo{
|
|
DeviceID: info.DeviceID,
|
|
ProductCode: info.Type,
|
|
Name: info.Name,
|
|
DiscoveryMethod: info.DiscoveryMethod,
|
|
}
|
|
|
|
for _, comp := range info.Components {
|
|
deviceInfo.Components = append(deviceInfo.Components, models.ServiceComponent{
|
|
Category: comp.Category,
|
|
SoftwareVersion: comp.SoftwareVersion,
|
|
SerialNumber: comp.SerialNumber,
|
|
})
|
|
|
|
switch comp.Category {
|
|
case "SCM":
|
|
deviceInfo.FirmwareVersion = comp.SoftwareVersion
|
|
deviceInfo.DeviceSerialNumber = comp.SerialNumber
|
|
case "PackagedProduct":
|
|
deviceInfo.ProductSerialNumber = comp.SerialNumber
|
|
}
|
|
}
|
|
|
|
for _, net := range info.NetworkInfo {
|
|
if net.Type == "SCM" {
|
|
deviceInfo.IPAddress = net.IPAddress
|
|
deviceInfo.MacAddress = net.MacAddress
|
|
}
|
|
}
|
|
|
|
return deviceInfo, nil
|
|
}
|
|
|
|
// GetPresets retrieves all presets for the specified account and device.
|
|
func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) {
|
|
ds.fileMutex.RLock()
|
|
defer ds.fileMutex.RUnlock()
|
|
|
|
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []models.ServicePreset{}, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
var presetsWrap struct {
|
|
Presets []struct {
|
|
ID string `xml:"id,attr"`
|
|
CreatedOn string `xml:"createdOn,attr"`
|
|
UpdatedOn string `xml:"updatedOn,attr"`
|
|
ContentItem struct {
|
|
Source string `xml:"source,attr"`
|
|
Type string `xml:"type,attr"`
|
|
Location string `xml:"location,attr"`
|
|
SourceAccount string `xml:"sourceAccount,attr"`
|
|
IsPresetable string `xml:"isPresetable,attr"`
|
|
ItemName string `xml:"itemName"`
|
|
ContainerArt string `xml:"containerArt"`
|
|
} `xml:"contentItem"`
|
|
SourceID string `xml:"sourceid"`
|
|
} `xml:"preset"`
|
|
}
|
|
|
|
if err := xml.Unmarshal(data, &presetsWrap); err != nil {
|
|
return nil, fmt.Errorf("malformed presets XML at %s: %w", path, err)
|
|
}
|
|
|
|
presets := []models.ServicePreset{}
|
|
|
|
for i := range presetsWrap.Presets {
|
|
p := &presetsWrap.Presets[i]
|
|
|
|
presets = append(presets, models.ServicePreset{
|
|
ServiceContentItem: models.ServiceContentItem{
|
|
Name: p.ContentItem.ItemName,
|
|
Source: p.ContentItem.Source,
|
|
Type: p.ContentItem.Type,
|
|
ContentItemType: p.ContentItem.Type,
|
|
Location: p.ContentItem.Location,
|
|
SourceAccount: p.ContentItem.SourceAccount,
|
|
IsPresetable: p.ContentItem.IsPresetable,
|
|
SourceID: p.SourceID,
|
|
},
|
|
ID: p.ID,
|
|
ButtonNumber: p.ID,
|
|
ContainerArt: p.ContentItem.ContainerArt,
|
|
CreatedOn: p.CreatedOn,
|
|
UpdatedOn: p.UpdatedOn,
|
|
})
|
|
}
|
|
|
|
return presets, nil
|
|
}
|
|
|
|
// SavePresets saves the preset list for the specified account and device.
|
|
func (ds *DataStore) SavePresets(account, device string, presets []models.ServicePreset) error {
|
|
ds.fileMutex.Lock()
|
|
defer ds.fileMutex.Unlock()
|
|
|
|
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
type PresetXML struct {
|
|
ID string `xml:"id,attr"`
|
|
CreatedOn string `xml:"createdOn,attr"`
|
|
UpdatedOn string `xml:"updatedOn,attr"`
|
|
ContentItem struct {
|
|
Source string `xml:"source,attr,omitempty"`
|
|
Type string `xml:"type,attr"`
|
|
Location string `xml:"location,attr"`
|
|
SourceAccount string `xml:"sourceAccount,attr"`
|
|
IsPresetable string `xml:"isPresetable,attr"`
|
|
ItemName string `xml:"itemName"`
|
|
ContainerArt string `xml:"containerArt"`
|
|
} `xml:"contentItem"`
|
|
SourceID string `xml:"sourceid,omitempty"`
|
|
}
|
|
|
|
type PresetsXML struct {
|
|
XMLName xml.Name `xml:"presets"`
|
|
Presets []PresetXML `xml:"preset"`
|
|
}
|
|
|
|
var px PresetsXML
|
|
|
|
for i := range presets {
|
|
p := &presets[i]
|
|
|
|
var pxml PresetXML
|
|
|
|
pxml.ID = p.ButtonNumber
|
|
if pxml.ID == "" {
|
|
pxml.ID = p.ID
|
|
}
|
|
|
|
pxml.CreatedOn = p.CreatedOn
|
|
pxml.UpdatedOn = p.UpdatedOn
|
|
pxml.ContentItem.Source = p.Source
|
|
pxml.ContentItem.Type = p.Type
|
|
pxml.ContentItem.Location = p.Location
|
|
pxml.ContentItem.SourceAccount = p.SourceAccount
|
|
pxml.ContentItem.IsPresetable = "true"
|
|
pxml.ContentItem.ItemName = p.Name
|
|
pxml.ContentItem.ContainerArt = p.ContainerArt
|
|
pxml.SourceID = p.SourceID
|
|
px.Presets = append(px.Presets, pxml)
|
|
}
|
|
|
|
data, err := xml.MarshalIndent(px, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
header := []byte(xml.Header)
|
|
|
|
return ds.atomicWriteFile(path, append(header, data...))
|
|
}
|
|
|
|
func (ds *DataStore) atomicWriteFile(filename string, data []byte) error {
|
|
perm := os.FileMode(0644)
|
|
|
|
tempFile := filename + ".tmp"
|
|
if err := os.WriteFile(tempFile, data, perm); err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.Rename(tempFile, filename)
|
|
}
|
|
|
|
// GetRecents returns the list of recently played items for the specified account and device.
|
|
func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent, error) {
|
|
ds.fileMutex.RLock()
|
|
defer ds.fileMutex.RUnlock()
|
|
|
|
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []models.ServiceRecent{}, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
type RecentXML struct {
|
|
DeviceID string `xml:"deviceID,attr,omitempty"`
|
|
UtcTime string `xml:"utcTime,attr,omitempty"`
|
|
ID string `xml:"id,attr"`
|
|
ContentItem struct {
|
|
Source string `xml:"source,attr"`
|
|
Type string `xml:"type,attr"`
|
|
Location string `xml:"location,attr"`
|
|
SourceAccount string `xml:"sourceAccount,attr"`
|
|
IsPresetable string `xml:"isPresetable,attr"`
|
|
ItemName string `xml:"itemName"`
|
|
ContainerArt string `xml:"containerArt,omitempty"`
|
|
} `xml:"contentItem"`
|
|
CreatedOn string `xml:"createdOn,omitempty"`
|
|
UpdatedOn string `xml:"updatedOn,omitempty"`
|
|
LastPlayedAt string `xml:"lastplayedat,omitempty"`
|
|
SourceID string `xml:"sourceid,omitempty"`
|
|
Username string `xml:"username,omitempty"`
|
|
}
|
|
|
|
type RecentsXML struct {
|
|
XMLName xml.Name `xml:"recents"`
|
|
Recents []RecentXML `xml:"recent"`
|
|
}
|
|
|
|
var wrap RecentsXML
|
|
if err := xml.Unmarshal(data, &wrap); err != nil {
|
|
return nil, fmt.Errorf("malformed recents XML at %s: %w", path, err)
|
|
}
|
|
|
|
recents := make([]models.ServiceRecent, 0, len(wrap.Recents))
|
|
maxID := 0
|
|
|
|
for i := range wrap.Recents {
|
|
rx := &wrap.Recents[i]
|
|
r := models.ServiceRecent{
|
|
DeviceID: rx.DeviceID,
|
|
UtcTime: rx.UtcTime,
|
|
ServiceContentItem: models.ServiceContentItem{
|
|
ID: rx.ID,
|
|
Name: rx.ContentItem.ItemName,
|
|
Source: rx.ContentItem.Source,
|
|
Type: rx.ContentItem.Type,
|
|
Location: rx.ContentItem.Location,
|
|
SourceAccount: rx.ContentItem.SourceAccount,
|
|
IsPresetable: rx.ContentItem.IsPresetable,
|
|
ContainerArt: rx.ContentItem.ContainerArt,
|
|
},
|
|
CreatedOn: rx.CreatedOn,
|
|
UpdatedOn: rx.UpdatedOn,
|
|
LastPlayedAt: rx.LastPlayedAt,
|
|
}
|
|
r.SourceID = rx.SourceID
|
|
|
|
if id, err := strconv.Atoi(r.ID); err == nil {
|
|
if id > maxID {
|
|
maxID = id
|
|
}
|
|
}
|
|
|
|
recents = append(recents, r)
|
|
}
|
|
|
|
for i := range recents {
|
|
r := &recents[i]
|
|
if r.ContentItemType == "" {
|
|
if r.Type != "" {
|
|
r.ContentItemType = r.Type
|
|
}
|
|
}
|
|
|
|
if _, err := strconv.Atoi(recents[i].ID); err != nil || recents[i].ID == "" {
|
|
maxID++
|
|
recents[i].ID = strconv.Itoa(maxID)
|
|
}
|
|
}
|
|
|
|
return recents, nil
|
|
}
|
|
|
|
// SaveRecents saves the recent items list for the specified account and device.
|
|
func (ds *DataStore) SaveRecents(account, device string, recents []models.ServiceRecent) error {
|
|
ds.fileMutex.Lock()
|
|
defer ds.fileMutex.Unlock()
|
|
|
|
dir := ds.AccountDeviceDir(account, device)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
path := filepath.Join(dir, constants.RecentsFile)
|
|
|
|
type RecentXML struct {
|
|
DeviceID string `xml:"deviceID,attr,omitempty"`
|
|
UtcTime string `xml:"utcTime,attr,omitempty"`
|
|
ID string `xml:"id,attr"`
|
|
ContentItem struct {
|
|
Source string `xml:"source,attr"`
|
|
Type string `xml:"type,attr"`
|
|
Location string `xml:"location,attr"`
|
|
SourceAccount string `xml:"sourceAccount,attr"`
|
|
IsPresetable string `xml:"isPresetable,attr"`
|
|
ItemName string `xml:"itemName"`
|
|
ContainerArt string `xml:"containerArt,omitempty"`
|
|
} `xml:"contentItem"`
|
|
CreatedOn string `xml:"createdOn,omitempty"`
|
|
UpdatedOn string `xml:"updatedOn,omitempty"`
|
|
LastPlayedAt string `xml:"lastplayedat,omitempty"`
|
|
SourceID string `xml:"sourceid,omitempty"`
|
|
Username string `xml:"username,omitempty"`
|
|
}
|
|
|
|
type RecentsXML struct {
|
|
XMLName xml.Name `xml:"recents"`
|
|
Recents []RecentXML `xml:"recent"`
|
|
}
|
|
|
|
wrap := RecentsXML{
|
|
Recents: make([]RecentXML, 0, len(recents)),
|
|
}
|
|
|
|
for i := range recents {
|
|
r := &recents[i]
|
|
rx := RecentXML{
|
|
DeviceID: r.DeviceID,
|
|
UtcTime: r.UtcTime,
|
|
ID: r.ID,
|
|
CreatedOn: r.CreatedOn,
|
|
UpdatedOn: r.UpdatedOn,
|
|
LastPlayedAt: r.LastPlayedAt,
|
|
SourceID: r.SourceID,
|
|
Username: r.Username,
|
|
}
|
|
rx.ContentItem.Source = r.Source
|
|
rx.ContentItem.Type = r.Type
|
|
rx.ContentItem.Location = r.Location
|
|
rx.ContentItem.SourceAccount = r.SourceAccount
|
|
|
|
rx.ContentItem.IsPresetable = r.IsPresetable
|
|
if rx.ContentItem.IsPresetable == "" {
|
|
rx.ContentItem.IsPresetable = "true"
|
|
}
|
|
|
|
rx.ContentItem.ItemName = r.Name
|
|
rx.ContentItem.ContainerArt = r.ContainerArt
|
|
rx.SourceID = r.SourceID
|
|
|
|
wrap.Recents = append(wrap.Recents, rx)
|
|
}
|
|
|
|
data, err := xml.MarshalIndent(wrap, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
header := []byte(xml.Header)
|
|
|
|
return ds.atomicWriteFile(path, append(header, data...))
|
|
}
|
|
|
|
// SaveDeviceInfo saves device information for the specified account and device.
|
|
func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.ServiceDeviceInfo) error {
|
|
ds.fileMutex.Lock()
|
|
defer ds.fileMutex.Unlock()
|
|
|
|
if device == "" {
|
|
return fmt.Errorf("device ID/name cannot be empty")
|
|
}
|
|
|
|
if !isSafeIdentifier(device) {
|
|
return fmt.Errorf("invalid device ID")
|
|
}
|
|
|
|
if account == "" {
|
|
return fmt.Errorf("account ID cannot be empty")
|
|
}
|
|
|
|
if !isSafeIdentifier(account) {
|
|
return fmt.Errorf("invalid account ID")
|
|
}
|
|
|
|
// Try to load existing device info to avoid overwriting existing details with empty values.
|
|
ds.mergeWithExistingDeviceInfo(account, device, info)
|
|
|
|
dir := ds.AccountDeviceDir(account, device)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
path := filepath.Join(dir, constants.DeviceInfoFile)
|
|
|
|
type NetworkInfoXML struct {
|
|
Type string `xml:"type,attr"`
|
|
IPAddress string `xml:"ipAddress"`
|
|
MacAddress string `xml:"macAddress"`
|
|
}
|
|
|
|
type InfoXML struct {
|
|
XMLName xml.Name `xml:"info"`
|
|
DeviceID string `xml:"deviceID,attr"`
|
|
Name string `xml:"name"`
|
|
Type string `xml:"type"`
|
|
ModuleType string `xml:"moduleType"`
|
|
Components []componentXML `xml:"components>component"`
|
|
NetworkInfo []NetworkInfoXML `xml:"networkInfo"`
|
|
DiscoveryMethod string `xml:"discoveryMethod,omitempty"`
|
|
}
|
|
|
|
// Parsing product code back to type and moduleType (best effort)
|
|
devType, moduleType := ds.parseProductCode(info.ProductCode)
|
|
|
|
ix := InfoXML{
|
|
DeviceID: info.DeviceID,
|
|
Name: info.Name,
|
|
Type: devType,
|
|
ModuleType: moduleType,
|
|
DiscoveryMethod: info.DiscoveryMethod,
|
|
}
|
|
|
|
if ix.DiscoveryMethod == "" {
|
|
ix.DiscoveryMethod = "sync_full"
|
|
}
|
|
|
|
ix.Components = ds.buildComponentsXML(info)
|
|
|
|
ix.NetworkInfo = []NetworkInfoXML{
|
|
{
|
|
Type: "SCM",
|
|
IPAddress: info.IPAddress,
|
|
MacAddress: info.MacAddress,
|
|
},
|
|
}
|
|
|
|
data, err := xml.MarshalIndent(ix, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
header := []byte(xml.Header)
|
|
|
|
return ds.atomicWriteFile(path, append(header, data...))
|
|
}
|
|
|
|
func (ds *DataStore) mergeWithExistingDeviceInfo(account, device string, info *models.ServiceDeviceInfo) {
|
|
existing, _ := ds.getDeviceInfoNoLock(account, device)
|
|
if existing == nil {
|
|
return
|
|
}
|
|
|
|
if info.Name == "" {
|
|
info.Name = existing.Name
|
|
}
|
|
|
|
if info.ProductCode == "" {
|
|
info.ProductCode = existing.ProductCode
|
|
}
|
|
|
|
if info.DeviceSerialNumber == "" {
|
|
info.DeviceSerialNumber = existing.DeviceSerialNumber
|
|
}
|
|
|
|
if info.ProductSerialNumber == "" {
|
|
info.ProductSerialNumber = existing.ProductSerialNumber
|
|
}
|
|
|
|
if info.FirmwareVersion == "" {
|
|
info.FirmwareVersion = existing.FirmwareVersion
|
|
}
|
|
|
|
if info.IPAddress == "" {
|
|
info.IPAddress = existing.IPAddress
|
|
}
|
|
|
|
if info.MacAddress == "" {
|
|
info.MacAddress = existing.MacAddress
|
|
}
|
|
|
|
if info.DiscoveryMethod == "" {
|
|
info.DiscoveryMethod = existing.DiscoveryMethod
|
|
}
|
|
}
|
|
|
|
func (ds *DataStore) parseProductCode(productCode string) (string, string) {
|
|
devType := productCode
|
|
moduleType := ""
|
|
|
|
for i := 0; i < len(productCode); i++ {
|
|
if productCode[i] == ' ' {
|
|
devType = productCode[:i]
|
|
moduleType = productCode[i+1:]
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
return devType, moduleType
|
|
}
|
|
|
|
type componentXML struct {
|
|
ComponentCategory string `xml:"componentCategory"`
|
|
SoftwareVersion string `xml:"softwareVersion,omitempty"`
|
|
SerialNumber string `xml:"serialNumber,omitempty"`
|
|
}
|
|
|
|
func (ds *DataStore) buildComponentsXML(info *models.ServiceDeviceInfo) []componentXML {
|
|
var components []componentXML
|
|
for _, comp := range info.Components {
|
|
components = append(components, componentXML{
|
|
ComponentCategory: comp.Category,
|
|
SoftwareVersion: comp.SoftwareVersion,
|
|
SerialNumber: comp.SerialNumber,
|
|
})
|
|
}
|
|
|
|
if len(components) == 0 && (info.FirmwareVersion != "" || info.DeviceSerialNumber != "" || info.ProductSerialNumber != "") {
|
|
components = []componentXML{
|
|
{
|
|
ComponentCategory: "SCM",
|
|
SoftwareVersion: info.FirmwareVersion,
|
|
SerialNumber: info.DeviceSerialNumber,
|
|
},
|
|
{
|
|
ComponentCategory: "PackagedProduct",
|
|
SerialNumber: info.ProductSerialNumber,
|
|
},
|
|
}
|
|
} else if len(components) > 0 {
|
|
if info.FirmwareVersion != "" {
|
|
for i := range components {
|
|
if components[i].ComponentCategory == "SCM" {
|
|
components[i].SoftwareVersion = info.FirmwareVersion
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return components
|
|
}
|
|
|
|
// SaveAccountInfo stores account-level metadata in the datastore.
|
|
func (ds *DataStore) SaveAccountInfo(accountID string, info *models.ServiceAccountInfo) error {
|
|
if ds == nil || ds.DataDir == "" || accountID == "" {
|
|
return nil
|
|
}
|
|
|
|
dir := ds.AccountDir(accountID)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
path := filepath.Join(dir, "account.json")
|
|
|
|
data, err := json.MarshalIndent(info, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return ds.atomicWriteFile(path, data)
|
|
}
|
|
|
|
// GetAccountInfo retrieves account-level metadata from the datastore.
|
|
func (ds *DataStore) GetAccountInfo(accountID string) (*models.ServiceAccountInfo, error) {
|
|
if ds == nil || ds.DataDir == "" || accountID == "" {
|
|
return &models.ServiceAccountInfo{AccountID: accountID}, nil
|
|
}
|
|
|
|
// Try account root (canonical location)
|
|
path := filepath.Join(ds.AccountDir(accountID), "account.json")
|
|
if !exists(path) {
|
|
return &models.ServiceAccountInfo{AccountID: accountID, IsPlaceholder: true}, nil
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var info models.ServiceAccountInfo
|
|
if err := json.Unmarshal(data, &info); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &info, nil
|
|
}
|
|
|
|
// RemoveDevice removes a device and all its data from the specified account.
|
|
func (ds *DataStore) RemoveDevice(account, device string) error {
|
|
ds.fileMutex.Lock()
|
|
defer ds.fileMutex.Unlock()
|
|
|
|
dir := ds.AccountDeviceDir(account, device)
|
|
|
|
return os.RemoveAll(dir)
|
|
}
|
|
|
|
// RemoveDeviceDir is an alias for RemoveDevice for backwards compatibility.
|
|
func (ds *DataStore) RemoveDeviceDir(account, device string) error {
|
|
return ds.RemoveDevice(account, device)
|
|
}
|
|
|
|
// DeduceSourceIDs updates the source IDs in the given slice by deducing them from recents and presets.
|
|
func (ds *DataStore) DeduceSourceIDs(account, device string, sources []models.ConfiguredSource) {
|
|
// Deduce source IDs from recents and presets
|
|
deducedIDs := ds.collectDeducedIDs(account, device)
|
|
|
|
for i := range sources {
|
|
if id, ok := deducedIDs[sources[i].SourceProviderID]; ok {
|
|
sources[i].ID = id
|
|
} else if sources[i].SourceKeyType == "AUX" {
|
|
auxID := strconv.Itoa(constants.AuxProviderID)
|
|
if id, ok := deducedIDs[auxID]; ok {
|
|
sources[i].ID = id
|
|
sources[i].SourceProviderID = auxID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (ds *DataStore) collectDeducedIDs(account, device string) map[string]string {
|
|
deducedIDs := make(map[string]string)
|
|
|
|
// Check recents and presets to find source IDs for provider IDs 2, 9, 11, 25
|
|
for _, filename := range []string{constants.RecentsFile, constants.PresetsFile} {
|
|
fileContent, err := os.ReadFile(filepath.Join(ds.AccountDeviceDir(account, device), filename))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
ds.parseIDsFromFile(fileContent, deducedIDs)
|
|
}
|
|
|
|
return deducedIDs
|
|
}
|
|
|
|
func (ds *DataStore) parseIDsFromFile(fileContent []byte, deducedIDs map[string]string) {
|
|
decoder := xml.NewDecoder(bytes.NewReader(fileContent))
|
|
for {
|
|
token, _ := decoder.Token()
|
|
if token == nil {
|
|
break
|
|
}
|
|
|
|
if se, ok := token.(xml.StartElement); ok {
|
|
switch se.Name.Local {
|
|
case "source":
|
|
ds.parseSourceElement(decoder, &se, deducedIDs)
|
|
case "recent", "preset":
|
|
ds.parseRecentPresetElement(decoder, &se, deducedIDs)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (ds *DataStore) parseSourceElement(decoder *xml.Decoder, se *xml.StartElement, deducedIDs map[string]string) {
|
|
var s struct {
|
|
ID string `xml:"id,attr"`
|
|
SourceProviderID string `xml:"sourceproviderid"`
|
|
// Also check for sourceproviderid as attribute just in case
|
|
SourceProviderIDAttr string `xml:"sourceproviderid,attr"`
|
|
}
|
|
if err := decoder.DecodeElement(&s, se); err == nil {
|
|
pid := s.SourceProviderID
|
|
if pid == "" {
|
|
pid = s.SourceProviderIDAttr
|
|
}
|
|
|
|
ds.extractIDs(pid, s.ID, deducedIDs)
|
|
}
|
|
}
|
|
|
|
func (ds *DataStore) parseRecentPresetElement(decoder *xml.Decoder, se *xml.StartElement, deducedIDs map[string]string) {
|
|
var s struct {
|
|
SourceID string `xml:"sourceid"`
|
|
SourceProviderID string `xml:"sourceproviderid"`
|
|
ContentItem struct {
|
|
Source string `xml:"source,attr"`
|
|
Type string `xml:"type,attr"`
|
|
} `xml:"contentItem"`
|
|
Source struct {
|
|
SourceProviderID string `xml:"sourceproviderid"`
|
|
} `xml:"source"`
|
|
}
|
|
if err := decoder.DecodeElement(&s, se); err == nil {
|
|
pid := s.SourceProviderID
|
|
if pid == "" {
|
|
pid = s.Source.SourceProviderID
|
|
}
|
|
|
|
if pid == "" {
|
|
// For AUX, we often don't have provider ID 9 but we know its name/source
|
|
switch s.ContentItem.Source {
|
|
case constants.ProviderAux:
|
|
pid = strconv.Itoa(constants.AuxProviderID)
|
|
case constants.ProviderInternetRadio:
|
|
pid = strconv.Itoa(constants.InternetRadioProviderID)
|
|
case constants.ProviderLocalInternetRadio:
|
|
pid = strconv.Itoa(constants.LocalInternetRadioProviderID)
|
|
case constants.ProviderTunein:
|
|
pid = strconv.Itoa(constants.TuneinProviderID)
|
|
}
|
|
}
|
|
|
|
ds.extractIDs(pid, s.SourceID, deducedIDs)
|
|
}
|
|
}
|
|
|
|
func (ds *DataStore) extractIDs(providerID, sourceID string, deducedIDs map[string]string) {
|
|
if sourceID == "" || providerID == "" {
|
|
return
|
|
}
|
|
// Stick to the provider ids mentioned: 2, 9, 11, 25
|
|
switch providerID {
|
|
case strconv.Itoa(constants.InternetRadioProviderID),
|
|
strconv.Itoa(constants.AuxProviderID),
|
|
strconv.Itoa(constants.LocalInternetRadioProviderID),
|
|
strconv.Itoa(constants.TuneinProviderID):
|
|
if _, exists := deducedIDs[providerID]; !exists {
|
|
deducedIDs[providerID] = sourceID
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetConfiguredSources retrieves all configured sources for the specified account and device.
|
|
func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.ConfiguredSource, error) {
|
|
ds.fileMutex.RLock()
|
|
defer ds.fileMutex.RUnlock()
|
|
|
|
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
sources := ds.getDefaultSources()
|
|
ds.DeduceSourceIDs(account, device, sources)
|
|
|
|
return sources, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
type persistentSource struct {
|
|
DisplayName string `xml:"displayName,attr,omitempty"`
|
|
ID string `xml:"id,attr,omitempty"`
|
|
Secret string `xml:"secret,attr"`
|
|
SecretType string `xml:"secretType,attr"`
|
|
Type string `xml:"type,attr,omitempty"`
|
|
CreatedOn string `xml:"createdOn,attr,omitempty"`
|
|
UpdatedOn string `xml:"updatedOn,attr,omitempty"`
|
|
SourceProviderID string `xml:"sourceproviderid,attr,omitempty"`
|
|
Credential struct {
|
|
Type string `xml:"type,attr"`
|
|
Value string `xml:",chardata"`
|
|
} `xml:"credential,omitempty"`
|
|
SourceKey struct {
|
|
Type string `xml:"type,attr"`
|
|
Account string `xml:"account,attr"`
|
|
} `xml:"sourceKey"`
|
|
}
|
|
|
|
var sourcesWrap struct {
|
|
Sources []persistentSource `xml:"source"`
|
|
}
|
|
|
|
if err := xml.Unmarshal(data, &sourcesWrap); err != nil {
|
|
return nil, fmt.Errorf("malformed sources XML at %s: %w", path, err)
|
|
}
|
|
|
|
sources := make([]models.ConfiguredSource, len(sourcesWrap.Sources))
|
|
defaults := ds.getDefaultSources()
|
|
|
|
// Pre-claim IDs already explicitly set in the file so the canonical fill
|
|
// below doesn't reuse them when multiple entries share a SourceKey.Type.
|
|
claimedIDs := make(map[string]bool, len(sourcesWrap.Sources))
|
|
for i := range sourcesWrap.Sources {
|
|
if id := sourcesWrap.Sources[i].ID; id != "" {
|
|
claimedIDs[id] = true
|
|
}
|
|
}
|
|
|
|
for i := range sourcesWrap.Sources {
|
|
ps := &sourcesWrap.Sources[i]
|
|
s := &sources[i]
|
|
|
|
s.DisplayName = ps.DisplayName
|
|
s.ID = ps.ID
|
|
s.Secret = ps.Secret
|
|
s.SecretType = ps.SecretType
|
|
s.Type = ps.Type
|
|
s.CreatedOn = ps.CreatedOn
|
|
s.UpdatedOn = ps.UpdatedOn
|
|
s.SourceProviderID = ps.SourceProviderID
|
|
s.SourceKey.Type = ps.SourceKey.Type
|
|
s.SourceKey.Account = ps.SourceKey.Account
|
|
|
|
// Prioritize Credential element if present, otherwise use secret/secretType attributes
|
|
if ps.Credential.Value != "" {
|
|
s.Secret = ps.Credential.Value
|
|
s.SecretType = ps.Credential.Type
|
|
}
|
|
|
|
// Ensure Secret/SecretType values are prioritized from legacy fields if still missing
|
|
if s.Secret == "" && s.Credential.Value != "" {
|
|
s.Secret = s.Credential.Value
|
|
}
|
|
|
|
if s.SecretType == "" && s.Credential.Type != "" {
|
|
s.SecretType = s.Credential.Type
|
|
}
|
|
|
|
// Ensure SourceKey values are prioritized for legacy fields
|
|
if s.SourceKey.Type != "" {
|
|
s.SourceKeyType = s.SourceKey.Type
|
|
}
|
|
|
|
if s.SourceKey.Account != "" {
|
|
s.SourceKeyAccount = s.SourceKey.Account
|
|
}
|
|
|
|
applyCanonicalDefaults(s, defaults, claimedIDs)
|
|
|
|
// Last-resort ID for unknown providers.
|
|
if s.ID == "" {
|
|
s.ID = strconv.Itoa(2000001 + i)
|
|
}
|
|
}
|
|
|
|
return sources, nil
|
|
}
|
|
|
|
// applyCanonicalDefaults fills missing canonical ID/Type/SourceProviderID for
|
|
// known providers and repairs Type that was previously synthesized from
|
|
// SourceKey.Type (e.g. "AUX") rather than the canonical value (e.g. "Audio").
|
|
// Without this, the on-device Sources.xml — which carries only displayName +
|
|
// sourceKey — would round-trip as id="2000001+i" type="<sourceKey.Type>" and
|
|
// be rejected by the speaker as INVALID_SOURCE after migration.
|
|
//
|
|
// claimedIDs tracks which canonical IDs are already in use so that multiple
|
|
// entries with the same SourceKey.Type don't collide on the same ID.
|
|
func applyCanonicalDefaults(s *models.ConfiguredSource, defaults []models.ConfiguredSource, claimedIDs map[string]bool) {
|
|
def := findCanonicalSource(defaults, s.SourceKey.Type)
|
|
if def == nil {
|
|
return
|
|
}
|
|
|
|
if s.ID == "" && !claimedIDs[def.ID] {
|
|
s.ID = def.ID
|
|
claimedIDs[def.ID] = true
|
|
}
|
|
|
|
if s.Type == "" || s.Type == s.SourceKey.Type {
|
|
s.Type = def.Type
|
|
}
|
|
|
|
if s.SourceProviderID == "" {
|
|
s.SourceProviderID = def.SourceProviderID
|
|
}
|
|
}
|
|
|
|
// findCanonicalSource returns the default source matching the given
|
|
// SourceKey.Type, or nil if it's not one of our known providers.
|
|
func findCanonicalSource(defaults []models.ConfiguredSource, sourceKeyType string) *models.ConfiguredSource {
|
|
if sourceKeyType == "" {
|
|
return nil
|
|
}
|
|
|
|
for i := range defaults {
|
|
if defaults[i].SourceKey.Type == sourceKeyType {
|
|
return &defaults[i]
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SaveConfiguredSources saves the configured sources list for the specified account and device.
|
|
func (ds *DataStore) SaveConfiguredSources(account, device string, sources []models.ConfiguredSource) error {
|
|
ds.fileMutex.Lock()
|
|
defer ds.fileMutex.Unlock()
|
|
|
|
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
type persistentSource struct {
|
|
DisplayName string `xml:"displayName,attr,omitempty"`
|
|
ID string `xml:"id,attr,omitempty"`
|
|
Secret string `xml:"secret,attr"`
|
|
SecretType string `xml:"secretType,attr"`
|
|
Type string `xml:"type,attr,omitempty"`
|
|
CreatedOn string `xml:"createdOn,attr,omitempty"`
|
|
UpdatedOn string `xml:"updatedOn,attr,omitempty"`
|
|
SourceProviderID string `xml:"sourceproviderid,attr,omitempty"`
|
|
Credential struct {
|
|
Type string `xml:"type,attr"`
|
|
Value string `xml:",chardata"`
|
|
} `xml:"credential,omitempty"`
|
|
SourceKey struct {
|
|
Type string `xml:"type,attr"`
|
|
Account string `xml:"account,attr"`
|
|
} `xml:"sourceKey"`
|
|
}
|
|
|
|
// Deduplicate by ID before saving; first occurrence wins to preserve established data
|
|
seen := make(map[string]bool)
|
|
|
|
deduped := make([]models.ConfiguredSource, 0, len(sources))
|
|
for i := range sources {
|
|
s := &sources[i]
|
|
if s.ID != "" {
|
|
if seen[s.ID] {
|
|
continue
|
|
}
|
|
|
|
seen[s.ID] = true
|
|
}
|
|
|
|
deduped = append(deduped, *s)
|
|
}
|
|
|
|
sources = deduped
|
|
|
|
// Ensure SourceKey is populated from legacy fields if necessary before saving
|
|
// and map to persistentSource to avoid custom MarshalXML for disk storage
|
|
persistSources := make([]persistentSource, len(sources))
|
|
for i := range sources {
|
|
s := sources[i]
|
|
if s.SourceKey.Type == "" && s.SourceKeyType != "" {
|
|
s.SourceKey.Type = s.SourceKeyType
|
|
}
|
|
|
|
if s.SourceKey.Account == "" && s.SourceKeyAccount != "" {
|
|
s.SourceKey.Account = s.SourceKeyAccount
|
|
}
|
|
|
|
persistSources[i] = persistentSource{
|
|
DisplayName: s.DisplayName,
|
|
ID: s.ID,
|
|
Secret: s.Secret,
|
|
SecretType: s.SecretType,
|
|
Type: s.Type,
|
|
CreatedOn: s.CreatedOn,
|
|
UpdatedOn: s.UpdatedOn,
|
|
SourceProviderID: s.SourceProviderID,
|
|
}
|
|
|
|
// Save to Credential element as well for parity with official Bose format
|
|
if s.Secret != "" {
|
|
persistSources[i].Credential.Value = s.Secret
|
|
persistSources[i].Credential.Type = s.SecretType
|
|
} else if s.Credential.Value != "" {
|
|
persistSources[i].Credential.Value = s.Credential.Value
|
|
persistSources[i].Credential.Type = s.Credential.Type
|
|
}
|
|
|
|
if persistSources[i].Secret == "" && s.Credential.Value != "" {
|
|
persistSources[i].Secret = s.Credential.Value
|
|
}
|
|
|
|
if persistSources[i].SecretType == "" && s.Credential.Type != "" {
|
|
persistSources[i].SecretType = s.Credential.Type
|
|
}
|
|
|
|
persistSources[i].SourceKey.Type = s.SourceKey.Type
|
|
persistSources[i].SourceKey.Account = s.SourceKey.Account
|
|
}
|
|
|
|
wrap := struct {
|
|
XMLName xml.Name `xml:"sources"`
|
|
Sources []persistentSource `xml:"source"`
|
|
}{
|
|
Sources: persistSources,
|
|
}
|
|
|
|
data, err := xml.MarshalIndent(wrap, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
header := []byte(xml.Header)
|
|
|
|
return ds.atomicWriteFile(path, append(header, data...))
|
|
}
|
|
|
|
// updateDeviceMappings creates bidirectional mappings for device resolution
|
|
func (ds *DataStore) updateDeviceMappings(info models.ServiceDeviceInfo) {
|
|
ds.idMutex.Lock()
|
|
defer ds.idMutex.Unlock()
|
|
|
|
deviceID := info.DeviceID
|
|
macAddress := info.MacAddress
|
|
deviceSerial := info.DeviceSerialNumber
|
|
|
|
// If device is stored with MAC as deviceID and has a serial, create backward mapping
|
|
if isMACAddressFormat(deviceID) && deviceSerial != "" && deviceSerial != deviceID {
|
|
ds.deviceMappings[deviceSerial] = deviceID
|
|
}
|
|
|
|
// If device is stored with serial as deviceID and has a MAC, create forward mapping
|
|
if !isMACAddressFormat(deviceID) && macAddress != "" {
|
|
ds.deviceMappings[macAddress] = deviceID
|
|
// Also store normalized MAC version
|
|
normalizedMAC := normalizeMAC(macAddress)
|
|
if normalizedMAC != macAddress {
|
|
ds.deviceMappings[normalizedMAC] = deviceID
|
|
}
|
|
}
|
|
}
|
|
|
|
// UpdateMapping maintains backward compatibility for external callers
|
|
func (ds *DataStore) UpdateMapping(mac, serial string) {
|
|
if mac == "" || serial == "" {
|
|
return
|
|
}
|
|
|
|
ds.idMutex.Lock()
|
|
defer ds.idMutex.Unlock()
|
|
|
|
// In the new system, MAC addresses are preferred as deviceIDs
|
|
// So map the serial TO the MAC (reverse of old system)
|
|
ds.deviceMappings[serial] = mac
|
|
|
|
// Also map MAC to serial for any remaining legacy code
|
|
ds.deviceMappings[mac] = serial
|
|
|
|
normalizedMAC := normalizeMAC(mac)
|
|
if normalizedMAC != mac {
|
|
ds.deviceMappings[normalizedMAC] = serial
|
|
}
|
|
}
|
|
|
|
// GenerateSerialSecret generates a base64 encoded JSON object with the specified serial.
|
|
func GenerateSerialSecret(serial string) string {
|
|
m := map[string]string{"serial": serial}
|
|
|
|
b, err := json.Marshal(m)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
return base64.StdEncoding.EncodeToString(b)
|
|
}
|
|
|
|
// GetDefaultSources returns the list of default sources.
|
|
func (ds *DataStore) GetDefaultSources() []models.ConfiguredSource {
|
|
return ds.getDefaultSources()
|
|
}
|
|
|
|
func (ds *DataStore) getDefaultSources() []models.ConfiguredSource {
|
|
sources := []models.ConfiguredSource{
|
|
{
|
|
ID: "10001",
|
|
DisplayName: "AUX IN",
|
|
SourceKeyType: constants.ProviderAux,
|
|
SourceKeyAccount: constants.ProviderAux,
|
|
Type: "Audio",
|
|
Status: "READY",
|
|
CreatedOn: "2015-03-11T19:12:38.000+00:00",
|
|
UpdatedOn: "2015-03-11T19:12:38.000+00:00",
|
|
},
|
|
{
|
|
ID: "10002",
|
|
DisplayName: "",
|
|
SourceKeyType: constants.ProviderInternetRadio,
|
|
SourceKeyAccount: "",
|
|
SourceProviderID: strconv.Itoa(constants.InternetRadioProviderID),
|
|
Type: "Audio",
|
|
SecretType: "token",
|
|
Status: "READY",
|
|
CreatedOn: "2015-03-11T19:12:38.000+00:00",
|
|
UpdatedOn: "2015-03-11T19:12:38.000+00:00",
|
|
},
|
|
{
|
|
ID: "10003",
|
|
DisplayName: "",
|
|
SourceKeyType: constants.ProviderLocalInternetRadio,
|
|
SourceKeyAccount: "",
|
|
SourceProviderID: strconv.Itoa(constants.LocalInternetRadioProviderID),
|
|
Type: "Audio",
|
|
Secret: GenerateSerialSecret("local-internet-radio"),
|
|
SecretType: "token",
|
|
Status: "READY",
|
|
CreatedOn: "2019-01-24T08:18:37.000+00:00",
|
|
UpdatedOn: "2019-02-03T18:35:45.000+00:00",
|
|
},
|
|
{
|
|
ID: "10004",
|
|
DisplayName: "",
|
|
SourceKeyType: constants.ProviderTunein,
|
|
SourceKeyAccount: "",
|
|
SourceProviderID: strconv.Itoa(constants.TuneinProviderID),
|
|
Type: "Audio",
|
|
Secret: GenerateSerialSecret("tunein"),
|
|
SecretType: "token",
|
|
Status: "READY",
|
|
CreatedOn: "2017-07-20T16:43:48.000+00:00",
|
|
UpdatedOn: "2017-07-20T16:43:48.000+00:00",
|
|
},
|
|
{
|
|
ID: "10005",
|
|
DisplayName: "",
|
|
SourceKeyType: constants.ProviderRadioBrowser,
|
|
SourceKeyAccount: "",
|
|
SourceProviderID: strconv.Itoa(constants.RadioBrowserProviderID),
|
|
Type: "Audio",
|
|
SecretType: "token",
|
|
Status: "READY",
|
|
CreatedOn: "2026-02-16T01:01:01.000+00:00",
|
|
UpdatedOn: "2026-02-16T01:01:01.000+00:00",
|
|
},
|
|
}
|
|
|
|
for i := range sources {
|
|
sources[i].SourceKey.Type = sources[i].SourceKeyType
|
|
sources[i].SourceKey.Account = sources[i].SourceKeyAccount
|
|
}
|
|
|
|
return sources
|
|
}
|
|
|
|
// isMACAddressFormat checks if a string looks like a MAC address
|
|
func isMACAddressFormat(s string) bool {
|
|
// AABBCCDDEEFF format
|
|
if len(s) == 12 {
|
|
return isHexOnly(s)
|
|
}
|
|
|
|
// AA:BB:CC:DD:EE:FF or AA-BB-CC-DD-EE-FF format
|
|
if len(s) == 17 && (strings.Contains(s, ":") || strings.Contains(s, "-")) {
|
|
s = strings.ReplaceAll(s, "-", ":")
|
|
|
|
parts := strings.Split(s, ":")
|
|
if len(parts) != 6 {
|
|
return false
|
|
}
|
|
|
|
for _, part := range parts {
|
|
if len(part) != 2 || !isHexOnly(part) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func isHexOnly(s string) bool {
|
|
for _, r := range s {
|
|
if (r < '0' || r > '9') && (r < 'A' || r > 'F') && (r < 'a' || r > 'f') {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Initialize creates the necessary directory structure for the datastore and populates ID mappings.
|
|
func (ds *DataStore) Initialize() error {
|
|
// Ensure base data directory exists
|
|
if err := os.MkdirAll(ds.DataDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create data directory: %w", err)
|
|
}
|
|
|
|
// Scan for devices to populate MAC to Serial mapping
|
|
_, err := ds.ListAllDevices()
|
|
|
|
return err
|
|
}
|
|
|
|
// GetETagForPresets returns the ETag (modification time) for the presets file for a specific device.
|
|
func (ds *DataStore) GetETagForPresets(account, device string) int64 {
|
|
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
return info.ModTime().UnixNano() / int64(time.Millisecond)
|
|
}
|
|
|
|
// HasConfiguredSources reports whether a Sources.xml file exists for the given account and device.
|
|
func (ds *DataStore) HasConfiguredSources(account, device string) bool {
|
|
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
|
_, err := os.Stat(path)
|
|
|
|
return err == nil
|
|
}
|
|
|
|
// GetETagForSources returns the ETag (modification time) for the sources file for a specific device.
|
|
func (ds *DataStore) GetETagForSources(account, device string) int64 {
|
|
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
return info.ModTime().UnixNano() / int64(time.Millisecond)
|
|
}
|
|
|
|
// GetETagForRecents returns the ETag (modification time) for the recents file for a specific device.
|
|
func (ds *DataStore) GetETagForRecents(account, device string) int64 {
|
|
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
return info.ModTime().UnixNano() / int64(time.Millisecond)
|
|
}
|
|
|
|
// GetETagForAccount returns a content hash (SHA-256) over presets, sources, and recents for the account and device.
|
|
// If device is empty, it hashes across all devices in the account.
|
|
// The default sources fingerprint is always included so that newly added defaults (e.g. Amazon)
|
|
// invalidate cached responses even when the stored Sources.xml has not changed.
|
|
func (ds *DataStore) GetETagForAccount(account, device string) string {
|
|
h := sha256.New()
|
|
|
|
// Include the default sources fingerprint so mergeDefaultSources changes are visible.
|
|
defaults := ds.GetDefaultSources()
|
|
for i := range defaults {
|
|
_, _ = io.WriteString(h, defaults[i].ID+defaults[i].SourceKeyType+defaults[i].DisplayName)
|
|
}
|
|
|
|
if device != "" {
|
|
deviceDir := ds.AccountDeviceDir(account, device)
|
|
for _, name := range []string{constants.PresetsFile, constants.SourcesFile, constants.RecentsFile} {
|
|
f, err := os.Open(filepath.Join(deviceDir, name))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
_, _ = io.Copy(h, f)
|
|
_ = f.Close()
|
|
}
|
|
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
devicesDir := ds.AccountDevicesDir(account)
|
|
|
|
// Ignore error: missing directory is treated as no devices, producing a
|
|
// stable non-empty hash rather than "" which would false-match an absent
|
|
// If-None-Match header and return 304 on the first request.
|
|
entries, _ := os.ReadDir(devicesDir)
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
deviceDir := ds.AccountDeviceDir(account, entry.Name())
|
|
for _, name := range []string{constants.PresetsFile, constants.SourcesFile, constants.RecentsFile} {
|
|
f, err := os.Open(filepath.Join(deviceDir, name))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
_, _ = io.Copy(h, f)
|
|
_ = f.Close()
|
|
}
|
|
}
|
|
}
|
|
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// Settings represents the global service settings.
|
|
type Settings struct {
|
|
ServerURL string `json:"server_url"`
|
|
HTTPServerURL string `json:"https_server_url,omitempty"`
|
|
RedactLogs bool `json:"redact_logs"`
|
|
LogBodies bool `json:"log_bodies"`
|
|
RecordInteractions bool `json:"record_interactions"`
|
|
DiscoveryInterval string `json:"discovery_interval,omitempty"`
|
|
DiscoveryEnabled bool `json:"discovery_enabled"`
|
|
DNSEnabled bool `json:"dns_enabled"`
|
|
DNSUpstream []string `json:"dns_upstream,omitempty"`
|
|
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
|
|
MirrorEnabled bool `json:"mirror_enabled"`
|
|
MirrorEndpoints []string `json:"mirror_endpoints,omitempty"`
|
|
SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"`
|
|
PreferredSource string `json:"preferred_source,omitempty"`
|
|
InternalPaths []string `json:"internal_paths,omitempty"`
|
|
Shortcuts map[string]int `json:"shortcuts,omitempty"`
|
|
SpotifyClientID string `json:"spotify_client_id,omitempty"`
|
|
SpotifyClientSecret string `json:"spotify_client_secret,omitempty"`
|
|
SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"`
|
|
AmazonClientID string `json:"amazon_client_id,omitempty"`
|
|
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
|
|
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
|
|
}
|
|
|
|
// GetSettings retrieves the global service settings.
|
|
func (ds *DataStore) GetSettings() (Settings, error) {
|
|
if ds == nil || ds.DataDir == "" {
|
|
return Settings{}, nil
|
|
}
|
|
|
|
path := filepath.Join(ds.DataDir, "settings.json")
|
|
if !exists(path) {
|
|
return Settings{}, nil
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
|
|
var settings Settings
|
|
if err := json.Unmarshal(data, &settings); err != nil {
|
|
return Settings{}, err
|
|
}
|
|
|
|
return settings, nil
|
|
}
|
|
|
|
// SaveSettings saves the global service settings.
|
|
func (ds *DataStore) SaveSettings(settings Settings) error {
|
|
if ds == nil || ds.DataDir == "" {
|
|
return nil
|
|
}
|
|
|
|
if err := os.MkdirAll(ds.DataDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create data directory: %w", err)
|
|
}
|
|
|
|
path := filepath.Join(ds.DataDir, "settings.json")
|
|
|
|
data, err := json.MarshalIndent(settings, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return ds.atomicWriteFile(path, data)
|
|
}
|
|
|
|
// SaveUsageStats saves usage statistics to the datastore.
|
|
func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
|
|
dir := filepath.Join(ds.DataDir, "stats", "usage")
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
filename := fmt.Sprintf("%d_%s.json", time.Now().UnixNano(), stats.DeviceID)
|
|
path := filepath.Join(dir, filename)
|
|
|
|
data, err := json.MarshalIndent(stats, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return ds.atomicWriteFile(path, data)
|
|
}
|
|
|
|
// SaveErrorStats saves error statistics to the datastore.
|
|
func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error {
|
|
dir := filepath.Join(ds.DataDir, "stats", "error")
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
filename := fmt.Sprintf("%d_%s.json", time.Now().UnixNano(), stats.DeviceID)
|
|
path := filepath.Join(dir, filename)
|
|
|
|
data, err := json.MarshalIndent(stats, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return ds.atomicWriteFile(path, data)
|
|
}
|
|
|
|
// AddDeviceEvent adds a device event to the in-memory event store.
|
|
func (ds *DataStore) AddDeviceEvent(deviceID string, event models.DeviceEvent) {
|
|
ds.eventMutex.Lock()
|
|
defer ds.eventMutex.Unlock()
|
|
|
|
events := ds.deviceEvents[deviceID]
|
|
events = append(events, event)
|
|
|
|
// Keep only last 100 events
|
|
if len(events) > 100 {
|
|
events = events[len(events)-100:]
|
|
}
|
|
|
|
ds.deviceEvents[deviceID] = events
|
|
}
|
|
|
|
// GetDeviceEvents retrieves all events for the specified device.
|
|
func (ds *DataStore) GetDeviceEvents(deviceID string) []models.DeviceEvent {
|
|
ds.eventMutex.RLock()
|
|
defer ds.eventMutex.RUnlock()
|
|
|
|
events, ok := ds.deviceEvents[deviceID]
|
|
if !ok {
|
|
return []models.DeviceEvent{}
|
|
}
|
|
|
|
// Return a copy to avoid race conditions if the caller modifies it
|
|
copiedEvents := make([]models.DeviceEvent, len(events))
|
|
copy(copiedEvents, events)
|
|
|
|
return copiedEvents
|
|
}
|
|
|
|
// DNSDiscoveryEntry represents a persisted DNS discovery.
|
|
type DNSDiscoveryEntry struct {
|
|
Hostname string `json:"hostname"`
|
|
FirstSeen time.Time `json:"first_seen"`
|
|
LastSeen time.Time `json:"last_seen"`
|
|
QueryCount int `json:"query_count"`
|
|
IsBoseService bool `json:"is_bose_service"`
|
|
IsIntercepted bool `json:"is_intercepted"`
|
|
RemoteAddr string `json:"remote_addr,omitempty"`
|
|
}
|
|
|
|
// SaveDNSDiscoveries saves DNS discoveries to the datastore.
|
|
func (ds *DataStore) SaveDNSDiscoveries(discoveries []DNSDiscoveryEntry) error {
|
|
if ds == nil || ds.DataDir == "" {
|
|
return nil
|
|
}
|
|
|
|
dir := filepath.Join(ds.DataDir, "dns")
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create dns directory: %w", err)
|
|
}
|
|
|
|
path := filepath.Join(dir, "discoveries.json")
|
|
|
|
// Sort by last seen descending
|
|
sort.Slice(discoveries, func(i, j int) bool {
|
|
return discoveries[i].LastSeen.After(discoveries[j].LastSeen)
|
|
})
|
|
|
|
data, err := json.MarshalIndent(discoveries, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return ds.atomicWriteFile(path, data)
|
|
}
|
|
|
|
// LoadDNSDiscoveries loads DNS discoveries from the datastore.
|
|
func (ds *DataStore) LoadDNSDiscoveries() ([]DNSDiscoveryEntry, error) {
|
|
if ds == nil || ds.DataDir == "" {
|
|
return []DNSDiscoveryEntry{}, nil
|
|
}
|
|
|
|
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
|
|
if !exists(path) {
|
|
return []DNSDiscoveryEntry{}, nil
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var discoveries []DNSDiscoveryEntry
|
|
if err := json.Unmarshal(data, &discoveries); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return discoveries, nil
|
|
}
|
|
|
|
// ClearDNSDiscoveries removes all DNS discoveries from the datastore.
|
|
func (ds *DataStore) ClearDNSDiscoveries() error {
|
|
if ds == nil || ds.DataDir == "" {
|
|
return nil
|
|
}
|
|
|
|
path := filepath.Join(ds.DataDir, "dns", "discoveries.json")
|
|
if !exists(path) {
|
|
return nil
|
|
}
|
|
|
|
return os.Remove(path)
|
|
}
|
|
|
|
// groupFilePath returns the on-disk path for a group file.
|
|
func (ds *DataStore) groupFilePath(account, groupID string) string {
|
|
return filepath.Join(ds.AccountDevicesDir(account), "Group_"+groupID+".xml")
|
|
}
|
|
|
|
// generateGroupID returns a unique 7-digit group ID that has no existing file.
|
|
func (ds *DataStore) generateGroupID(account string) string {
|
|
for {
|
|
id := fmt.Sprintf("%07d", rand.Int63n(10_000_000)) //nolint:gosec
|
|
if !exists(ds.groupFilePath(account, id)) {
|
|
return id
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetGroupForDevice returns the group containing the given device, or nil if ungrouped.
|
|
func (ds *DataStore) GetGroupForDevice(account, deviceID string) (*models.Group, error) {
|
|
ds.fileMutex.RLock()
|
|
defer ds.fileMutex.RUnlock()
|
|
|
|
dir := ds.AccountDevicesDir(account)
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, ErrGroupNotFound
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasPrefix(e.Name(), "Group_") || !strings.HasSuffix(e.Name(), ".xml") {
|
|
continue
|
|
}
|
|
|
|
data, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
|
|
if readErr != nil {
|
|
continue
|
|
}
|
|
|
|
var g models.Group
|
|
if unmarshalErr := xml.Unmarshal(data, &g); unmarshalErr != nil {
|
|
continue
|
|
}
|
|
|
|
for _, role := range g.Roles.Roles {
|
|
if role.DeviceID == deviceID {
|
|
return &g, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, ErrGroupNotFound
|
|
}
|
|
|
|
// AddGroup saves a new group to disk and returns its generated ID.
|
|
func (ds *DataStore) AddGroup(account string, group *models.Group) (string, error) {
|
|
ds.fileMutex.Lock()
|
|
defer ds.fileMutex.Unlock()
|
|
|
|
dir := ds.AccountDevicesDir(account)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
id := ds.generateGroupID(account)
|
|
group.ID = id
|
|
|
|
data, err := xml.MarshalIndent(group, "", " ")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return id, ds.atomicWriteFile(ds.groupFilePath(account, id), append([]byte(xml.Header), data...))
|
|
}
|
|
|
|
// ModifyGroup updates the name of an existing group and returns the updated group.
|
|
func (ds *DataStore) ModifyGroup(account, groupID, newName string) (*models.Group, error) {
|
|
ds.fileMutex.Lock()
|
|
defer ds.fileMutex.Unlock()
|
|
|
|
path := ds.groupFilePath(account, groupID)
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("group %s not found", groupID)
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
var g models.Group
|
|
if xmlErr := xml.Unmarshal(data, &g); xmlErr != nil {
|
|
return nil, xmlErr
|
|
}
|
|
|
|
g.Name = newName
|
|
|
|
updated, err := xml.MarshalIndent(&g, "", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := ds.atomicWriteFile(path, append([]byte(xml.Header), updated...)); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &g, nil
|
|
}
|
|
|
|
// DeleteGroup removes a group from disk.
|
|
func (ds *DataStore) DeleteGroup(account, groupID string) error {
|
|
ds.fileMutex.Lock()
|
|
defer ds.fileMutex.Unlock()
|
|
|
|
err := os.Remove(ds.groupFilePath(account, groupID))
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("group %s not found", groupID)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// SaveTuneInFavorite records a TuneIn station as favorited by creating a marker file.
|
|
// File presence indicates the station is a favorite; no content is stored.
|
|
func (ds *DataStore) SaveTuneInFavorite(stationID string) error {
|
|
if ds == nil || ds.DataDir == "" || stationID == "" {
|
|
return nil
|
|
}
|
|
|
|
dir := ds.safeJoin("tunein", "favorites")
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(ds.safeJoin("tunein", "favorites", stationID), nil, 0644)
|
|
}
|
|
|
|
// DeleteTuneInFavorite removes a previously saved TuneIn favorite marker file.
|
|
// Returns nil if the station was not favorited.
|
|
func (ds *DataStore) DeleteTuneInFavorite(stationID string) error {
|
|
if ds == nil || ds.DataDir == "" || stationID == "" {
|
|
return nil
|
|
}
|
|
|
|
err := os.Remove(ds.safeJoin("tunein", "favorites", stationID))
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
|
|
return err
|
|
}
|