mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
feat(export): bundle the local activity log into diagnostic exports; docs
Seventh and final piece of #419's initial rollout. Adds addActivityLog to buildDiagnosticArchive, walking stats/activity/ and bundling every event file verbatim (same idea as the per-device XML bundling, mirroring addSettingsJSON's placement). Without this, the privacy guarantee discussed during design ("local-only, but included in an explicit diagnostic export") would have been aspirational rather than true — caught before documenting it as fact. Documents the activity log in DIAGNOSTIC-EXPORT.md, anchored to the existing "all data stays on your network" language in SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md. This closes out the initial #419 implementation: AdminAreaAuth setting + guard rail, BasicAuthAdmin gate, activity log + dismissal cache, announcements + dismiss endpoint, admin UI banner, health check nudge, and now diagnostic-export coverage + docs. Still opt-in only (AdminAreaAuth defaults to unset) — flipping the default is a separate, later change per the design doc's rollout plan. Refs #419
This commit is contained in:
@@ -22,6 +22,8 @@ The encrypted `.age` file decrypts to a `.tar.gz` archive with:
|
||||
Source, SourceID, location), device product code, firmware version, IP, name
|
||||
- `datastore/accounts/{id}/devices/{id}/*.xml` — raw XML files verbatim from
|
||||
the sender's datastore (`Presets.xml`, `Sources.xml`, `Recents.xml`, …)
|
||||
- `stats/activity/{kind}/*.json` — the local admin-UI activity log (e.g.
|
||||
announcement-banner dismissals), verbatim, one file per recorded event
|
||||
|
||||
Having both the structured JSON and the raw XML lets you compare what the
|
||||
service serves via HTTP against what is actually stored on disk.
|
||||
@@ -31,6 +33,24 @@ secrets, Spotify refresh tokens. The raw XML files are included as-is.
|
||||
|
||||
---
|
||||
|
||||
## Local activity log
|
||||
|
||||
AfterTouch records a small local activity log for admin-UI actions —
|
||||
today, just announcement-banner dismissals (e.g. the admin-area-gate notice
|
||||
from issue #419) — under `stats/activity/{kind}/` in the data directory.
|
||||
Each event is its own plain JSON file (id, timestamp, and any detail),
|
||||
readable with a text editor; there is no encoding or opaque format to
|
||||
decode.
|
||||
|
||||
This follows the same "[all data stays on your
|
||||
network](SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md)" principle as the rest of
|
||||
AfterTouch: nothing here is ever transmitted automatically. The only way it
|
||||
leaves the operator's network is the same as everything else in this
|
||||
document — an explicitly-triggered diagnostic export, which the operator
|
||||
has to click a button and choose to send.
|
||||
|
||||
---
|
||||
|
||||
## Maintainer setup (one-time)
|
||||
|
||||
> This section is for the project maintainer only.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -189,6 +190,7 @@ func (s *Server) buildDiagnosticArchive() ([]byte, error) {
|
||||
s.addSystemFiles(tw)
|
||||
s.addServiceLog(tw)
|
||||
s.addSettingsJSON(tw)
|
||||
s.addActivityLog(tw)
|
||||
addEnvVars(tw)
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
@@ -698,6 +700,56 @@ type diagSettings struct {
|
||||
// addSettingsJSON serialises the service settings into the archive as
|
||||
// settings.json. OAuth client secrets are replaced with "[REDACTED]" so the
|
||||
// file is safe to share.
|
||||
// addActivityLog bundles the local admin-UI activity log (announcement
|
||||
// dismissals, and any other kind recorded via datastore.RecordActivity)
|
||||
// into the diagnostic archive verbatim, one file per event — same idea as
|
||||
// the per-device XML bundling above, but for stats/activity/. This is what
|
||||
// makes the "local-only, but included in an explicitly-triggered diagnostic
|
||||
// export" claim in DIAGNOSTIC-EXPORT.md actually true. A missing directory
|
||||
// (nothing recorded yet) is not an error.
|
||||
func (s *Server) addActivityLog(tw *tar.Writer) {
|
||||
if s.ds == nil || s.ds.DataDir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
root := filepath.Join(s.ds.DataDir, "stats", "activity")
|
||||
|
||||
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
log.Printf("[Export] read activity log %s: %v", sanitizeLog(path), readErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, relErr := filepath.Rel(s.ds.DataDir, path)
|
||||
if relErr != nil {
|
||||
log.Printf("[Export] rel path for %s: %v", sanitizeLog(path), relErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
if addErr := addTarBytes(tw, rel, data); addErr != nil {
|
||||
log.Printf("[Export] add %s: %v", sanitizeLog(rel), addErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
log.Printf("[Export] walk activity log: %v", walkErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) addSettingsJSON(tw *tar.Writer) {
|
||||
st, err := s.ds.GetSettings()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// tarEntries reads every file name + content out of a tar written by
|
||||
// addActivityLog, for assertions.
|
||||
func tarEntries(t *testing.T, tw *tar.Writer, buf *bytes.Buffer) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatalf("Failed to close tar writer: %v", err)
|
||||
}
|
||||
|
||||
entries := make(map[string]string)
|
||||
tr := tar.NewReader(buf)
|
||||
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar entry: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read tar entry content: %v", err)
|
||||
}
|
||||
|
||||
entries[hdr.Name] = string(data)
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// TestAddActivityLog_EmptyByDefault verifies a fresh install (nothing
|
||||
// recorded via datastore.RecordActivity yet — the common case, since
|
||||
// stats/activity/ won't exist at all) doesn't error and adds nothing.
|
||||
func TestAddActivityLog_EmptyByDefault(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "export-activity-empty-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
server.addActivityLog(tw)
|
||||
|
||||
entries := tarEntries(t, tw, &buf)
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("Expected no tar entries for an empty activity log, got %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddActivityLog_IncludesRecordedDismissal is the regression test for
|
||||
// the #419 design's stated privacy guarantee: a dismissal recorded locally
|
||||
// must actually show up in the diagnostic export, not just in theory. This
|
||||
// closes the loop DIAGNOSTIC-EXPORT.md documents.
|
||||
func TestAddActivityLog_IncludesRecordedDismissal(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "export-activity-dismissal-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
|
||||
|
||||
if err := server.RecordDismissal("admin-area-auth-419"); err != nil {
|
||||
t.Fatalf("RecordDismissal failed: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
server.addActivityLog(tw)
|
||||
|
||||
entries := tarEntries(t, tw, &buf)
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("Expected exactly 1 tar entry, got %+v", entries)
|
||||
}
|
||||
|
||||
var (
|
||||
name string
|
||||
content string
|
||||
)
|
||||
for n, c := range entries {
|
||||
name, content = n, c
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(name, "stats/activity/notification_dismissed/") {
|
||||
t.Errorf("Expected entry under stats/activity/notification_dismissed/, got %q", name)
|
||||
}
|
||||
if !strings.Contains(content, "admin-area-auth-419") {
|
||||
t.Errorf("Expected entry content to reference the dismissed id, got %q", content)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user