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) + } +}