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
+27 -3
View File
@@ -98,9 +98,21 @@ func (h *Handler) Info(c *okapi.Context) error {
"requests": h.requests.Load(),
"admin": h.auth.Enabled(),
"reactions": ReactionEmojis(),
"visitors": h.visitorsActive(c.Request().Context()),
})
}
// visitorsActive counts identities seen in the last few minutes — the
// "currently visiting" number. It degrades to 0 on a database hiccup; the
// SSE presence event simply carries whatever the database last said.
func (h *Handler) visitorsActive(ctx context.Context) int64 {
n, err := h.store.VisitorsSeenAfter(ctx, time.Now().Add(-visitorFreshWindow))
if err != nil {
return 0
}
return n
}
// PublicSettings returns the wall state visitors need: paused flag and banner.
func (h *Handler) PublicSettings(c *okapi.Context) error {
st, err := h.store.Settings(c.Request().Context())
@@ -172,9 +184,6 @@ func (h *Handler) CreateEntry(c *okapi.Context) error {
name := strings.TrimSpace(req.Name)
message := strings.TrimSpace(req.Message)
if name == "" {
name = "Anonymous"
}
if r := []rune(name); len(r) > maxNameLen {
name = string(r[:maxNameLen])
}
@@ -187,10 +196,24 @@ func (h *Handler) CreateEntry(c *okapi.Context) error {
})
}
// The visitor identity ties the signature to a browser: an unsigned post
// keeps the name the visitor used before, and a signed post is remembered
// for next time.
visitor := visitorFromContext(c)
if name == "" {
name = visitorName(visitor)
}
if name == "" {
name = "Anonymous"
}
entry, err := h.store.Create(ctx, name, message, h.host, clientIP(c))
if err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save entry"})
}
if !visitor.IsZero() {
h.store.VisitorSigned(ctx, visitor.ID, name)
}
h.broker.Publish(Event{Type: "created", Entry: &entry})
return c.Created(entry)
@@ -233,6 +256,7 @@ func (h *Handler) Stream(c *okapi.Context) error {
"online": h.broker.Online(),
"replicas": len(h.broker.Replicas()),
"host": h.host,
"visitors": h.visitorsActive(ctx),
}}
select {
case msgs <- welcome: