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
+5 -37
View File
@@ -1,12 +1,9 @@
package main
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strconv"
"time"
"github.com/jkaninda/okapi"
"gorm.io/gorm"
@@ -14,37 +11,8 @@ import (
// Reactions let visitors cheer a signature without an account: one tap adds,
// tapping again removes, and the tally streams to every open tab over SSE.
// A first-party cookie identifies the browser so a count can't be inflated by
// hammering the endpoint.
const (
reactionCookie = "gb_reactor"
reactionCookieTTL = 365 * 24 * time.Hour
)
// reactorID returns the stable, anonymous id of this browser, setting the
// cookie on first sight.
func reactorID(c *okapi.Context) string {
if id, err := c.Cookie(reactionCookie); err == nil && len(id) >= 8 && len(id) <= 64 {
return id
}
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return ""
}
id := hex.EncodeToString(buf)
r := c.Request()
http.SetCookie(c.ResponseWriter(), &http.Cookie{
Name: reactionCookie,
Value: id,
Path: "/",
MaxAge: int(reactionCookieTTL.Seconds()),
HttpOnly: true,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteLaxMode,
})
return id
}
// The visitor identity cookie (see visitors.go) identifies the browser, so a
// count can't be inflated by hammering the endpoint.
// parseReactionRequest validates the {id} path param and the ?emoji= query.
func parseReactionRequest(c *okapi.Context) (uint64, string, error) {
@@ -69,7 +37,7 @@ func (h *Handler) React(c *okapi.Context) error {
if _, err := h.store.Get(ctx, id); errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(http.StatusNotFound, okapi.M{"error": "entry not found"})
}
who := reactorID(c)
who := visitorFromContext(c).ID
if who == "" {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not identify the browser"})
}
@@ -88,7 +56,7 @@ func (h *Handler) Unreact(c *okapi.Context) error {
if err != nil {
return c.JSON(http.StatusBadRequest, okapi.M{"error": err.Error()})
}
who := reactorID(c)
who := visitorFromContext(c).ID
if who == "" {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not identify the browser"})
}
@@ -101,7 +69,7 @@ func (h *Handler) Unreact(c *okapi.Context) error {
}
// countRequests is middleware that tallies the HTTP requests this replica has
// answered since it started. The number lands in /healthz, /api/info and the
// answered since boot. The number lands in /healthz, /api/info and the
// admin replica table: it grows per replica under load-balancing and drops
// back to ~0 when a rollout replaces a container, which makes recreate and
// rolling updates visible from the outside.