3a68f64eb6
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
112 lines
3.6 KiB
Go
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()
|
|
}
|