From 50509ef729582e80c9f315da0ce2b995118438c8 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 8 Aug 2026 23:02:17 +0200 Subject: [PATCH] feat(export): bundle the local activity log into diagnostic exports; docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../docs/appendix/DIAGNOSTIC-EXPORT.md | 20 +++ pkg/service/handlers/handlers_export.go | 52 ++++++++ .../handlers/handlers_export_activity_test.go | 115 ++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 pkg/service/handlers/handlers_export_activity_test.go diff --git a/docs/content/docs/appendix/DIAGNOSTIC-EXPORT.md b/docs/content/docs/appendix/DIAGNOSTIC-EXPORT.md index 1623691..d3ab9b8 100644 --- a/docs/content/docs/appendix/DIAGNOSTIC-EXPORT.md +++ b/docs/content/docs/appendix/DIAGNOSTIC-EXPORT.md @@ -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. diff --git a/pkg/service/handlers/handlers_export.go b/pkg/service/handlers/handlers_export.go index 6f1a2c6..e31cb2a 100644 --- a/pkg/service/handlers/handlers_export.go +++ b/pkg/service/handlers/handlers_export.go @@ -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 { diff --git a/pkg/service/handlers/handlers_export_activity_test.go b/pkg/service/handlers/handlers_export_activity_test.go new file mode 100644 index 0000000..967d170 --- /dev/null +++ b/pkg/service/handlers/handlers_export_activity_test.go @@ -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) + } +}