Files
guestbook/reactions.go
T
Akili 86215f1eb1 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
2026-10-07 19:06:07 +02:00

80 lines
2.9 KiB
Go

package main
import (
"errors"
"net/http"
"strconv"
"github.com/jkaninda/okapi"
"gorm.io/gorm"
)
// 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.
// 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) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
return 0, "", errors.New("invalid id")
}
emoji := c.Query("emoji")
if !validReaction(emoji) {
return 0, "", errors.New("unknown emoji (want one of: heart, tada, thumbsup, smile)")
}
return id, emoji, nil
}
// React adds a reaction to an entry and broadcasts the new tally live.
func (h *Handler) React(c *okapi.Context) error {
ctx := c.Request().Context()
id, emoji, err := parseReactionRequest(c)
if err != nil {
return c.JSON(http.StatusBadRequest, okapi.M{"error": err.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 := visitorFromContext(c).ID
if who == "" {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not identify the browser"})
}
count, err := h.store.React(ctx, id, emoji, who)
if err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save the reaction"})
}
h.broker.Publish(Event{Type: "reaction", ID: uint(id), Emoji: emoji, Count: count})
return c.OK(okapi.M{"entry_id": id, "emoji": emoji, "count": count})
}
// Unreact removes the browser's reaction and broadcasts the new tally live.
func (h *Handler) Unreact(c *okapi.Context) error {
ctx := c.Request().Context()
id, emoji, err := parseReactionRequest(c)
if err != nil {
return c.JSON(http.StatusBadRequest, okapi.M{"error": err.Error()})
}
who := visitorFromContext(c).ID
if who == "" {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not identify the browser"})
}
count, err := h.store.Unreact(ctx, id, emoji, who)
if err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not remove the reaction"})
}
h.broker.Publish(Event{Type: "reaction", ID: uint(id), Emoji: emoji, Count: count})
return c.OK(okapi.M{"entry_id": id, "emoji": emoji, "count": count})
}
// countRequests is middleware that tallies the HTTP requests this replica has
// 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.
func (h *Handler) countRequests(c *okapi.Context) error {
h.requests.Add(1)
return c.Next()
}