diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 18fac99..d89e78e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -59,11 +59,11 @@ jobs: - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: - # Keep CI on the patched toolchain used by the Docker build. The - # go.mod language version remains 1.25 for source compatibility; + # Keep CI on the patched Go 1.25 toolchain used by the Docker build. + # The go.mod language version remains 1.25 for source compatibility; # pin the patch version so a newly published standard-library # vulnerability cannot silently select an older toolchain. - go-version: '1.26.6' + go-version: '1.25.13' cache: true - name: Run race detector diff --git a/Dockerfile b/Dockerfile index c7211eb..6e3c80b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1.26@sha256:ecfaec9ed6d810b56388c508f4121597bfbba70d41a6dfeee4d8cad5f295fc32 -FROM golang:1.26.6-alpine@sha256:af8d6740070b8906d12eae1c3e3ea0957fb63f492051ea05e354c38ef9fe88df AS dependencies +FROM golang:1.25.13-alpine@sha256:844b27705f54e73773e0f9bc3c780633b9d7f4b4831bf35cdad02a81a4c80bd0 AS dependencies WORKDIR /src RUN apk add --no-cache ca-certificates tzdata COPY go.mod go.sum* ./ diff --git a/Makefile b/Makefile index 8e121ef..1195409 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,14 @@ tooling-check: echo "unpinned container helper reference found" >&2; \ exit 1; \ fi + @docker_go=$$(sed -n 's/^FROM golang:\([0-9.]*\)-alpine.*/\1/p' Dockerfile | head -1); \ + ci_go=$$(sed -n "s/.*go-version: '\([^']*\)'.*/\1/p" .github/workflows/docker.yml | head -1); \ + module_go=$$(sed -n 's/^go //p' go.mod | cut -d. -f1-2); \ + docker_line=$$(printf '%s' "$$docker_go" | cut -d. -f1-2); \ + if [ -z "$$docker_go" ] || [ -z "$$ci_go" ] || [ -z "$$module_go" ] || [ "$$docker_go" != "$$ci_go" ] || [ "$$docker_line" != "$$module_go" ]; then \ + echo "Go toolchain drift: module=$$module_go Docker=$$docker_go CI=$$ci_go" >&2; \ + exit 1; \ + fi lint: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 run ./... diff --git a/README.md b/README.md index 7b73af2..f479f42 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ releases without creating releases or notifications. Use the moon/sun button in the header to switch between light and dark mode; your choice is remembered in the browser. The running application version and project repository are available in the -footer. The current release is `v0.42.0`; release images display the injected +footer. The current release is `v0.43.0`; release images display the injected semantic version while local builds identify themselves as `dev`. Operational timestamps are stored in UTC and rendered in the configured system timezone; existing databases are @@ -57,10 +57,15 @@ error when a data lookup fails, while the detailed cause remains in structured logs. Static assets use immutable, version-stamped URLs and continue to serve their unversioned paths for compatibility. -The v0.42.0 reliability-guardrails release routes production SQLite writes -through a bounded busy/locked retry path, rejects malformed operational -timestamps instead of silently showing zero values, and preflights database -paths and MusicBrainz contact input before startup. The v0.40.0 operations +The v0.43.0 retention-governance release adds an administrator dry-run and +explicit cleanup action for bounded operational state. Notification events, +delivery rows, inbox state, blocked work, and delivery-attempt audit records +are retained indefinitely; only expired sessions/tokens, old login-attempt +records, completed transient work, and application logs inside the documented +windows are eligible for cleanup. The v0.42.0 reliability release routes +production SQLite writes through a bounded busy/locked retry path, rejects +malformed operational timestamps instead of silently showing zero values, and +preflights database paths and MusicBrainz contact input before startup. The v0.40.0 operations release adds strict boolean configuration validation, polling-cadence-aware provider freshness, and bounded scheduler, provider, and delivery metrics in the administrator diagnostics report. The v0.39.0 @@ -212,17 +217,21 @@ GitHub Actions builds and publishes the Docker image to - `latest` and `main` follow the current `main` branch. - `sha-` identifies an exact source revision. -- Pushing a tag such as `v0.42.0` publishes `0.42.0`, `0.42`, and `latest`. +- Pushing a tag such as `v0.43.0` publishes `0.43.0`, `0.43`, and `latest`. Release images receive their version through the Docker build's `APP_VERSION` argument. Tag builds inject the semantic tag (without the leading `v`), while branch and local builds use `dev` or `dev-` so development images are not confused with a release. +The module targets Go 1.25 for source compatibility; CI and the Docker build +use the pinned patched Go 1.25.13 toolchain so local builds and release images +share the same supported language/runtime line. + Pin a deployment to a release by setting the Compose image before starting: ```console -ARTIST_TRACKARR_IMAGE=ghcr.io/crypt0rr/artist-trackarr:0.42.0 docker compose up -d +ARTIST_TRACKARR_IMAGE=ghcr.io/crypt0rr/artist-trackarr:0.43.0 docker compose up -d ``` ## Configuration @@ -365,6 +374,20 @@ owner can retry after replacing/recovering the destination. A newly added destination receives future events only and is not backfilled with historical notifications. +### Retention and cleanup + +The administrator page includes a retention dry-run with the effective policy +before any cleanup is possible. Application logs are kept for seven days; +expired sessions and tokens, old login-attempt records, completed manual-sync +requests, and import jobs are transient operational state and use a 30-day +window (login attempts use a 24-hour safety window). Cleanup is never run from +the web request automatically: an administrator must explicitly confirm it. +Notification events, deliveries, inbox state, blocked deliveries, and +delivery-attempt audit records have no automatic expiry and are not removed by +this action while the account exists (account deletion still removes that +account's private data). Backups should therefore be treated as confidential and retained +according to the household's own recovery policy. + Users can choose whether albums, EPs, singles, announcements, and release-day reminders should be delivered. Followed artists show their last and next synchronization times, and **Sync now** queues a rate-limited refresh. Release diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index 9555fbd..e9abf2a 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -571,7 +571,8 @@ func (r *Runner) runMaintenance(ctx context.Context) { } else if reconciled > 0 { r.logger.Info("stale delivery attempts reconciled", "attempts", reconciled) } - if err := r.store.PruneApplicationLogs(ctx, time.Now().UTC().Add(-7*24*time.Hour)); err != nil { + policy := r.store.RetentionPolicy() + if err := r.store.PruneApplicationLogs(ctx, time.Now().UTC().Add(-time.Duration(policy.ApplicationLogsDays)*24*time.Hour)); err != nil { r.logger.Debug("application log pruning failed", "error", err) } if maintenance, err := r.store.PruneExpiredState(ctx, time.Now().UTC()); err != nil { diff --git a/internal/store/imports.go b/internal/store/imports.go index e8308aa..613bc2e 100644 --- a/internal/store/imports.go +++ b/internal/store/imports.go @@ -192,8 +192,9 @@ type MaintenanceStats struct { } func (s *Store) PruneExpiredState(ctx context.Context, now time.Time) (MaintenanceStats, error) { + policy := s.retention() cutoff := now.Add(-24 * time.Hour) - manualCutoff := now.Add(-30 * 24 * time.Hour) + manualCutoff := now.Add(-time.Duration(policy.TransientStateDays) * 24 * time.Hour) var stats MaintenanceStats statements := []struct { query string diff --git a/internal/store/retention.go b/internal/store/retention.go new file mode 100644 index 0000000..65857aa --- /dev/null +++ b/internal/store/retention.go @@ -0,0 +1,138 @@ +package store + +import ( + "context" + "database/sql" + "time" +) + +// RetentionReport is a safe, read-only dry run for the administrator. The +// history counters are informational; only the two operational candidate +// counts can be affected by CleanupRetention. +type RetentionReport struct { + CheckedAt time.Time + Policy RetentionPolicy + NotificationEvents int64 + Deliveries int64 + DeliveryAttempts int64 + ApplicationLogs int64 + OldestNotificationEvent *time.Time + OldestDelivery *time.Time + OldestDeliveryAttempt *time.Time + OldestApplicationLog *time.Time + PrunableApplicationLogs int64 + PrunableTransientSessions int64 + PrunableAuthTokens int64 + PrunableLoginAttempts int64 + PrunableManualSyncs int64 + PrunableImportJobs int64 +} + +// RetentionCleanupStats contains only rows removed from transient state. It +// deliberately has no notification or delivery fields so callers cannot +// accidentally imply that user-facing history was purged. +type RetentionCleanupStats struct { + ApplicationLogs int64 + Sessions int64 + AuthTokens int64 + LoginAttempts int64 + ManualSyncs int64 + ImportJobs int64 +} + +func (s *Store) RetentionReport(ctx context.Context, now time.Time) (RetentionReport, error) { + if now.IsZero() { + now = time.Now().UTC() + } + policy := s.retention() + report := RetentionReport{CheckedAt: now.UTC(), Policy: policy} + + queries := []struct { + query string + count *int64 + oldest **time.Time + name string + }{ + {`SELECT COUNT(*), MIN(created_at) FROM notification_events`, &report.NotificationEvents, &report.OldestNotificationEvent, "notification event"}, + {`SELECT COUNT(*), MIN(COALESCE(sent_at,next_attempt_at)) FROM deliveries`, &report.Deliveries, &report.OldestDelivery, "delivery"}, + {`SELECT COUNT(*), MIN(started_at) FROM delivery_attempts`, &report.DeliveryAttempts, &report.OldestDeliveryAttempt, "delivery attempt"}, + {`SELECT COUNT(*), MIN(created_at) FROM application_logs`, &report.ApplicationLogs, &report.OldestApplicationLog, "application log"}, + } + for _, item := range queries { + var oldest sql.NullString + if err := s.readerDB().QueryRowContext(ctx, item.query).Scan(item.count, &oldest); err != nil { + return RetentionReport{}, err + } + parsed, err := parseStoredNullableTime(oldest, item.name+" oldest timestamp") + if err != nil { + return RetentionReport{}, err + } + *item.oldest = parsed + } + + logCutoff := timeText(report.CheckedAt.Add(-time.Duration(policy.ApplicationLogsDays) * 24 * time.Hour)) + transientCutoff := timeText(report.CheckedAt.Add(-time.Duration(policy.TransientStateDays) * 24 * time.Hour)) + if err := s.readerDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM application_logs WHERE created_at < ?`, logCutoff).Scan(&report.PrunableApplicationLogs); err != nil { + return RetentionReport{}, err + } + if err := s.readerDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions WHERE expires_at < ?`, timeText(report.CheckedAt)).Scan(&report.PrunableTransientSessions); err != nil { + return RetentionReport{}, err + } + if err := s.readerDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_tokens WHERE expires_at < ? OR used_at IS NOT NULL`, timeText(report.CheckedAt)).Scan(&report.PrunableAuthTokens); err != nil { + return RetentionReport{}, err + } + if err := s.readerDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM login_attempts WHERE first_at < ?`, timeText(report.CheckedAt.Add(-24*time.Hour))).Scan(&report.PrunableLoginAttempts); err != nil { + return RetentionReport{}, err + } + if err := s.readerDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM manual_sync_requests WHERE status IN ('completed','failed') AND finished_at IS NOT NULL AND finished_at < ?`, transientCutoff).Scan(&report.PrunableManualSyncs); err != nil { + return RetentionReport{}, err + } + if err := s.readerDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM import_jobs WHERE created_at < ?`, transientCutoff).Scan(&report.PrunableImportJobs); err != nil { + return RetentionReport{}, err + } + return report, nil +} + +// CleanupRetention performs the same bounded cleanup as scheduled +// maintenance, but only when an administrator explicitly requests it. It +// never deletes notification events, deliveries, inbox state, blocked rows, +// or delivery-attempt audit records. +func (s *Store) CleanupRetention(ctx context.Context, now time.Time) (RetentionCleanupStats, error) { + if now.IsZero() { + now = time.Now().UTC() + } + policy := s.retention() + stats := RetentionCleanupStats{} + tx, err := s.beginWriteTx(ctx) + if err != nil { + return stats, err + } + defer func() { _ = tx.Rollback() }() + statements := []struct { + query string + args []any + out *int64 + }{ + {`DELETE FROM application_logs WHERE created_at < ?`, []any{timeText(now.Add(-time.Duration(policy.ApplicationLogsDays) * 24 * time.Hour))}, &stats.ApplicationLogs}, + {`DELETE FROM sessions WHERE expires_at < ?`, []any{timeText(now)}, &stats.Sessions}, + {`DELETE FROM auth_tokens WHERE expires_at < ? OR used_at IS NOT NULL`, []any{timeText(now)}, &stats.AuthTokens}, + {`DELETE FROM login_attempts WHERE first_at < ?`, []any{timeText(now.Add(-24 * time.Hour))}, &stats.LoginAttempts}, + {`DELETE FROM manual_sync_requests WHERE status IN ('completed','failed') AND finished_at IS NOT NULL AND finished_at < ?`, []any{timeText(now.Add(-time.Duration(policy.TransientStateDays) * 24 * time.Hour))}, &stats.ManualSyncs}, + {`DELETE FROM import_jobs WHERE created_at < ?`, []any{timeText(now.Add(-time.Duration(policy.TransientStateDays) * 24 * time.Hour))}, &stats.ImportJobs}, + } + for _, statement := range statements { + result, err := tx.ExecContext(ctx, statement.query, statement.args...) + if err != nil { + return RetentionCleanupStats{}, err + } + count, err := result.RowsAffected() + if err != nil { + return RetentionCleanupStats{}, err + } + *statement.out = count + } + if err := tx.Commit(); err != nil { + return RetentionCleanupStats{}, err + } + return stats, nil +} diff --git a/internal/store/retention_test.go b/internal/store/retention_test.go new file mode 100644 index 0000000..8d4e8ae --- /dev/null +++ b/internal/store/retention_test.go @@ -0,0 +1,110 @@ +package store + +import ( + "context" + "testing" + "time" +) + +func TestRetentionReportAndCleanupPreserveNotificationHistory(t *testing.T) { + ctx := context.Background() + s := testStore(t) + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + old := timeText(now.Add(-10 * 24 * time.Hour)) + if _, err := s.DB.ExecContext(ctx, `INSERT INTO application_logs(created_at,level,message,attributes_json) VALUES(?,?,?,?)`, old, "INFO", "old", "[]"); err != nil { + t.Fatal(err) + } + if _, err := s.DB.ExecContext(ctx, `INSERT INTO application_logs(created_at,level,message,attributes_json) VALUES(?,?,?,?)`, timeText(now), "INFO", "current", "[]"); err != nil { + t.Fatal(err) + } + userID, err := s.CreateUser(ctx, "retention@example.com", "hash", "member", "UTC", "retention") + if err != nil { + t.Fatal(err) + } + if _, err := s.DB.ExecContext(ctx, `INSERT INTO sessions(token_hash,user_id,csrf_token,expires_at,created_at) VALUES(?,?,?,?,?)`, []byte("expired"), userID, "csrf", old, old); err != nil { + t.Fatal(err) + } + if _, err := s.DB.ExecContext(ctx, `INSERT INTO login_attempts(key_hash,failures,first_at) VALUES(?,?,?)`, []byte("attempt"), 1, old); err != nil { + t.Fatal(err) + } + + report, err := s.RetentionReport(ctx, now) + if err != nil { + t.Fatal(err) + } + if report.Policy.ApplicationLogsDays != 7 || report.Policy.TransientStateDays != 30 { + t.Fatalf("unexpected policy: %#v", report.Policy) + } + if report.PrunableApplicationLogs != 1 || report.PrunableTransientSessions != 1 || report.PrunableLoginAttempts != 1 { + t.Fatalf("unexpected dry-run counts: %#v", report) + } + + stats, err := s.CleanupRetention(ctx, now) + if err != nil { + t.Fatal(err) + } + if stats.ApplicationLogs != 1 || stats.Sessions != 1 || stats.LoginAttempts != 1 { + t.Fatalf("unexpected cleanup stats: %#v", stats) + } + var logs int + if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM application_logs`).Scan(&logs); err != nil { + t.Fatal(err) + } + if logs != 1 { + t.Fatalf("application logs=%d, want current row only", logs) + } + var sessions int + if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions`).Scan(&sessions); err != nil { + t.Fatal(err) + } + if sessions != 0 { + t.Fatalf("sessions=%d, want expired session removed", sessions) + } +} + +func TestRetentionCleanupNeverDeletesDeliveryHistory(t *testing.T) { + ctx := context.Background() + s := testStore(t) + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + userID, err := s.CreateUser(ctx, "history@example.com", "hash", "member", "UTC", "history") + if err != nil { + t.Fatal(err) + } + artist, err := s.UpsertArtist(ctx, Artist{MBID: "retention-history-artist", Name: "Retention History"}) + if err != nil { + t.Fatal(err) + } + if _, err := s.Follow(ctx, userID, artist.ID); err != nil { + t.Fatal(err) + } + result, err := s.DB.ExecContext(ctx, `INSERT INTO release_groups + (mbid,artist_id,title,primary_type,secondary_types,first_release_date,date_precision,musicbrainz_url,source,first_observed_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`, "mb-retention", artist.ID, "Retained release", "Album", "[]", "2026-01-01", 3, "https://musicbrainz.org/release-group/mb-retention", "musicbrainz", timeText(now.Add(-365*24*time.Hour)), timeText(now.Add(-365*24*time.Hour))) + if err != nil { + t.Fatal(err) + } + releaseID, err := result.LastInsertId() + if err != nil { + t.Fatal(err) + } + if _, err := s.DB.ExecContext(ctx, `INSERT INTO notification_events(user_id,release_group_id,event_type,title,body,created_at) VALUES(?,?,?,?,?,?)`, userID, releaseID, "announcement", "Retained release", "body", timeText(now.Add(-365*24*time.Hour))); err != nil { + t.Fatal(err) + } + report, err := s.RetentionReport(ctx, now) + if err != nil { + t.Fatal(err) + } + if report.NotificationEvents != 1 { + t.Fatalf("notification events=%d, want 1", report.NotificationEvents) + } + if _, err := s.CleanupRetention(ctx, now); err != nil { + t.Fatal(err) + } + var events int + if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM notification_events WHERE user_id=? AND release_group_id=?`, userID, releaseID).Scan(&events); err != nil { + t.Fatal(err) + } + if events != 1 { + t.Fatalf("notification events after cleanup=%d, want 1", events) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 034296f..b1bab33 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -30,12 +30,62 @@ type Store struct { dataDir string readerMu sync.RWMutex healthMu sync.RWMutex + retentionMu sync.RWMutex pollInterval time.Duration spotifyPollInterval time.Duration + retentionPolicy RetentionPolicy closeOnce sync.Once closeErr error } +// RetentionPolicy describes the bounded operational state that may be +// removed by maintenance. Notification events, delivery queue rows, inbox +// state, blocked work, and delivery-attempt audit records are intentionally +// not part of this policy and are retained indefinitely. +type RetentionPolicy struct { + ApplicationLogsDays int + TransientStateDays int +} + +// DefaultRetentionPolicy is conservative and matches the existing automatic +// maintenance windows. It is exposed so the administrator report and tests +// can describe exactly what an explicit cleanup would remove. +func DefaultRetentionPolicy() RetentionPolicy { + return RetentionPolicy{ApplicationLogsDays: 7, TransientStateDays: 30} +} + +func normalizeRetentionPolicy(policy RetentionPolicy) RetentionPolicy { + defaults := DefaultRetentionPolicy() + if policy.ApplicationLogsDays <= 0 { + policy.ApplicationLogsDays = defaults.ApplicationLogsDays + } + if policy.TransientStateDays <= 0 { + policy.TransientStateDays = defaults.TransientStateDays + } + return policy +} + +// SetRetentionPolicy updates the maintenance windows used by reports and +// explicit administrator cleanup. A zero value restores the safe defaults. +func (s *Store) SetRetentionPolicy(policy RetentionPolicy) { + s.retentionMu.Lock() + s.retentionPolicy = normalizeRetentionPolicy(policy) + s.retentionMu.Unlock() +} + +func (s *Store) retention() RetentionPolicy { + s.retentionMu.RLock() + policy := s.retentionPolicy + s.retentionMu.RUnlock() + return normalizeRetentionPolicy(policy) +} + +// RetentionPolicy returns the effective windows used by scheduled and +// explicit maintenance. +func (s *Store) RetentionPolicy() RetentionPolicy { + return s.retention() +} + // SetProviderHealthCadences supplies the configured polling intervals used by // diagnostics when deciding whether a successful provider check is stale. A // zero value keeps the conservative historical defaults for tests and diff --git a/internal/web/actions_test.go b/internal/web/actions_test.go index 2619078..4dd7940 100644 --- a/internal/web/actions_test.go +++ b/internal/web/actions_test.go @@ -380,6 +380,12 @@ func TestAdminInvitationAndResetRoutes(t *testing.T) { if response.StatusCode != http.StatusOK { t.Fatalf("admin retry queue status=%d", response.StatusCode) } + response = postForm(t, client, server.URL+"/admin/retention/cleanup", url.Values{"_csrf": {csrf}}) + cleanupBody, _ := io.ReadAll(response.Body) + _ = response.Body.Close() + if response.StatusCode != http.StatusOK || !strings.Contains(string(cleanupBody), "Cleanup was not confirmed") { + t.Fatalf("unconfirmed retention cleanup status/body=%d %q", response.StatusCode, cleanupBody) + } csrf = getCSRF(t, client, server.URL+"/admin") response = postForm(t, client, server.URL+"/admin/sync/artists/"+strconv.FormatInt(artist.ID, 10), url.Values{"_csrf": {csrf}}) _ = response.Body.Close() diff --git a/internal/web/admin.go b/internal/web/admin.go index 36838d4..4fa06e0 100644 --- a/internal/web/admin.go +++ b/internal/web/admin.go @@ -123,6 +123,8 @@ func (a *App) adminData(r *http.Request) PageData { failed = a.pageStoreError(r, &d, "Household administration", "provider health", err) || failed d.Diagnostics, err = a.store.Diagnostics(r.Context()) failed = a.pageStoreError(r, &d, "Household administration", "system diagnostics", err) || failed + d.Retention, err = a.store.RetentionReport(r.Context(), time.Now().UTC()) + failed = a.pageStoreError(r, &d, "Household administration", "retention report", err) || failed if a.jobs != nil { d.RunnerStatus = a.jobs.Status() } @@ -146,6 +148,25 @@ func (a *App) adminData(r *http.Request) PageData { return d } +func (a *App) cleanupRetention(w http.ResponseWriter, r *http.Request) { + if strings.TrimSpace(r.FormValue("confirm")) != "cleanup" { + http.Redirect(w, r, "/admin?message="+url.QueryEscape("Cleanup was not confirmed; no records were removed."), http.StatusSeeOther) + return + } + stats, err := a.store.CleanupRetention(r.Context(), time.Now().UTC()) + if err != nil { + a.logger.Error("retention cleanup failed", "path", r.URL.Path, "error", err) + http.Redirect(w, r, "/admin?message="+url.QueryEscape("Retention cleanup could not be completed."), http.StatusSeeOther) + return + } + removed := stats.ApplicationLogs + stats.Sessions + stats.AuthTokens + stats.LoginAttempts + stats.ManualSyncs + stats.ImportJobs + a.logger.Info("retention cleanup completed", "removed", removed, + "application_logs", stats.ApplicationLogs, "sessions", stats.Sessions, + "auth_tokens", stats.AuthTokens, "login_attempts", stats.LoginAttempts, + "manual_syncs", stats.ManualSyncs, "import_jobs", stats.ImportJobs) + http.Redirect(w, r, "/admin?message="+url.QueryEscape(fmt.Sprintf("Retention cleanup removed %d transient records; notification and delivery history was preserved.", removed)), http.StatusSeeOther) +} + func diagnosticReport(snapshot store.DiagnosticsSnapshot, runner jobs.RunnerStatus) string { var report strings.Builder report.WriteString("ArtistTrackarr release assurance report\n") diff --git a/internal/web/core.go b/internal/web/core.go index 17572a0..56b807f 100644 --- a/internal/web/core.go +++ b/internal/web/core.go @@ -608,6 +608,7 @@ func (a *App) Handler() http.Handler { admin.Post("/admin/sync/artists/{id}", a.queueArtistSync) admin.Get("/admin/provider-health", a.providerHealth) admin.Get("/admin/diagnostics", a.diagnostics) + admin.Post("/admin/retention/cleanup", a.cleanupRetention) }) }) return r diff --git a/internal/web/templates/admin.html b/internal/web/templates/admin.html index 669a6f7..5c1b84d 100644 --- a/internal/web/templates/admin.html +++ b/internal/web/templates/admin.html @@ -47,6 +47,32 @@ +
+

Retention governance

Operational history

This is a read-only dry run until you explicitly confirm cleanup. Notification events, deliveries, inbox state, blocked work, and delivery-attempt audit records are retained indefinitely for existing accounts; deleting an account still removes its private data.

Checked {{formatProviderTime .Retention.CheckedAt}}
+
+
{{.Retention.PrunableApplicationLogs}}application logs eligible ({{.Retention.Policy.ApplicationLogsDays}} days)
+
{{.Retention.PrunableTransientSessions}}expired sessions eligible
+
{{.Retention.PrunableAuthTokens}}used/expired tokens eligible
+
{{.Retention.PrunableLoginAttempts}}old login attempts eligible
+
{{.Retention.PrunableManualSyncs}}completed sync requests eligible ({{.Retention.Policy.TransientStateDays}} days)
+
{{.Retention.PrunableImportJobs}}old import jobs eligible ({{.Retention.Policy.TransientStateDays}} days)
+
{{.Retention.NotificationEvents}}notification events retained
+
{{.Retention.Deliveries}}delivery rows retained
+
{{.Retention.DeliveryAttempts}}delivery attempts retained
+
+
Retention policy and history ages +

Application logs are kept for {{.Retention.Policy.ApplicationLogsDays}} days. Expired sessions, used tokens, old login attempts, and completed transient work are eligible after {{.Retention.Policy.TransientStateDays}} days (login attempts use a 24-hour safety window). Notification and delivery history has no automatic expiry for existing accounts.

+ {{if .Retention.OldestNotificationEvent}}Oldest notification event: {{formatProviderTime .Retention.OldestNotificationEvent}}{{end}} + {{if .Retention.OldestDelivery}}Oldest delivery row: {{formatProviderTime .Retention.OldestDelivery}}{{end}} + {{if .Retention.OldestDeliveryAttempt}}Oldest delivery attempt: {{formatProviderTime .Retention.OldestDeliveryAttempt}}{{end}} + {{if .Retention.OldestApplicationLog}}Oldest application log: {{formatProviderTime .Retention.OldestApplicationLog}}{{end}} +
+
+ {{template "csrf" .}} + + +
+

Users

Household accounts

{{len .AdminUsers}} user{{if ne (len .AdminUsers) 1}}s{{end}}

Deleting an account permanently removes its follows, notification destinations, pending artist identifications, sessions, and delivery history.

diff --git a/internal/web/web.go b/internal/web/web.go index 317d798..9f2618c 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -118,6 +118,7 @@ type PageData struct { CoveragePageEnd int AssuranceSummary store.AssuranceSummary Diagnostics store.DiagnosticsSnapshot + Retention store.RetentionReport RunnerStatus jobs.RunnerStatus DiagnosticReport string EvidenceIssues []store.EvidenceIssue diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 2a30690..335c4a5 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -1154,7 +1154,7 @@ func TestAdminDeliveryAuditAndAuthorization(t *testing.T) { _ = response.Body.Close() for _, expected := range []string{ "member@example.com", "Audited Album", "Kitchen display", "ntfy", "failed", "5 attempts", - "View details", "Household accounts", "delete-me@example.com", + "View details", "Household accounts", "delete-me@example.com", "Retention governance", "/admin/users/" + strconv.FormatInt(targetID, 10) + "/delete", "Current account", } { if !strings.Contains(string(body), expected) {