Files
guestbook/reactions.go
T
Akili 3a68f64eb6 feat: add emoji reactions, light/dark themes and rollout observability
Add a set of guest-facing and operator-facing features that make the
deployment-strategy demos (recreate, rolling, canary) more visual:

- Emoji reactions (heart/tada/thumbsup/smile): visitors react without an
  account, a first-party cookie keeps counts honest, the tally streams
  live to every tab over SSE. Backed by a new reactions table with an
  idempotent upsert; whitelisted emoji so the endpoint can't be abused.
- Light & dark themes: the wall follows the OS preference, visitors can
  toggle it (persisted locally), and a moderator can force a theme for
  everyone from the admin console, pushed live over SSE.
- Per-replica request counter: a middleware tallies requests answered by
  each replica; surfaced in /healthz, /api/info and the admin replica
  table. Under load-balancing the counts diverge, and a rolling update
  resets them one container at a time — the rollout made visible.
- Rolling-update wave in the Chaos lab: bounces replicas one at a time
  (via the crash endpoint) while polling the replica table, so a training
  session can watch each replica reset and rejoin — what a rolling update
  looks like from the inside.
- Admin console: reactions tile, per-replica Requests column, reaction
  tallies in moderation, and a wall-theme setting.
- Favicon + /favicon.ico redirect.

Tests cover reactions (idempotency, tallies, whitelist) and the theme
setting round-trip.

Akili-Task: tsk_xgg6sfuzkzymyc6gstr4
Akili-Agent: goldfish
2026-10-05 21:36:23 +02:00

112 lines
3.6 KiB
Go

package main
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strconv"
"time"
"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.
// 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
}
// 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 := reactorID(c)
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 := reactorID(c)
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 it started. 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()
}