86215f1eb1
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
121 lines
4.0 KiB
Go
121 lines
4.0 KiB
Go
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)
|
|
}
|