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
80 lines
2.9 KiB
Go
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()
|
|
}
|