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:
+120
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jkaninda/okapi"
|
||||
)
|
||||
|
||||
// Visitor identity. Until now the only cookie was the reaction de-dupe token:
|
||||
// accepted blindly, never checked against anything. The wall now issues a
|
||||
// first-party identity cookie and keeps the matching row in the database, so
|
||||
// the backend can tell a browser it has seen before from one it has not.
|
||||
//
|
||||
// The flow, on every API request (see identifyVisitor):
|
||||
//
|
||||
// 1. no cookie / malformed id → mint a new UUID, persist it, set the cookie
|
||||
// 2. cookie with a known id → the visitor is recognised (last_seen stamped)
|
||||
// 3. cookie with an unknown id → the database was reset, the identity was
|
||||
// pruned by the cleanup job, or the value was forged: discard it and
|
||||
// generate a fresh identity as in (1)
|
||||
//
|
||||
// Identities are anonymous (a random UUID), recognised — never authenticated.
|
||||
|
||||
const (
|
||||
visitorCookie = "gb_visitor"
|
||||
visitorCookieTTL = 365 * 24 * time.Hour
|
||||
// visitorFreshWindow is how long an identity counts as "visiting now".
|
||||
visitorFreshWindow = 5 * time.Minute
|
||||
)
|
||||
|
||||
// visitorCtxKey is where identifyVisitor stashes the resolved *Visitor.
|
||||
const visitorCtxKey = "gb.visitor"
|
||||
|
||||
// identifyVisitor is middleware that resolves (or creates) the caller's
|
||||
// visitor identity for every API request and stores it on the context; see
|
||||
// the package comment above for the flow.
|
||||
func (h *Handler) identifyVisitor(c *okapi.Context) error {
|
||||
visitor, fresh, err := h.resolveVisitor(c)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not resolve visitor identity"})
|
||||
}
|
||||
if fresh {
|
||||
h.setVisitorCookie(c, visitor.ID)
|
||||
}
|
||||
c.Set(visitorCtxKey, &visitor)
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// resolveVisitor returns the caller's identity; fresh reports whether a new
|
||||
// visitor row was created and the cookie must be (re)set.
|
||||
func (h *Handler) resolveVisitor(c *okapi.Context) (visitor Visitor, fresh bool, err error) {
|
||||
ctx := c.Request().Context()
|
||||
if id, cerr := c.Cookie(visitorCookie); cerr == nil && ValidVisitorID(id) {
|
||||
visitor, found, err := h.store.FindVisitor(ctx, id)
|
||||
if err != nil {
|
||||
return Visitor{}, false, err
|
||||
}
|
||||
if found {
|
||||
// Refresh the cookie's lifetime while the visitor stays active, and
|
||||
// stamp the visit so presence and pruning have something to work with.
|
||||
h.setVisitorCookie(c, id)
|
||||
h.store.TouchVisitor(ctx, id, time.Now().UTC())
|
||||
return visitor, false, nil
|
||||
}
|
||||
// Known shape, unknown id: fall through and mint a new identity.
|
||||
}
|
||||
visitor, err = h.store.CreateVisitor(ctx, NewVisitorID())
|
||||
if err != nil {
|
||||
return Visitor{}, false, err
|
||||
}
|
||||
return visitor, true, nil
|
||||
}
|
||||
|
||||
// setVisitorCookie writes (or refreshes) the identity cookie.
|
||||
func (h *Handler) setVisitorCookie(c *okapi.Context, id string) {
|
||||
r := c.Request()
|
||||
http.SetCookie(c.ResponseWriter(), &http.Cookie{
|
||||
Name: visitorCookie,
|
||||
Value: id,
|
||||
Path: "/",
|
||||
MaxAge: int(visitorCookieTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// visitorFromContext returns the identity resolved by the middleware; the
|
||||
// zero value means "unidentified" (e.g. a request that bypassed the group).
|
||||
func visitorFromContext(c *okapi.Context) *Visitor {
|
||||
if v, ok := c.Get(visitorCtxKey); ok {
|
||||
if visitor, ok := v.(*Visitor); ok {
|
||||
return visitor
|
||||
}
|
||||
}
|
||||
return &Visitor{}
|
||||
}
|
||||
|
||||
// VisitorMe lets the UI learn its own identity: whether the cookie was just
|
||||
// (re)generated and the remembered name to pre-fill the form with.
|
||||
func (h *Handler) VisitorMe(c *okapi.Context) error {
|
||||
v := visitorFromContext(c)
|
||||
return c.OK(okapi.M{
|
||||
"id": v.ID,
|
||||
"name": v.Name,
|
||||
"first_seen": v.FirstSeen.UTC().Format(time.RFC3339),
|
||||
"signatures": v.Signatures,
|
||||
})
|
||||
}
|
||||
|
||||
// visitorName returns the name under which the visitor last signed, so the
|
||||
// server can attach it to an unsigned signature.
|
||||
func visitorName(v *Visitor) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(v.Name)
|
||||
}
|
||||
Reference in New Issue
Block a user