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:
Tobias Gesellchen
2026-08-08 23:49:57 +02:00
parent 4bbcd4d178
commit 50509ef729
3 changed files with 187 additions and 0 deletions
@@ -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.
+52
View File
@@ -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)
}
}