Files
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

233 lines
7.4 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(),
"visitors": h.visitorsActive(ctx),
"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})
}
// AdminVisitors lists the most recently active visitor identities, so the
// console can show the cookies the app has issued and who is around now.
func (h *Handler) AdminVisitors(c *okapi.Context) error {
limit := atoiDefault(c.Query("limit"), 50)
visitors, err := h.store.AdminVisitors(c.Request().Context(), limit)
if err != nil {
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not load visitors"})
}
fresh := time.Now().Add(-visitorFreshWindow)
return c.OK(okapi.M{"visitors": visitors, "active_since": fresh.UTC().Format(time.RFC3339)})
}
// 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
}