// 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 } // maxSafeIdentifierLength bounds account/device IDs accepted from a // speaker or third-party pairing tool. Well under typical filesystem // path-component limits (255 bytes); generous for any realistic // margeAccountUUID or MAC-derived device ID. const maxSafeIdentifierLength = 128 // IsSafeIdentifier returns true if the given identifier is safe to use // as a single path component (for account IDs, device IDs, etc.), and // safe to embed in the other places these values end up: XML sent to a // speaker, log lines, and datastore-key comparisons. It rejects empty // or overlong strings, path separators, and parent directory // references. // // The allowed character set intentionally excludes XML/HTML-special // characters (`< > & " '`), whitespace, and shell/URL metacharacters // (see #634's `postSetMargeAccount`, which interpolates an account ID // into an XML body, and `PairAccount`, which interpolates one into a // literal `envswitch accountid set ` telnet command line) even // though it accepts more than Bose's own 7-digit account format — // devices paired via third-party or manual tooling (e.g. the // USB-stick SSH-enable method) can report arbitrary margeAccountUUID // values such as "stick@local". func IsSafeIdentifier(id string) bool { if id == "" || len(id) > maxSafeIdentifierLength { return false } // Disallow obvious path traversal / multi-component paths. if strings.Contains(id, "/") || strings.Contains(id, "\\") || strings.Contains(id, "..") { return false } // Letters, digits, and a conservative set of punctuation seen in // real-world IDs: underscore, dash, dot, colon (MAC-like IDs), and // '@' (e.g. "stick@local"). Everything else — including all XML, // HTML, shell, and URL metacharacters, whitespace, and control // characters — is rejected. 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 == ':' || 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) { ds.fileMutex.RLock() presets, needsRewrite, err := ds.readPresetsNoLock(account, device) ds.fileMutex.RUnlock() if err != nil { return nil, err } if needsRewrite { log.Printf("[Datastore] Presets.xml for device %s used legacy 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 } // MutatePresets atomically reads the current preset list, transforms it via // mutate, and persists the result — holding a single write lock for the // entire read-mutate-write cycle. Calling GetPresets followed by a separate // SavePresets leaves a lost-update window open: two concurrent callers can // each read the same starting list, mutate different entries, and the // second writer's SavePresets silently clobbers the first writer's update. // That's the exact interleave that dropped a preset during #614's rapid-fire // repro (overlapping PUT .../preset/N requests). Callers that read-then-write // a single device's presets should use this instead of GetPresets+SavePresets. func (ds *DataStore) MutatePresets(account, device string, mutate func(current []models.ServicePreset) ([]models.ServicePreset, error)) ([]models.ServicePreset, error) { ds.fileMutex.Lock() defer ds.fileMutex.Unlock() current, _, err := ds.readPresetsNoLock(account, device) if err != nil { return nil, err } next, err := mutate(current) if err != nil { return nil, err } if err := ds.savePresetsNoLock(account, device, next); err != nil { return nil, err } return next, nil } // readPresetsNoLock is the lock-free read half of GetPresets/MutatePresets. // Callers must already hold ds.fileMutex (for reading or writing). It // returns the parsed presets and a flag indicating whether the on-disk file // used the legacy (capital C) format that needs rewriting. func (ds *DataStore) readPresetsNoLock(account, device string) ([]models.ServicePreset, bool, error) { path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile) data, err := ds.rootReadFile(path) if err != nil { if os.IsNotExist(err) { log.Printf("[Datastore] readPresetsNoLock: no Presets.xml at %s — reporting no presets", sanitizeLog(path)) 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] readPresetsNoLock: 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 (capital C); normalise to lowercase // before unmarshaling so legacy files are parsed correctly. normalized := bytes.ReplaceAll(data, []byte(""), []byte("")) needsRewrite := !bytes.Equal(normalized, data) if err := xml.Unmarshal(normalized, &presetsWrap); err != nil { log.Printf("[Datastore] readPresetsNoLock: 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.getConfiguredSourcesNoLock(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 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" } // getConfiguredSourcesNoLock 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) getConfiguredSourcesNoLock(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() return ds.savePresetsNoLock(account, device, presets) } // savePresetsNoLock is the lock-free write half of // SavePresets/MutatePresets. Callers must already hold ds.fileMutex.Lock(). func (ds *DataStore) savePresetsNoLock(account, device string, presets []models.ServicePreset) error { 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() return ds.readRecentsNoLock(account, device) } // MutateRecents atomically reads the current recents list, transforms it // via mutate, and persists the result — holding a single write lock for the // entire read-mutate-write cycle. See MutatePresets for why this matters: a // separate GetRecents followed by SaveRecents leaves a lost-update window // open between concurrent callers. func (ds *DataStore) MutateRecents(account, device string, mutate func(current []models.ServiceRecent) ([]models.ServiceRecent, error)) ([]models.ServiceRecent, error) { ds.fileMutex.Lock() defer ds.fileMutex.Unlock() current, err := ds.readRecentsNoLock(account, device) if err != nil { return nil, err } next, err := mutate(current) if err != nil { return nil, err } if err := ds.saveRecentsNoLock(account, device, next); err != nil { return nil, err } return next, nil } // readRecentsNoLock is the lock-free read half of GetRecents/MutateRecents. // Callers must already hold ds.fileMutex (for reading or writing). func (ds *DataStore) readRecentsNoLock(account, device string) ([]models.ServiceRecent, error) { path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile) data, err := ds.rootReadFile(path) if err != nil { if os.IsNotExist(err) { log.Printf("[Datastore] GetRecents: no Recents.xml at %s — reporting no recents", sanitizeLog(path)) 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() return ds.saveRecentsNoLock(account, device, recents) } // saveRecentsNoLock is the lock-free write half of // SaveRecents/MutateRecents. Callers must already hold ds.fileMutex.Lock(). func (ds *DataStore) saveRecentsNoLock(account, device string, recents []models.ServiceRecent) error { 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 } if !IsSafeIdentifier(accountID) { return fmt.Errorf("invalid account ID") } 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() return ds.readConfiguredSourcesNoLock(account, device) } // MutateConfiguredSources atomically reads the current configured-source // list, transforms it via mutate, and persists the result — holding a // single write lock for the entire read-mutate-write cycle. See // MutatePresets for why this matters: a separate GetConfiguredSources // followed by SaveConfiguredSources leaves a lost-update window open // between concurrent callers. func (ds *DataStore) MutateConfiguredSources(account, device string, mutate func(current []models.ConfiguredSource) ([]models.ConfiguredSource, error)) ([]models.ConfiguredSource, error) { ds.fileMutex.Lock() defer ds.fileMutex.Unlock() current, err := ds.readConfiguredSourcesNoLock(account, device) if err != nil { return nil, err } next, err := mutate(current) if err != nil { return nil, err } if err := ds.saveConfiguredSourcesNoLock(account, device, next); err != nil { return nil, err } return next, nil } // readConfiguredSourcesNoLock is the lock-free read half of // GetConfiguredSources/MutateConfiguredSources. Callers must already hold // ds.fileMutex (for reading or writing). func (ds *DataStore) readConfiguredSourcesNoLock(account, device string) ([]models.ConfiguredSource, error) { 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="" 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() return ds.saveConfiguredSourcesNoLock(account, device, sources) } // saveConfiguredSourcesNoLock is the lock-free write half of // SaveConfiguredSources/MutateConfiguredSources. Callers must already hold // ds.fileMutex.Lock(). func (ds *DataStore) saveConfiguredSourcesNoLock(account, device string, sources []models.ConfiguredSource) error { 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"` UpdateCheckInterval string `json:"update_check_interval,omitempty"` UpdateCheckEnabled bool `json:"update_check_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"` LastReleaseURL string `json:"last_release_url,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//, 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 }