Files
Bose-SoundTouch/pkg/service/datastore/datastore.go
T
Tobias Gesellchen f33a306c47 chore: suppress math/rand Semgrep finding on two non-security use sites
Addresses the Semgrep finding on PR #599
(go.lang.security.audit.crypto.math_random.math-random-used) on the
update-check jitter delay, and applies the same treatment to the #419
activity-log filename suffix, which has the same non-security shape but
predates this PR's diff so it wasn't flagged.

Neither value is ever compared, kept secret, or otherwise security-
sensitive (a sleep duration and a filename-uniqueness suffix), so
crypto/rand would only add error-handling overhead for no real benefit.
Suppressed with the same // nosemgrep: <rule-id> pattern already used in
the mock-amazon/mock-spotify/mock-tunein servers, mirroring the existing
//nolint:gosec on the same lines.

Refs #591
2026-08-09 01:22:03 +02:00

3187 lines
94 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"
"log"
"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
// rootMu guards lazy initialisation of root.
rootMu sync.Mutex
// root is an os.Root anchored at baseDir. All filesystem operations within
// the datastore go through it, so ".." or absolute paths in
// caller-supplied components cannot escape the root — the Go runtime
// enforces containment regardless of what safeJoin's output looks like.
// Lazily opened so NewDataStore stays a pure constructor.
root *os.Root
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...)
}
// getRoot returns the lazily-opened *os.Root anchored at baseDir. The root is
// created on first call after MkdirAll-ing baseDir; subsequent calls return
// the cached handle. Filesystem operations performed via the returned root
// cannot escape baseDir even if the relative path passed to them is malicious.
func (ds *DataStore) getRoot() (*os.Root, error) {
ds.rootMu.Lock()
defer ds.rootMu.Unlock()
if ds.root != nil {
return ds.root, nil
}
if ds.baseDir == "" {
return nil, fmt.Errorf("datastore: baseDir not configured")
}
if err := os.MkdirAll(ds.baseDir, 0755); err != nil {
return nil, fmt.Errorf("datastore: ensure baseDir %s: %w", ds.baseDir, err)
}
r, err := os.OpenRoot(ds.baseDir)
if err != nil {
return nil, fmt.Errorf("datastore: open root at %s: %w", ds.baseDir, err)
}
ds.root = r
return r, nil
}
// Close releases any open filesystem handles held by the datastore. Safe to
// call on a never-used DataStore.
func (ds *DataStore) Close() error {
ds.rootMu.Lock()
defer ds.rootMu.Unlock()
if ds.root == nil {
return nil
}
err := ds.root.Close()
ds.root = nil
return err
}
// rootRel converts a path produced by safeJoin (or by filepath.Join over
// ds.DataDir) into the form expected by *os.Root methods — relative to
// baseDir, no leading separator. Tolerates both absolute paths and paths
// whose root is the relative ds.DataDir.
//
// Returns "." for baseDir itself.
func (ds *DataStore) rootRel(absPath string) (string, error) {
// If the input is relative, absolutise so the comparison with baseDir
// works regardless of how DataDir was originally configured.
if !filepath.IsAbs(absPath) {
a, err := filepath.Abs(absPath)
if err != nil {
return "", fmt.Errorf("datastore: absolutise %s: %w", absPath, err)
}
absPath = a
}
if absPath == ds.baseDir {
return ".", nil
}
rel, err := filepath.Rel(ds.baseDir, absPath)
if err != nil {
return "", fmt.Errorf("datastore: %s is outside baseDir: %w", absPath, err)
}
if rel == "." || rel == "" {
return ".", nil
}
if strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("datastore: %s is outside baseDir", absPath)
}
return rel, nil
}
// rootStat is the os.Stat equivalent for a path under baseDir.
func (ds *DataStore) rootStat(absPath string) (os.FileInfo, error) {
r, err := ds.getRoot()
if err != nil {
return nil, err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return nil, err
}
return r.Stat(rel)
}
// rootReadFile is the os.ReadFile equivalent.
func (ds *DataStore) rootReadFile(absPath string) ([]byte, error) {
r, err := ds.getRoot()
if err != nil {
return nil, err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return nil, err
}
return r.ReadFile(rel)
}
// rootWriteFile is the os.WriteFile equivalent.
func (ds *DataStore) rootWriteFile(absPath string, data []byte, perm os.FileMode) error {
r, err := ds.getRoot()
if err != nil {
return err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return err
}
return r.WriteFile(rel, data, perm)
}
// rootMkdirAll is the os.MkdirAll equivalent.
func (ds *DataStore) rootMkdirAll(absPath string, perm os.FileMode) error {
r, err := ds.getRoot()
if err != nil {
return err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return err
}
if rel == "." {
return nil
}
return r.MkdirAll(rel, perm)
}
// rootRemove is the os.Remove equivalent.
func (ds *DataStore) rootRemove(absPath string) error {
r, err := ds.getRoot()
if err != nil {
return err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return err
}
return r.Remove(rel)
}
// rootRemoveAll is the os.RemoveAll equivalent.
func (ds *DataStore) rootRemoveAll(absPath string) error {
r, err := ds.getRoot()
if err != nil {
return err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return err
}
return r.RemoveAll(rel)
}
// rootRename is the os.Rename equivalent. Both paths must be under baseDir.
func (ds *DataStore) rootRename(oldAbs, newAbs string) error {
r, err := ds.getRoot()
if err != nil {
return err
}
oldRel, err := ds.rootRel(oldAbs)
if err != nil {
return err
}
newRel, err := ds.rootRel(newAbs)
if err != nil {
return err
}
return r.Rename(oldRel, newRel)
}
// rootReadDir lists the entries in absPath. Equivalent to os.ReadDir,
// including the same alphabetical-by-name sort order — *os.File.ReadDir(-1)
// returns entries in directory order, but callers (and existing tests)
// depend on the sorted contract that os.ReadDir documents.
func (ds *DataStore) rootReadDir(absPath string) ([]os.DirEntry, error) {
r, err := ds.getRoot()
if err != nil {
return nil, err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return nil, err
}
f, err := r.Open(rel)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
entries, err := f.ReadDir(-1)
if err != nil {
return entries, err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
return entries, nil
}
// rootExists is true when absPath exists under baseDir.
func (ds *DataStore) rootExists(absPath string) bool {
_, err := ds.rootStat(absPath)
return err == nil
}
// ReadDirUnderBase lists the entries in absPath, which must resolve to a
// directory under the datastore baseDir. Cross-package callers (marge,
// handlers, …) use this instead of os.ReadDir so that the underlying
// *os.Root sanitises the path against traversal.
func (ds *DataStore) ReadDirUnderBase(absPath string) ([]os.DirEntry, error) {
return ds.rootReadDir(absPath)
}
// MkdirAllUnderBase creates a directory tree under baseDir.
func (ds *DataStore) MkdirAllUnderBase(absPath string, perm os.FileMode) error {
return ds.rootMkdirAll(absPath, perm)
}
// WriteFileUnderBase atomically writes data to absPath, which must be under
// baseDir.
func (ds *DataStore) WriteFileUnderBase(absPath string, data []byte, perm os.FileMode) error {
return ds.rootWriteFile(absPath, data, perm)
}
// rootOpen is the os.Open equivalent for a path under baseDir. The caller
// owns the returned *os.File and must Close it.
func (ds *DataStore) rootOpen(absPath string) (*os.File, error) {
r, err := ds.getRoot()
if err != nil {
return nil, err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return nil, err
}
return r.Open(rel)
}
// 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 !ds.rootExists(accountsDir) {
return []string{"default"}, nil
}
entries, err := ds.rootReadDir(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 := ds.rootStat(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 := ds.rootStat(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 := ds.rootReadFile(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"`
CreatedOn string `xml:"createdOn,omitempty"`
UpdatedOn string `xml:"updatedOn,omitempty"`
}
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,
CreatedOn: info.CreatedOn,
UpdatedOn: info.UpdatedOn,
}
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{}
type seenEntry struct {
index int
account string
}
seenIDs := make(map[string]seenEntry)
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
// Sort "default" to the back so a real-account entry always
// wins the first-seen race. "default" exists as a pre-pair
// placeholder; once the speaker pairs with a real account it
// becomes orphan state. We don't try to pick a winner among
// multiple real accounts via mtime or other proxies — the
// authoritative signal is the URL of the speaker's incoming
// PUT, which only the live handler sees. Stale real-account
// entries are flagged as findings by the consistency check
// instead.
accounts := make([]os.DirEntry, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
accounts = append(accounts, e)
}
}
sort.SliceStable(accounts, func(i, j int) bool {
ai, aj := accounts[i].Name(), accounts[j].Name()
if ai == accountIDDefault && aj != accountIDDefault {
return false
}
if aj == accountIDDefault && ai != accountIDDefault {
return true
}
return ai < aj
})
for _, acc := range accounts {
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
}
entry, alreadySeen := seenIDs[key]
if !alreadySeen {
devices = append(devices, info)
seenIDs[key] = seenEntry{index: len(devices) - 1, account: info.AccountID}
continue
}
// "default" never replaces a real-account entry.
// But when two "default" entries collide across data dirs,
// prefer the one with a non-empty name (more information).
if info.AccountID == accountIDDefault {
if entry.account == accountIDDefault && devices[entry.index].Name == "" && info.Name != "" {
devices[entry.index] = info
seenIDs[key] = seenEntry{index: entry.index, account: info.AccountID}
}
continue
}
// First real-account encountered wins (sort is
// stable + alphabetical so the result is
// deterministic across runs). Don't pick a different
// winner here — the consistency check enumerates all
// the duplicate account dirs for the operator to
// clean up.
if entry.account == accountIDDefault {
devices[entry.index] = info
seenIDs[key] = seenEntry{index: entry.index, account: info.AccountID}
}
}
}
}
return devices, nil
}
// AllAccountsForDevice returns every account directory that contains a
// device with the given deviceID, in their on-disk order. Used by the
// consistency check to enumerate stale account entries left behind
// when the speaker was re-paired to a different account.
func (ds *DataStore) AllAccountsForDevice(deviceID string) []string {
if deviceID == "" {
return nil
}
var hits []string
seen := map[string]bool{}
for _, dir := range ds.getPossibleDataDirs() {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, acc := range entries {
if !acc.IsDir() {
continue
}
devicePath := filepath.Join(dir, acc.Name(), "devices", deviceID)
if _, err := os.Stat(devicePath); err != nil {
continue
}
if !seen[acc.Name()] {
seen[acc.Name()] = true
hits = append(hits, acc.Name())
}
}
}
return hits
}
// accountIDDefault is the placeholder account id assigned to records
// that exist before a speaker has been paired with a real Marge
// account. Treated as fallback in ListAllDevices and skipped in
// consistency checks when a real-account entry exists for the same
// device.
const accountIDDefault = "default"
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"`
CreatedOn string `xml:"createdOn,omitempty"`
UpdatedOn string `xml:"updatedOn,omitempty"`
}
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) {
presets, needsRewrite, err := ds.readPresetsLocked(account, device)
if err != nil {
return nil, err
}
if needsRewrite {
log.Printf("[Datastore] Presets.xml for device %s used legacy <ContentItem> format; rewriting in canonical form", sanitizeLog(device))
if werr := ds.SavePresets(account, device, presets); werr != nil {
log.Printf("[Datastore] failed to rewrite normalised Presets.xml for device %s: %s", sanitizeLog(device), sanitizeErr(werr))
}
}
return presets, nil
}
// readPresetsLocked is the locked read half of GetPresets. It returns the
// parsed presets and a flag indicating whether the on-disk file used the
// legacy <ContentItem> (capital C) format that needs rewriting.
func (ds *DataStore) readPresetsLocked(account, device string) ([]models.ServicePreset, bool, error) {
ds.fileMutex.RLock()
defer ds.fileMutex.RUnlock()
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
data, err := ds.rootReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return []models.ServicePreset{}, false, nil
}
return nil, false, err
}
// An empty / 0-byte Presets.xml (e.g. truncated by an unclean power-cut on
// the speaker's NAND) is treated as "no presets" rather than a hard parse
// error, so the device-level /presets endpoint returns an empty list
// instead of HTTP 500. See #458.
if len(bytes.TrimSpace(data)) == 0 {
log.Printf("[Datastore] readPresetsLocked: empty/0-byte Presets.xml at %s — treating as no presets (#458)", sanitizeLog(path))
return []models.ServicePreset{}, false, nil
}
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"`
}
// encoding/xml is case-sensitive. Older AfterTouch versions (and raw
// speaker XML) used <ContentItem> (capital C); normalise to lowercase
// before unmarshaling so legacy files are parsed correctly.
normalized := bytes.ReplaceAll(data, []byte("<ContentItem"), []byte("<contentItem"))
normalized = bytes.ReplaceAll(normalized, []byte("</ContentItem>"), []byte("</contentItem>"))
needsRewrite := !bytes.Equal(normalized, data)
if err := xml.Unmarshal(normalized, &presetsWrap); err != nil {
log.Printf("[Datastore] readPresetsLocked: malformed Presets.xml at %s (%s) — treating as no presets (#458)", sanitizeLog(path), sanitizeErr(err))
return []models.ServicePreset{}, false, nil
}
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: repairLeakedSource(account, device, "preset "+p.ID, p.ContentItem.Source, p.SourceID, ds),
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, needsRewrite, nil
}
// repairLeakedSource quietly substitutes the speaker-perspective
// SourceKeyType for a persisted Source that has the protocol-level
// "Audio" leak signature from the legacy syncPresets/syncRecents path.
// Critically it only repairs the *leak* — when persisted Source carries
// a non-leak symbolic value, even one that disagrees with the current
// Sources.xml mapping for the same SourceID, we leave it alone. That's
// what protects the GH-343 case: a TUNEIN preset whose SourceID was
// re-classified to RADIOPLAYER in Sources.xml must stay TUNEIN here,
// otherwise the speaker's previously-stored intent gets silently
// overwritten by the stale source-list entry.
//
// Speaker is the source of truth: this only un-rots data that was
// never the speaker's perspective to begin with.
func repairLeakedSource(account, device, label, persistedSource, sourceID string, ds *DataStore) string {
if !isLeakedSourceValue(persistedSource) {
return persistedSource
}
if sourceID == "" {
return persistedSource
}
sources, err := ds.getConfiguredSourcesLocked(account, device)
if err != nil {
return persistedSource
}
for i := range sources {
if sources[i].ID == sourceID && sources[i].SourceKeyType != "" {
log.Printf("[Datastore] %s: repaired leaked source %q -> %q via sourceid=%s (account=%s device=%s) — likely written by the pre-fix marge.syncPresets/syncRecents path; speaker's perspective is now restored on read",
sanitizeLog(label), sanitizeLog(persistedSource), sanitizeLog(sources[i].SourceKeyType), sanitizeLog(sourceID), sanitizeLog(account), sanitizeLog(device))
return sources[i].SourceKeyType
}
}
return persistedSource
}
// isLeakedSourceValue identifies the known protocol-level leak signature
// (the upstream <source type="Audio"> attribute that the old
// marge.syncPresets persisted into ServicePreset.Source). Empty Source
// is also treated as a leak so the resolve fires for legacy entries
// missing the attribute entirely.
func isLeakedSourceValue(s string) bool {
return s == "" || s == "Audio"
}
// getConfiguredSourcesLocked is GetConfiguredSources without the
// fileMutex.RLock() — callers must already hold it. Used by
// repairLeakedSource from within GetPresets/GetRecents which already
// hold the lock.
func (ds *DataStore) getConfiguredSourcesLocked(account, device string) ([]models.ConfiguredSource, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
data, err := ds.rootReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return ds.getDefaultSources(), nil
}
return nil, err
}
type persistentSource struct {
ID string `xml:"id,attr,omitempty"`
Type string `xml:"type,attr,omitempty"`
SourceKeyType string `xml:"-"`
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, err
}
out := make([]models.ConfiguredSource, 0, len(sourcesWrap.Sources))
for i := range sourcesWrap.Sources {
ps := sourcesWrap.Sources[i]
out = append(out, models.ConfiguredSource{
ID: ps.ID,
Type: ps.Type,
SourceKeyType: ps.SourceKey.Type,
})
}
return out, 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 := ds.rootMkdirAll(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
// Preserve the speaker's IsPresetable verdict instead of forcing
// "true". The speaker firmware sets isPresetable="false" for
// content it can't independently recall later (e.g. Spotify Connect
// pushes from a phone — see GH-235). Hard-coding "true" makes the
// on-disk XML look valid while the speaker still refuses to play
// the preset, which leaves users debugging a phantom "stored but
// won't play" state. Default to "true" only when the caller
// supplied nothing.
pxml.ContentItem.IsPresetable = p.IsPresetable
if pxml.ContentItem.IsPresetable == "" {
pxml.ContentItem.IsPresetable = "true"
}
if pxml.ContentItem.IsPresetable == "false" {
log.Printf("[Datastore] SavePresets: storing preset %s as isPresetable=false (account=%s device=%s source=%s) — speaker firmware marked this content non-recallable; preset will appear on the speaker but pressing it will not play",
sanitizeLog(pxml.ID), sanitizeLog(account), sanitizeLog(device), sanitizeLog(p.Source))
}
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...))
}
// atomicWriteFile writes data to filename atomically AND durably: it writes a
// temp file, fsyncs it, renames it into place, then fsyncs the parent
// directory. Without the fsyncs an unclean power-cut on a journaling NAND
// filesystem (UBIFS, the speaker's /mnt/nv) can leave the renamed file present
// but 0 bytes — the rename was journalled but the data blocks were never
// flushed. See #458.
func (ds *DataStore) atomicWriteFile(filename string, data []byte) error {
perm := os.FileMode(0644)
tempFile := filename + ".tmp"
if err := ds.rootWriteFileSync(tempFile, data, perm); err != nil {
return err
}
if err := ds.rootRename(tempFile, filename); err != nil {
return err
}
// Fsync the parent directory so the rename itself survives a power-cut.
// Best-effort: not every filesystem permits directory fsync, and the data +
// rename have already succeeded by this point.
ds.rootSyncDir(filepath.Dir(filename))
return nil
}
// rootWriteFileSync writes data to absPath (truncating any existing file) and
// fsyncs the file before returning, so the contents are on stable storage. This
// is the durable equivalent of rootWriteFile.
func (ds *DataStore) rootWriteFileSync(absPath string, data []byte, perm os.FileMode) error {
r, err := ds.getRoot()
if err != nil {
return err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return err
}
f, err := r.OpenFile(rel, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err
}
if _, werr := f.Write(data); werr != nil {
_ = f.Close()
return werr
}
if serr := f.Sync(); serr != nil {
_ = f.Close()
return serr
}
return f.Close()
}
// rootSyncDir fsyncs the directory at absDir so a preceding create/rename is
// durable. Best-effort: directory fsync isn't supported on every filesystem, so
// failures are logged and swallowed rather than failing an already-successful
// write.
func (ds *DataStore) rootSyncDir(absDir string) {
d, err := ds.rootOpen(absDir)
if err != nil {
log.Printf("[Datastore] rootSyncDir: open %s failed (best-effort): %s", sanitizeLog(absDir), sanitizeErr(err))
return
}
if serr := d.Sync(); serr != nil {
log.Printf("[Datastore] rootSyncDir: fsync %s failed (best-effort): %s", sanitizeLog(absDir), sanitizeErr(serr))
}
_ = d.Close()
}
// 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 := ds.rootReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return []models.ServiceRecent{}, nil
}
return nil, err
}
// An empty / 0-byte Recents.xml (e.g. truncated by an unclean power-cut) is
// treated as "no recents" rather than a hard parse error, so the
// device-level /recents endpoint returns an empty list instead of HTTP 500.
// See #458.
if len(bytes.TrimSpace(data)) == 0 {
log.Printf("[Datastore] GetRecents: empty/0-byte Recents.xml at %s — treating as no recents (#458)", sanitizeLog(path))
return []models.ServiceRecent{}, nil
}
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 {
log.Printf("[Datastore] GetRecents: malformed Recents.xml at %s (%s) — treating as no recents (#458)", sanitizeLog(path), sanitizeErr(err))
return []models.ServiceRecent{}, nil
}
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)
}
r.Source = repairLeakedSource(account, device, "recent "+r.ID, r.Source, r.SourceID, ds)
}
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 := ds.rootMkdirAll(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"`
}
// Deduplicate by ID before saving; first occurrence wins. A speaker<->marge
// recents sync can otherwise re-store the same recent (same ID) multiple
// times — it then crowds the capped list and evicts other sources from the
// speaker's recents. Mirrors SaveConfiguredSources.
seen := make(map[string]bool)
deduped := make([]models.ServiceRecent, 0, len(recents))
for i := range recents {
if id := recents[i].ID; id != "" {
if seen[id] {
continue
}
seen[id] = true
}
deduped = append(deduped, recents[i])
}
recents = deduped
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 := ds.rootMkdirAll(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"`
CreatedOn string `xml:"createdOn,omitempty"`
UpdatedOn string `xml:"updatedOn,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,
CreatedOn: info.CreatedOn,
UpdatedOn: info.UpdatedOn,
}
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
}
// CreatedOn is set once at first persistence and never re-derived
// from inbound data — preserve unconditionally so the
// "first-paired" timestamp survives renames, IP refreshes, etc.
// UpdatedOn is the opposite: every write that reaches here is by
// definition an update, so callers that want it refreshed must
// set it explicitly. If they didn't, fall back to the existing
// value (better than a regression to empty).
if existing.CreatedOn != "" {
info.CreatedOn = existing.CreatedOn
}
if info.UpdatedOn == "" {
info.UpdatedOn = existing.UpdatedOn
}
}
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 := ds.rootMkdirAll(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 !ds.rootExists(path) {
return &models.ServiceAccountInfo{AccountID: accountID, IsPlaceholder: true}, nil
}
data, err := ds.rootReadFile(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 ds.rootRemoveAll(dir)
}
// RemoveDeviceDir is an alias for RemoveDevice for backwards compatibility.
func (ds *DataStore) RemoveDeviceDir(account, device string) error {
return ds.RemoveDevice(account, device)
}
// MoveDevice atomically moves a device directory from one account to another
// on the same filesystem. If the target account directory doesn't exist it is
// created. Returns an error if the rename fails (leaving the source intact).
func (ds *DataStore) MoveDevice(oldAccount, newAccount, deviceID string) error {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
oldDir := ds.AccountDeviceDir(oldAccount, deviceID)
newDir := ds.AccountDeviceDir(newAccount, deviceID)
newAccountDir := filepath.Dir(newDir)
if err := ds.rootMkdirAll(newAccountDir, 0755); err != nil {
return err
}
return ds.rootRename(oldDir, newDir)
}
// 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 := ds.rootReadFile(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)
// defaultSources is the fallback used whenever there is no usable
// Sources.xml: file missing (normal for a fresh device) or present but
// empty / 0-byte / unparseable. The latter happens when an unclean
// power-cut truncates a not-yet-flushed datastore write on the speaker's
// NAND; treating it like "missing" lets /full re-serve the managed defaults
// so the speaker self-heals instead of dropping all its sources. See #458.
defaultSources := func() []models.ConfiguredSource {
sources := ds.getInitialSources()
ds.DeduceSourceIDs(account, device, sources)
return sources
}
data, err := ds.rootReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return defaultSources(), nil
}
return nil, err
}
if len(bytes.TrimSpace(data)) == 0 {
log.Printf("[Datastore] GetConfiguredSources: empty/0-byte Sources.xml at %s — treating as missing, serving defaults (#458)", sanitizeLog(path))
return defaultSources(), nil
}
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 {
log.Printf("[Datastore] GetConfiguredSources: malformed Sources.xml at %s (%s) — treating as missing, serving defaults (#458)", sanitizeLog(path), sanitizeErr(err))
return defaultSources(), nil
}
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 := ds.rootMkdirAll(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...))
}
// DeleteSourceByID removes the source with the given ID from the device's
// Sources.xml. It is a no-op if the source is not present.
func (ds *DataStore) DeleteSourceByID(account, device, sourceID string) error {
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
return err
}
filtered := make([]models.ConfiguredSource, 0, len(sources))
for i := range sources {
if sources[i].ID != sourceID {
filtered = append(filtered, sources[i])
}
}
if len(filtered) == len(sources) {
return nil
}
return ds.SaveConfiguredSources(account, device, filtered)
}
// DeleteSourceByType removes the source with the given SourceKeyType from
// the device's Sources.xml. Returns an error if more than one source matches
// (to prevent accidental bulk deletion). No-op if no match is found.
func (ds *DataStore) DeleteSourceByType(account, device, sourceKeyType string) error {
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
return err
}
var matches int
for i := range sources {
if sources[i].SourceKeyType == sourceKeyType {
matches++
}
}
if matches > 1 {
return fmt.Errorf("found %d sources with type %q; use an ID-based deletion for precision", matches, sourceKeyType)
}
if matches == 0 {
return nil
}
filtered := make([]models.ConfiguredSource, 0, len(sources))
for i := range sources {
if sources[i].SourceKeyType != sourceKeyType {
filtered = append(filtered, sources[i])
}
}
return ds.SaveConfiguredSources(account, device, filtered)
}
// 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()
}
// GetInitialSources returns the default sources for a brand-new device:
// the full default list minus the legacy INTERNET_RADIO stub.
// Use this (not GetDefaultSources) whenever deciding which sources to add
// to a device — re-adding INTERNET_RADIO to existing devices would silently
// undo the stale_internet_radio health-check quick-fix.
func (ds *DataStore) GetInitialSources() []models.ConfiguredSource {
return ds.getInitialSources()
}
// CanonicalSourceByID returns the canonical default ConfiguredSource for one
// of the well-known built-in source IDs (10001..10005). The returned source
// has its SourceKey.Type / SourceKey.Account mirrored from
// SourceKeyType / SourceKeyAccount so it round-trips through SaveConfiguredSources
// without losing the embedded struct fields. Returns (zero, false) when the
// ID isn't one we can synthesise.
//
// Callers that auto-add missing sources (e.g. UpdatePreset when a speaker
// PUTs a preset referencing a canonical source AfterTouch hasn't been told
// about yet) should use this to keep parity with the post-pair defaults.
func (ds *DataStore) CanonicalSourceByID(id string) (models.ConfiguredSource, bool) {
defaults := ds.getDefaultSources()
for i := range defaults {
if defaults[i].ID == id {
return defaults[i], true
}
}
return models.ConfiguredSource{}, false
}
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
}
// getInitialSources returns the default sources for a brand-new device.
// INTERNET_RADIO (ID 10002) is excluded — it is a legacy provider no longer
// actively served by AfterTouch; omitting it prevents speakers from
// receiving a stale entry in their initial Sources.xml. The full list
// (including 10002) is kept in getDefaultSources() for canonicalisation
// of existing devices that already reference INTERNET_RADIO.
func (ds *DataStore) getInitialSources() []models.ConfiguredSource {
all := ds.getDefaultSources()
out := make([]models.ConfiguredSource, 0, len(all))
for i := range all {
if all[i].SourceKeyType == constants.ProviderInternetRadio {
continue
}
out = append(out, all[i])
}
return out
}
// 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 := ds.rootStat(path)
if err != nil {
return 0
}
return info.ModTime().UnixNano() / int64(time.Millisecond)
}
// HasConfiguredSources reports whether a non-empty Sources.xml file exists for
// the given account and device. A present-but-0-byte file (truncated by an
// unclean power-cut) counts as absent. See #458.
func (ds *DataStore) HasConfiguredSources(account, device string) bool {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
data, err := ds.rootReadFile(path)
if err != nil {
return false
}
// An existing but empty / 0-byte Sources.xml (e.g. truncated by an unclean
// power-cut on the speaker's NAND) must not count as "present": otherwise it
// hides the sources_xml_present health check and its create_default_sources
// quick fix, leaving the device with no managed sources. See #458.
return len(bytes.TrimSpace(data)) > 0
}
// 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 := ds.rootStat(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 := ds.rootStat(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 := ds.rootOpen(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, _ := ds.rootReadDir(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 := ds.rootOpen(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"`
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"`
TTSProvider string `json:"tts_provider,omitempty"`
TTSGoogleAPIKey string `json:"tts_google_api_key,omitempty"`
TTSAppKey string `json:"tts_app_key,omitempty"`
TTSLanguage string `json:"tts_language,omitempty"`
TTSVoice string `json:"tts_voice,omitempty"`
TTSVolume int `json:"tts_volume,omitempty"`
// TrustForwardedHeaders enables proxy-aware client IP resolution: when the
// immediate TCP peer is one of the TrustedProxyCIDRs, the client IP is
// resolved from the X-Forwarded-For header (read via the request context;
// it does not rewrite r.RemoteAddr). Required when the service is fronted
// by nginx, Caddy, or any other reverse proxy. Default false - direct LAN
// deployments must not enable this, otherwise a malicious LAN-resident
// client could spoof its source IP via the X-Forwarded-For header.
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
// TrustedProxyCIDRs is the list of CIDR blocks whose immediate TCP peers
// are allowed to set X-Forwarded-For headers when TrustForwardedHeaders is
// true. Defaults to loopback (127.0.0.0/8 and ::1/128) - i.e. only a
// reverse proxy on the same host. Override only if the proxy lives on a
// different host within a known-good private subnet.
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
// TLSExtraHosts is the persisted list of additional DNS names or IPs
// to include in the TLS certificate SAN list. Merged with the
// CLI/env --tls-extra-host values at startup (CLI/env wins; persisted
// values are additive and deduplicated). Applying a change requires a
// service restart so the TLS cert can be regenerated. Used by the
// `speaker_marge_url` health check's QuickFix and the Settings tab UI.
TLSExtraHosts []string `json:"tls_extra_hosts,omitempty"`
// TuneInStreamFormats overrides the comma-separated format list
// AfterTouch sends to TuneIn's Tune.ashx (formats=…). Empty value
// uses bmx.DefaultTuneInStreamFormats ("mp3,aac,ogg"), which
// matches AfterTouch's pre-2026-05-10 behaviour and plays on
// every SoundTouch model verified so far. PR #249 had added
// "hls" unconditionally; that regressed playback on the
// SoundTouch line (#292 — speaker can't parse the .m3u8 playlist
// and blinks amber). Operators with HLS-compatible speakers can
// set this to e.g. "mp3,aac,ogg,hls" via settings.json. The value
// is passed through verbatim; AfterTouch does not validate the
// individual format tokens.
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
// DefaultLanding selects what the root path "/" serves to a browser:
// "chooser" (or empty) — the neutral landing page that links to the
// player and the admin/setup console;
// "app" — redirect straight to the player UI (/app);
// "admin" — redirect straight to the admin console (/admin).
// API/speaker clients (non-HTML Accept) always get the version JSON
// regardless of this setting.
DefaultLanding string `json:"default_landing,omitempty"`
// AdminAreaAuth is a tri-state toggle for gating the entire admin area
// (/admin, /setup, /api/setup — minus a small set of routes shared with
// soundtouch-cli/soundtouch-player) behind the same Basic Auth used for
// /api/mgmt/*, rather than just the Local Account / Spotify / Amazon
// linking endpoints as today. Values:
// "" — unset (default). Today this means "not enforced"; a
// later release is expected to flip the *meaning* of ""
// to "enforced" as the project moves the entire admin
// area to require login by default. See #419.
// "enabled" — the whole admin area requires Basic Auth now.
// "disabled" — explicit opt-out. Kept open even after the default
// flips, so an operator's deliberate choice survives
// the upgrade.
// The tri-state (rather than a plain bool) is what lets "never decided"
// be told apart from "explicitly chose off" once that default flips.
AdminAreaAuth string `json:"admin_area_auth,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 !ds.rootExists(path) {
return Settings{}, nil
}
data, err := ds.rootReadFile(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 := ds.rootMkdirAll(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)
}
// UpdateCheckState is the small persisted state for the opt-in periodic
// update check (#591, _/i591/design-update-check.md): when it last ran and
// what it last saw, so a restart doesn't lose the "already logged this
// version" and "don't hammer GitHub on every startup" context. Separate
// from Settings, which is operator-editable config, not runtime state.
type UpdateCheckState struct {
LastCheckedAt string `json:"last_checked_at,omitempty"`
LastSeenVersion string `json:"last_seen_version,omitempty"`
}
// GetUpdateCheckState retrieves the persisted update-check state. Same
// missing-file-is-not-an-error shape as GetSettings — a fresh install (or
// one that has never had the check enabled) has no file yet.
func (ds *DataStore) GetUpdateCheckState() (UpdateCheckState, error) {
if ds == nil || ds.DataDir == "" {
return UpdateCheckState{}, nil
}
path := filepath.Join(ds.DataDir, "update-check.json")
if !ds.rootExists(path) {
return UpdateCheckState{}, nil
}
data, err := ds.rootReadFile(path)
if err != nil {
return UpdateCheckState{}, err
}
var state UpdateCheckState
if err := json.Unmarshal(data, &state); err != nil {
return UpdateCheckState{}, err
}
return state, nil
}
// SaveUpdateCheckState persists the update-check state.
func (ds *DataStore) SaveUpdateCheckState(state UpdateCheckState) error {
if ds == nil || ds.DataDir == "" {
return nil
}
if err := ds.rootMkdirAll(ds.DataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
path := filepath.Join(ds.DataDir, "update-check.json")
data, err := json.MarshalIndent(state, "", " ")
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 := ds.rootMkdirAll(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)
}
// RecordActivity appends one entry to the local admin-UI activity log, under
// DataDir/stats/activity/<kind>/, one file per event (same shape as
// SaveUsageStats/SaveErrorStats above). kind is meant to be a small,
// developer-defined constant (e.g. "notification_dismissed") used directly
// as a directory name — callers must not pass untrusted/user-supplied
// values. id may recur across calls with a new timestamp each time; this is
// an append-only log, not a keyed store. See models.ActivityRecord for the
// local-only/never-transmitted-automatically guarantee this backs.
func (ds *DataStore) RecordActivity(kind, id string, detail map[string]interface{}) error {
dir := filepath.Join(ds.DataDir, "stats", "activity", kind)
if err := ds.rootMkdirAll(dir, 0755); err != nil {
return err
}
now := time.Now()
record := models.ActivityRecord{
Kind: kind,
ID: id,
Timestamp: now.UTC().Format(time.RFC3339Nano),
Detail: detail,
}
// The random suffix guards against two events for the same id landing in
// the same nanosecond (observed as flaky on coarser-resolution clocks)
// silently overwriting one another instead of both being recorded. Not
// a security-sensitive use of randomness — only affects filename
// uniqueness, not any value that's compared or kept secret.
// nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used
filename := fmt.Sprintf("%d_%d_%s.json", now.UnixNano(), rand.Int63n(1_000_000), id) //nolint:gosec
path := filepath.Join(dir, filename)
data, err := json.MarshalIndent(record, "", " ")
if err != nil {
return err
}
return ds.atomicWriteFile(path, data)
}
// GetActivityRecords reads back every entry recorded via RecordActivity for
// the given kind. Unreadable or malformed files are skipped rather than
// failing the whole read — a single corrupt event shouldn't make the rest of
// the log unreadable. Returns an empty slice (not an error) when the
// directory doesn't exist yet, matching the "nothing recorded yet" case.
func (ds *DataStore) GetActivityRecords(kind string) ([]models.ActivityRecord, error) {
dir := filepath.Join(ds.DataDir, "stats", "activity", kind)
entries, err := ds.rootReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
records := make([]models.ActivityRecord, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
data, readErr := ds.rootReadFile(filepath.Join(dir, entry.Name()))
if readErr != nil {
continue
}
var record models.ActivityRecord
if unmarshalErr := json.Unmarshal(data, &record); unmarshalErr != nil {
continue
}
records = append(records, record)
}
return records, nil
}
// SaveErrorStats saves error statistics to the datastore.
func (ds *DataStore) SaveErrorStats(stats models.ErrorStats) error {
dir := filepath.Join(ds.DataDir, "stats", "error")
if err := ds.rootMkdirAll(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 := ds.rootMkdirAll(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 !ds.rootExists(path) {
return []DNSDiscoveryEntry{}, nil
}
data, err := ds.rootReadFile(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 !ds.rootExists(path) {
return nil
}
return ds.rootRemove(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 !ds.rootExists(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 := ds.rootReadDir(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 := ds.rootReadFile(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 := ds.rootMkdirAll(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 := ds.rootReadFile(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 := ds.rootRemove(ds.groupFilePath(account, groupID))
if os.IsNotExist(err) {
return fmt.Errorf("group %s not found", groupID)
}
return err
}
// DeleteAllGroupsForAccount removes every Group_*.xml file stored under
// account. Speakers send DELETE /streaming/account/{id}/group/ (no group
// ID) during stereo-pair teardown; since master and slave may live in
// different accounts each speaker deletes its own copy. Returns nil if no
// group files are found — idempotent by design.
func (ds *DataStore) DeleteAllGroupsForAccount(account string) error {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
dir := ds.AccountDevicesDir(account)
entries, err := ds.rootReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil // nothing to delete
}
return err
}
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), "Group_") || !strings.HasSuffix(e.Name(), ".xml") {
continue
}
_ = ds.rootRemove(filepath.Join(dir, e.Name()))
}
return nil
}
// 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 := ds.rootMkdirAll(dir, 0755); err != nil {
return err
}
return ds.rootWriteFile(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 := ds.rootRemove(ds.safeJoin("tunein", "favorites", stationID))
if os.IsNotExist(err) {
return nil
}
return err
}