Files
guestbook/admin.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

220 lines
6.8 KiB
Go

package main
import (
"encoding/csv"
"errors"
"net/http"
"runtime"
"strconv"
"strings"
"time"
"github.com/jkaninda/okapi"
"gorm.io/gorm"
)
const maxBannerLen = 200
type loginRequest struct {
Token string `json:"token"`
}
// adminEntry exposes the fields public responses omit.
type adminEntry struct {
Entry
IP string `json:"ip,omitempty"`
}
func toAdmin(e Entry) adminEntry { return adminEntry{Entry: e, IP: e.IP} }
type flagsRequest struct {
Pinned *bool `json:"pinned"`
Hidden *bool `json:"hidden"`
}
// AdminSession reports whether the console is enabled and the caller signed in.
func (h *Handler) AdminSession(c *okapi.Context) error {
return c.OK(okapi.M{
"enabled": h.auth.Enabled(),
"authenticated": h.auth.Authenticated(c),
})
}
// AdminLogin exchanges ADMIN_TOKEN for a session cookie.
func (h *Handler) AdminLogin(c *okapi.Context) error {
if !h.auth.Enabled() {
return c.JSON(http.StatusNotFound, okapi.M{"error": "admin console is disabled (set ADMIN_TOKEN)"})
}
var req loginRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid JSON body"})
}
if !h.auth.CheckToken(strings.TrimSpace(req.Token)) {
// Slows down guessing without needing shared rate-limit state.
time.Sleep(700 * time.Millisecond)
return c.JSON(http.StatusUnauthorized, okapi.M{"error": "invalid admin token"})
}
h.auth.SetSession(c, h.auth.Issue(time.Now()))
return c.OK(okapi.M{"authenticated": true})
}
// AdminLogout clears the session cookie.
func (h *Handler) AdminLogout(c *okapi.Context) error {
h.auth.SetSession(c, "")
return c.NoContent()
}
// AdminOverview returns wall statistics plus runtime details of the replica
// that answered, which is what makes scaling visible in the console.
func (h *Handler) AdminOverview(c *okapi.Context) error {
ctx := c.Request().Context()
stats, err := h.store.Stats(ctx, 14)
if err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not compute stats"})
}
settings, _ := h.store.Settings(ctx)
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
return c.OK(okapi.M{
"stats": stats,
"settings": settings,
"online": h.broker.Online(),
"replicas": h.broker.Replicas(),
"debug": h.debug,
"runtime": okapi.M{
"app": h.appName,
"version": h.version,
"host": h.host,
"db": dbDisplayName(h.dbDriver),
"broker": h.broker.Mode(),
"redis": h.broker.RedisStatus(ctx),
"healthy": !h.unhealthy.Load(),
"started_at": h.started.UTC().Format(time.RFC3339),
"uptime_s": int64(time.Since(h.started).Seconds()),
"requests": h.requests.Load(),
"go": runtime.Version(),
"cpus": runtime.NumCPU(),
"goroutines": runtime.NumGoroutine(),
"heap_mb": float64(mem.HeapAlloc) / (1 << 20),
"sys_mb": float64(mem.Sys) / (1 << 20),
},
})
}
// AdminListEntries lists every entry, hidden ones included, with filters.
func (h *Handler) AdminListEntries(c *okapi.Context) error {
limit := atoiDefault(c.Query("limit"), 25)
if limit <= 0 || limit > 200 {
limit = 25
}
offset := max(atoiDefault(c.Query("offset"), 0), 0)
entries, total, err := h.store.AdminList(c.Request().Context(), EntryFilter{
Status: c.Query("status"),
Query: c.Query("q"),
Limit: limit,
Offset: offset,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not load entries"})
}
out := make([]adminEntry, len(entries))
for i, e := range entries {
out[i] = toAdmin(e)
}
return c.OK(okapi.M{"entries": out, "total": total, "limit": limit, "offset": offset})
}
// AdminUpdateEntry pins/unpins or hides/unhides an entry and broadcasts it.
func (h *Handler) AdminUpdateEntry(c *okapi.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid id"})
}
var req flagsRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid JSON body"})
}
entry, err := h.store.SetFlags(c.Request().Context(), id, req.Pinned, req.Hidden)
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(http.StatusNotFound, okapi.M{"error": "entry not found"})
}
if err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not update entry"})
}
h.broker.Publish(Event{Type: "updated", Entry: &entry})
return c.OK(toAdmin(entry))
}
// AdminSettings returns the shared wall settings.
func (h *Handler) AdminSettings(c *okapi.Context) error {
st, err := h.store.Settings(c.Request().Context())
if err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not load settings"})
}
return c.OK(st)
}
// AdminSaveSettings persists the wall settings and broadcasts them so every
// open tab on every replica reacts immediately.
func (h *Handler) AdminSaveSettings(c *okapi.Context) error {
var st Settings
if err := c.Bind(&st); err != nil {
return c.JSON(http.StatusBadRequest, okapi.M{"error": "invalid JSON body"})
}
st.Banner = strings.TrimSpace(st.Banner)
if len(st.Banner) > maxBannerLen {
return c.JSON(http.StatusBadRequest, okapi.M{"error": "banner is too long (max 200 characters)"})
}
switch st.Theme {
case "light", "dark":
// forced appearance for every visitor
case "", "system":
st.Theme = "" // follow each visitor's preference
default:
return c.JSON(http.StatusBadRequest, okapi.M{"error": "theme must be system, light or dark"})
}
if err := h.store.SaveSettings(c.Request().Context(), st); err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save settings"})
}
h.broker.Publish(Event{Type: "settings", Settings: &st})
return c.OK(st)
}
// AdminExport streams every entry as CSV.
func (h *Handler) AdminExport(c *okapi.Context) error {
entries, err := h.store.All(c.Request().Context())
if err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not export entries"})
}
w := c.ResponseWriter()
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="guestbook-`+time.Now().UTC().Format("20060102-150405")+`.csv"`)
cw := csv.NewWriter(w)
_ = cw.Write([]string{"id", "name", "message", "pinned", "hidden", "replica", "ip", "created_at"})
for _, e := range entries {
_ = cw.Write([]string{
strconv.FormatUint(uint64(e.ID), 10),
csvSafe(e.Name),
csvSafe(e.Message),
strconv.FormatBool(e.Pinned),
strconv.FormatBool(e.Hidden),
e.Replica,
e.IP,
e.CreatedAt.UTC().Format(time.RFC3339),
})
}
cw.Flush()
return cw.Error()
}
// csvSafe neutralises values a spreadsheet would evaluate as a formula.
func csvSafe(s string) string {
if s != "" && strings.ContainsRune("=+-@\t\r", rune(s[0])) {
return "'" + s
}
return s
}