feat: add visitor identity cookie and database reset job

Three connected features for the deployment-strategy demo:

1. Visitor identity cookie (v3). A new visitors table backs a first-party
   gb_visitor cookie: middleware on /api checks the cookie's UUID against
   the database on every request and regenerates + persists it when the id
   is missing, malformed, or unknown (fresh browser, pruned identity, or a
   reset database). The identity remembers the name a visitor signed with
   (pre-filled next time, used for unsigned posts), replaces the standalone
   reaction cookie for reaction de-dupe, and feeds a live "N visiting"
   counter (identities seen in the last 5 minutes) shown on the wall, in
   /api/info, the admin overview, and the presence/tick SSE events. The
   admin console gains Visitors/Visiting tiles and a visitors table, plus a
   GET /api/me endpoint and GET /api/admin/visitors.

2. guestbook reset command. Wipes entries (including soft-deleted rows),
   reactions and visitor identities under the schema advisory lock, with
   --dry-run, --hard (also reset wall settings) and --reseed flags; meant
   to run as a one-off Miabi Job. After a reset every outstanding cookie is
   invalidated, so browsers get a fresh identity on their next request.

3. cleanup learns --prune-visitors (default 30 days): the cron job now also
   forgets idle identities together with their reactions.

Docs (README, DEMO) updated: act 8 covers reset, the visitor-identity
talking point is in act 3, and the API tables list the new endpoints.

Akili-Task: tsk_ptdi5ufndrzwlljk4v5g
Akili-Agent: goldfish
This commit is contained in:
Akili
2026-10-07 19:06:07 +02:00
parent a12c27b6bc
commit 86215f1eb1
13 changed files with 775 additions and 74 deletions
+19 -4
View File
@@ -19,8 +19,10 @@ import (
// - "deleted" → ID is set (a removed signature)
// - "reaction" → ID/Emoji/Count are set (an entry's tally changed)
// - "settings" → Settings is set (signing paused, banner changed, theme forced)
// - "presence" → Online/Replicas are set (connected clients changed)
// - "tick" → Time/Version/Host are set (live server clock; new in v2)
// - "presence" → Online/Replicas/Visitors are set (connected clients or active
// visitor identities changed)
// - "tick" → Time/Version/Host are set (live server clock; new in v2),
// plus Visitors (active identities; new in v3)
type Event struct {
Type string `json:"type"`
Entry *Entry `json:"entry,omitempty"`
@@ -30,6 +32,7 @@ type Event struct {
Settings *Settings `json:"settings,omitempty"`
Online int `json:"online,omitempty"`
Replicas int `json:"replicas,omitempty"`
Visitors int64 `json:"visitors,omitempty"`
Time string `json:"time,omitempty"`
Version string `json:"version,omitempty"`
Host string `json:"host,omitempty"`
@@ -69,6 +72,9 @@ type Broker struct {
rdb *redis.Client
dirty chan struct{}
requests func() int64
// visitors reports how many visitor identities were recently active (from
// the database, so it is cluster-wide by construction); may be nil.
visitors func() int64
cmu sync.RWMutex
replicas []Replica
@@ -229,7 +235,7 @@ func (b *Broker) localCount() int {
func (b *Broker) presenceChanged() {
if b.rdb == nil {
b.emit(Event{Type: "presence", Online: b.localCount(), Replicas: 1})
b.emit(Event{Type: "presence", Online: b.localCount(), Replicas: 1, Visitors: b.visitorsActive()})
return
}
select {
@@ -238,6 +244,15 @@ func (b *Broker) presenceChanged() {
}
}
// visitorsActive returns the recent-visitor count, or 0 when no reporter is
// registered.
func (b *Broker) visitorsActive() int64 {
if b.visitors == nil {
return 0
}
return b.visitors()
}
func (b *Broker) publishRaw(ctx context.Context, ev Event) error {
payload, err := json.Marshal(ev)
if err != nil {
@@ -321,7 +336,7 @@ func (b *Broker) refresh(ctx context.Context) {
b.cmu.Unlock()
if changed {
b.emit(Event{Type: "presence", Online: total, Replicas: len(replicas)})
b.emit(Event{Type: "presence", Online: total, Replicas: len(replicas), Visitors: b.visitorsActive()})
}
}