Merge pull request #6 from miabi-io/akili/tsk_ptdi5ufndrzwlljk4v5g
feat: visitor identity cookie + database reset job
This commit is contained in:
@@ -17,7 +17,7 @@ handy for logs, events and scaling.
|
||||
| 5 | Attach Redis → fixed | managed Redis, env prefix |
|
||||
| 6 | Break it on purpose (+ rolling wave) | restarts, health gating, alerts, logs, analytics |
|
||||
| 7 | Canary v1 → v2 | canary weights, promote |
|
||||
| 8 | Scheduled cleanup | cron jobs |
|
||||
| 8 | Scheduled cleanup + reset | cron jobs, one-off jobs |
|
||||
|
||||
---
|
||||
|
||||
@@ -67,6 +67,13 @@ Ask the audience to open the wall on their phones and sign it. The
|
||||
**online** counter climbs live. Have them tap the ❤️ / 🎉 reactions — the
|
||||
tallies move on every screen at once, with no refresh.
|
||||
|
||||
> Talking point — visitor identity: each browser also gets a `gb_visitor`
|
||||
> cookie, and the wall greets it ("you are guest-… · N signatures"). The
|
||||
> backend only accepts the cookie while its id exists in the database — in
|
||||
> the admin console, the **Visitors** tile and table show every identity
|
||||
> issued, and **Visiting** counts the ones seen in the last 5 minutes.
|
||||
> Reactions are keyed on the same identity.
|
||||
|
||||
## 4. Scale out, and watch it break
|
||||
|
||||
1. App → **Replicas** → scale to **3**.
|
||||
@@ -136,18 +143,35 @@ updates, shown from the inside.
|
||||
> differ for the demo to work — but the clock card (absent in v1) makes the
|
||||
> split visible to the audience without a terminal.
|
||||
|
||||
## 8. Scheduled cleanup
|
||||
## 8. Scheduled cleanup and reset
|
||||
|
||||
The binary has a `cleanup` subcommand that permanently removes deleted
|
||||
signatures and optionally expires old ones:
|
||||
signatures, optionally expires old ones, and prunes idle visitor identities:
|
||||
|
||||
```bash
|
||||
guestbook cleanup --purge-after 168h # default: purge deletes older than 7 days
|
||||
guestbook cleanup --max-age 720h --dry-run # preview expiring >30-day-old, unpinned entries
|
||||
guestbook cleanup --prune-visitors 720h # forget identities idle for 30 days (and their reactions)
|
||||
```
|
||||
|
||||
1. **Jobs** → *New cronjob*, image blank (uses the app's image), command `/app/guestbook cleanup --purge-after 1h`, schedule `*/15 * * * *`.
|
||||
2. Delete a signature in **Moderation**, then **Run now**. The job log shows `cleanup finished purged=… remaining=…`.
|
||||
2. Delete a signature in **Moderation**, then **Run now**. The job log shows `cleanup finished purged=… visitors_pruned=… remaining=…`.
|
||||
|
||||
There is also a `reset` subcommand to **clean the database** between sessions
|
||||
— entries, reactions and visitor identities go (soft-deleted rows are wiped
|
||||
for good), wall settings are kept unless `--hard`:
|
||||
|
||||
```bash
|
||||
guestbook reset --dry-run # report what would be removed
|
||||
guestbook reset --reseed # wipe, then insert the sample signatures
|
||||
guestbook reset --hard --reseed # also reset banner/pause/theme
|
||||
```
|
||||
|
||||
3. **Jobs** → *New job*, command `/app/guestbook reset --reseed`. The wall
|
||||
empties immediately; every browser's `gb_visitor` cookie stops matching a
|
||||
database row, so on the next request each visitor is handed a **fresh
|
||||
identity** — the "you are …" card on the wall changes, and the admin
|
||||
Visitors table starts refilling from zero.
|
||||
|
||||
`guestbook migrate` works the same way as a one-off **Job**, for teams that
|
||||
want migrations as an explicit step rather than on boot.
|
||||
@@ -158,4 +182,7 @@ want migrations as an explicit step rather than on boot.
|
||||
|
||||
- Admin → Settings: clear the banner, set the theme back to *System*, un-pause.
|
||||
- Chaos lab: **Healthy**.
|
||||
- To wipe the wall: detach/recreate the database, or `DELETE FROM entries; DELETE FROM reactions;` and restart (the seed runs when the table is empty).
|
||||
- To wipe the wall: run `/app/guestbook reset --reseed` as a one-off **Job**
|
||||
(entries, reactions and visitor identities go; the wall is re-seeded), or
|
||||
detach/recreate the database. Browsers pick up a fresh identity cookie on
|
||||
their next request either way.
|
||||
|
||||
@@ -19,9 +19,15 @@ miniature:
|
||||
- **Live updates over SSE** — new/removed signatures stream to every open tab,
|
||||
with a real-time **connected-clients** ("N online") indicator
|
||||
- **Emoji reactions** — visitors cheer a signature with ❤️ 🎉 👍 😄 (no
|
||||
account; a cookie keeps counts honest) and the tally streams live to every
|
||||
tab. Each reaction counts against a whitelist, so the endpoint can't be
|
||||
used to spam arbitrary emoji
|
||||
account; the visitor identity keeps counts honest) and the tally streams
|
||||
live to every tab. Each reaction counts against a whitelist, so the
|
||||
endpoint can't be used to spam arbitrary emoji
|
||||
- **Visitor identity cookie** (new in v3) — every browser gets a `gb_visitor`
|
||||
cookie whose UUID the backend checks **against the database** on each API
|
||||
request: unknown ids (first visit, pruned identity, or a wiped database) are
|
||||
discarded and regenerated. The identity remembers the name you signed with,
|
||||
drives the reaction de-dupe, and feeds a live **“N visiting”** counter
|
||||
(distinct from connected SSE clients)
|
||||
- **Light & dark themes** — the wall follows the OS preference, visitors can
|
||||
toggle it, and a moderator can **force a theme for everyone** from the admin
|
||||
console (pushed live over SSE)
|
||||
@@ -53,8 +59,10 @@ miniature:
|
||||
hold memory, burst logs, inject latency/5xx, a **rolling-update wave** that
|
||||
bounces replicas one at a time while the replica table shows each one reset,
|
||||
plus an in-browser traffic probe that shows load-balancing and canary splits
|
||||
- **Scheduled-job friendly CLI** — `guestbook cleanup` (purge/expire) and
|
||||
`guestbook migrate` subcommands for Miabi cron jobs and one-off jobs
|
||||
- **Scheduled-job friendly CLI** — `guestbook cleanup` (purge/expire/prune
|
||||
visitors) and `guestbook migrate` for Miabi cron jobs, plus
|
||||
**`guestbook reset`** to wipe the wall (entries, reactions, identities;
|
||||
`--reseed`, `--hard`) as a one-off job
|
||||
|
||||
**Presenting it?** [`DEMO.md`](DEMO.md) is a step-by-step training runbook.
|
||||
- **Single static binary** — the whole UI is embedded with `go:embed`, so the
|
||||
@@ -75,9 +83,10 @@ and keeps the container tiny.
|
||||
| Method | Path | Description |
|
||||
|----------|----------------------|------------------------------------|
|
||||
| `GET` | `/healthz` | Liveness/readiness (checks the DB), reports `version`, `host`, `redis`, `requests` |
|
||||
| `GET` | `/api/info` | App name, serving `version`, `host`, online count, `broker` mode, `replicas` (with per-replica request counts), `requests`, reaction whitelist |
|
||||
| `GET` | `/api/info` | App name, serving `version`, `host`, online count, active `visitors`, `broker` mode, `replicas` (with per-replica request counts), `requests`, reaction whitelist |
|
||||
| `GET` | `/api/time` | Current server time + `version` + `host` (v2 — canary probe) |
|
||||
| `GET` | `/api/settings` | Public wall settings: `signing_paused`, `banner`, `theme` |
|
||||
| `GET` | `/api/me` | The caller's visitor identity: id, remembered name, signature count, first seen |
|
||||
| `GET` | `/api/entries` | Visible entries (pinned first) with reaction tallies + total; paginated via `?limit=&offset=` |
|
||||
| `POST` | `/api/entries` | Create `{ "name", "message" }` (broadcast live; `503` while paused) |
|
||||
| `POST` | `/api/entries/{id}/reactions?emoji=` | Add this browser's reaction (`heart`·`tada`·`thumbsup`·`smile`); idempotent, broadcasts the new tally |
|
||||
@@ -93,7 +102,8 @@ or an `Authorization: Bearer <ADMIN_TOKEN>` header. Disabled (`404`) when
|
||||
|
||||
| Method | Path | Description |
|
||||
|----------|-------------------------------|-------------|
|
||||
| `GET` | `/api/admin/overview` | Stats (14-day histogram, reaction totals), replicas (with per-replica request counts), runtime of the answering replica |
|
||||
| `GET` | `/api/admin/overview` | Stats (14-day histogram, reaction + visitor totals), replicas (with per-replica request counts), runtime of the answering replica |
|
||||
| `GET` | `/api/admin/visitors` | Recent visitor identities (name remembered, signature count, last seen; `?limit=`) |
|
||||
| `GET` | `/api/admin/entries` | All entries with reaction tallies; `?status=visible\|hidden\|pinned&q=&limit=&offset=` |
|
||||
| `PATCH` | `/api/admin/entries/{id}` | `{ "pinned"?: bool, "hidden"?: bool }` |
|
||||
| `DELETE` | `/api/admin/entries/{id}` | Soft-delete an entry |
|
||||
@@ -133,6 +143,26 @@ each replica advertises its client count **and request count** under a
|
||||
short-TTL key so presence is cluster-wide. `tick` events stay per-replica on
|
||||
purpose: the clock shows who served you.
|
||||
|
||||
### Visitor identity
|
||||
|
||||
Every API request passes through middleware that resolves the caller's
|
||||
**visitor identity**: if the `gb_visitor` cookie is missing, malformed, or its
|
||||
id is **not found in the `visitors` table**, the backend mints a new UUID,
|
||||
persists it and rewrites the cookie. Known ids are kept and stamped
|
||||
(`last_seen`). The identity:
|
||||
|
||||
- remembers the **name** you signed with (pre-filled next time, used for
|
||||
unsigned posts),
|
||||
- keys **reaction** de-duplication (replacing the old standalone reaction
|
||||
cookie),
|
||||
- feeds the **“N visiting”** counter on the wall, in `/api/info`, the admin
|
||||
overview and the `tick`/`presence` SSE events (identities seen in the last
|
||||
5 minutes — read from the database, so it is cluster-wide with no Redis).
|
||||
|
||||
Because validity lives in the database, a `guestbook reset` (or pruning by the
|
||||
`cleanup` job) invalidates every outstanding cookie: browsers silently get a
|
||||
fresh identity on their next request.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
@@ -176,7 +206,8 @@ go run . # runs the default "server" command
|
||||
go run . server -p 9000 # okapicli flags: choose the port
|
||||
go run . --help # list commands and flags
|
||||
go run . migrate # apply migrations and exit
|
||||
go run . cleanup --purge-after 168h --max-age 720h --dry-run
|
||||
go run . cleanup --purge-after 168h --max-age 720h --prune-visitors 720h --dry-run
|
||||
go run . reset --dry-run # preview a full wipe; add --reseed to refill the wall
|
||||
```
|
||||
|
||||
To point it at your own Postgres, set `DATABASE_URL` (or the `DB_*` vars) first:
|
||||
|
||||
@@ -81,6 +81,7 @@ func (h *Handler) AdminOverview(c *okapi.Context) error {
|
||||
"stats": stats,
|
||||
"settings": settings,
|
||||
"online": h.broker.Online(),
|
||||
"visitors": h.visitorsActive(ctx),
|
||||
"replicas": h.broker.Replicas(),
|
||||
"debug": h.debug,
|
||||
"runtime": okapi.M{
|
||||
@@ -127,6 +128,18 @@ func (h *Handler) AdminListEntries(c *okapi.Context) error {
|
||||
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)
|
||||
|
||||
@@ -19,8 +19,10 @@ import (
|
||||
// - "deleted" → ID is set (a removed signature)
|
||||
// - "reaction" → ID/Emoji/Count are set (an entry's tally changed)
|
||||
// - "settings" → Settings is set (signing paused, banner changed, theme forced)
|
||||
// - "presence" → Online/Replicas are set (connected clients changed)
|
||||
// - "tick" → Time/Version/Host are set (live server clock; new in v2)
|
||||
// - "presence" → Online/Replicas/Visitors are set (connected clients or active
|
||||
// visitor identities changed)
|
||||
// - "tick" → Time/Version/Host are set (live server clock; new in v2),
|
||||
// plus Visitors (active identities; new in v3)
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Entry *Entry `json:"entry,omitempty"`
|
||||
@@ -30,6 +32,7 @@ type Event struct {
|
||||
Settings *Settings `json:"settings,omitempty"`
|
||||
Online int `json:"online,omitempty"`
|
||||
Replicas int `json:"replicas,omitempty"`
|
||||
Visitors int64 `json:"visitors,omitempty"`
|
||||
Time string `json:"time,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
@@ -69,6 +72,9 @@ type Broker struct {
|
||||
rdb *redis.Client
|
||||
dirty chan struct{}
|
||||
requests func() int64
|
||||
// visitors reports how many visitor identities were recently active (from
|
||||
// the database, so it is cluster-wide by construction); may be nil.
|
||||
visitors func() int64
|
||||
|
||||
cmu sync.RWMutex
|
||||
replicas []Replica
|
||||
@@ -229,7 +235,7 @@ func (b *Broker) localCount() int {
|
||||
|
||||
func (b *Broker) presenceChanged() {
|
||||
if b.rdb == nil {
|
||||
b.emit(Event{Type: "presence", Online: b.localCount(), Replicas: 1})
|
||||
b.emit(Event{Type: "presence", Online: b.localCount(), Replicas: 1, Visitors: b.visitorsActive()})
|
||||
return
|
||||
}
|
||||
select {
|
||||
@@ -238,6 +244,15 @@ func (b *Broker) presenceChanged() {
|
||||
}
|
||||
}
|
||||
|
||||
// visitorsActive returns the recent-visitor count, or 0 when no reporter is
|
||||
// registered.
|
||||
func (b *Broker) visitorsActive() int64 {
|
||||
if b.visitors == nil {
|
||||
return 0
|
||||
}
|
||||
return b.visitors()
|
||||
}
|
||||
|
||||
func (b *Broker) publishRaw(ctx context.Context, ev Event) error {
|
||||
payload, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
@@ -321,7 +336,7 @@ func (b *Broker) refresh(ctx context.Context) {
|
||||
b.cmu.Unlock()
|
||||
|
||||
if changed {
|
||||
b.emit(Event{Type: "presence", Online: total, Replicas: len(replicas)})
|
||||
b.emit(Event{Type: "presence", Online: total, Replicas: len(replicas), Visitors: b.visitorsActive()})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jkaninda/go-utils v0.1.4
|
||||
github.com/jkaninda/logger v0.0.5
|
||||
github.com/jkaninda/okapi v1.0.0
|
||||
@@ -21,7 +22,6 @@ require (
|
||||
github.com/go-openapi/jsonpointer v0.22.5 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
|
||||
+27
-3
@@ -98,9 +98,21 @@ func (h *Handler) Info(c *okapi.Context) error {
|
||||
"requests": h.requests.Load(),
|
||||
"admin": h.auth.Enabled(),
|
||||
"reactions": ReactionEmojis(),
|
||||
"visitors": h.visitorsActive(c.Request().Context()),
|
||||
})
|
||||
}
|
||||
|
||||
// visitorsActive counts identities seen in the last few minutes — the
|
||||
// "currently visiting" number. It degrades to 0 on a database hiccup; the
|
||||
// SSE presence event simply carries whatever the database last said.
|
||||
func (h *Handler) visitorsActive(ctx context.Context) int64 {
|
||||
n, err := h.store.VisitorsSeenAfter(ctx, time.Now().Add(-visitorFreshWindow))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// PublicSettings returns the wall state visitors need: paused flag and banner.
|
||||
func (h *Handler) PublicSettings(c *okapi.Context) error {
|
||||
st, err := h.store.Settings(c.Request().Context())
|
||||
@@ -172,9 +184,6 @@ func (h *Handler) CreateEntry(c *okapi.Context) error {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
message := strings.TrimSpace(req.Message)
|
||||
|
||||
if name == "" {
|
||||
name = "Anonymous"
|
||||
}
|
||||
if r := []rune(name); len(r) > maxNameLen {
|
||||
name = string(r[:maxNameLen])
|
||||
}
|
||||
@@ -187,10 +196,24 @@ func (h *Handler) CreateEntry(c *okapi.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// The visitor identity ties the signature to a browser: an unsigned post
|
||||
// keeps the name the visitor used before, and a signed post is remembered
|
||||
// for next time.
|
||||
visitor := visitorFromContext(c)
|
||||
if name == "" {
|
||||
name = visitorName(visitor)
|
||||
}
|
||||
if name == "" {
|
||||
name = "Anonymous"
|
||||
}
|
||||
|
||||
entry, err := h.store.Create(ctx, name, message, h.host, clientIP(c))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save entry"})
|
||||
}
|
||||
if !visitor.IsZero() {
|
||||
h.store.VisitorSigned(ctx, visitor.ID, name)
|
||||
}
|
||||
|
||||
h.broker.Publish(Event{Type: "created", Entry: &entry})
|
||||
return c.Created(entry)
|
||||
@@ -233,6 +256,7 @@ func (h *Handler) Stream(c *okapi.Context) error {
|
||||
"online": h.broker.Online(),
|
||||
"replicas": len(h.broker.Replicas()),
|
||||
"host": h.host,
|
||||
"visitors": h.visitorsActive(ctx),
|
||||
}}
|
||||
select {
|
||||
case msgs <- welcome:
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// /healthz readiness probe, and a single static binary that ships the whole
|
||||
// frontend embedded — no Node build step.
|
||||
//
|
||||
// Subcommands (okapicli): server (default), migrate, cleanup.
|
||||
// Subcommands (okapicli): server (default), migrate, cleanup, reset.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -61,6 +61,14 @@ func main() {
|
||||
}).
|
||||
Duration("purge-after", "", 7*24*time.Hour, "Permanently remove entries deleted longer ago than this").
|
||||
Duration("max-age", "", 0, "Also delete unpinned entries older than this (0 = keep forever)").
|
||||
Duration("prune-visitors", "", 30*24*time.Hour, "Forget visitor identities idle longer than this (0 = keep forever)").
|
||||
Bool("dry-run", "", false, "Report what would be removed without changing anything")
|
||||
|
||||
cli.Command("reset", "Wipe the database: entries, reactions and visitor identities (run as a one-off job)", func(cmd *okapicli.Command) error {
|
||||
return runReset(cmd, cfg)
|
||||
}).
|
||||
Bool("hard", "", false, "Also reset the wall settings (pause, banner, theme)").
|
||||
Bool("reseed", "", false, "Insert the sample signatures after the wipe").
|
||||
Bool("dry-run", "", false, "Report what would be removed without changing anything")
|
||||
|
||||
cli.DefaultCommand("server")
|
||||
@@ -107,6 +115,7 @@ func runServer(cmd *okapicli.Command, cfg Config) error {
|
||||
started: time.Now(),
|
||||
}
|
||||
broker.requests = h.requests.Load
|
||||
broker.visitors = func() int64 { return h.visitorsActive(context.Background()) }
|
||||
if !h.auth.Enabled() {
|
||||
logger.Warn("admin console disabled: set ADMIN_TOKEN to enable /admin")
|
||||
}
|
||||
@@ -139,10 +148,14 @@ func runServer(cmd *okapicli.Command, cfg Config) error {
|
||||
func registerRoutes(app *okapi.Okapi, h *Handler) {
|
||||
app.Get("/healthz", h.Health)
|
||||
|
||||
api := app.Group("/api")
|
||||
// Every API request resolves the visitor identity: the cookie is checked
|
||||
// against the database and regenerated when its id is unknown (fresh
|
||||
// browser, pruned identity, or a reset database).
|
||||
api := app.Group("/api", h.identifyVisitor)
|
||||
api.Get("/info", h.Info)
|
||||
api.Get("/time", h.Time)
|
||||
api.Get("/settings", h.PublicSettings)
|
||||
api.Get("/me", h.VisitorMe)
|
||||
api.Get("/entries", h.ListEntries)
|
||||
api.Post("/entries", h.CreateEntry)
|
||||
api.Post("/entries/{id:int}/reactions", h.React)
|
||||
@@ -153,13 +166,14 @@ func registerRoutes(app *okapi.Okapi, h *Handler) {
|
||||
api.Post("/admin/login", h.AdminLogin)
|
||||
api.Post("/admin/logout", h.AdminLogout)
|
||||
|
||||
admin := app.Group("/api/admin", h.auth.Require)
|
||||
admin := app.Group("/api/admin", h.identifyVisitor, h.auth.Require)
|
||||
admin.Get("/overview", h.AdminOverview)
|
||||
admin.Get("/entries", h.AdminListEntries)
|
||||
admin.Patch("/entries/{id:int}", h.AdminUpdateEntry)
|
||||
admin.Delete("/entries/{id:int}", h.DeleteEntry)
|
||||
admin.Get("/settings", h.AdminSettings)
|
||||
admin.Put("/settings", h.AdminSaveSettings)
|
||||
admin.Get("/visitors", h.AdminVisitors)
|
||||
admin.Get("/export.csv", h.AdminExport)
|
||||
|
||||
debug := app.Group("/api/debug", h.auth.Require, h.RequireDebug)
|
||||
@@ -211,8 +225,56 @@ func runCleanup(cmd *okapicli.Command, cfg Config) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var pruned int64
|
||||
if prune := cmd.GetDuration("prune-visitors"); prune > 0 {
|
||||
if pruned, err = store.PurgeVisitors(ctx, now.Add(-prune), dryRun); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
remaining, _ := store.Count(ctx)
|
||||
logger.Info("cleanup finished", "purged", purged, "expired", expired, "remaining", remaining, "dry_run", dryRun)
|
||||
logger.Info("cleanup finished",
|
||||
"purged", purged, "expired", expired, "visitors_pruned", pruned,
|
||||
"remaining", remaining, "dry_run", dryRun)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runReset wipes the database (the "clean slate" job): entries, reactions and
|
||||
// visitor identities go, settings stay unless --hard. With --reseed the sample
|
||||
// signatures are inserted right away; otherwise restart the app so its first
|
||||
// boot seeds the empty wall. The wall's cookies stop matching any visitor row
|
||||
// after a reset, so browsers get a fresh identity on their next request.
|
||||
func runReset(cmd *okapicli.Command, cfg Config) error {
|
||||
ctx := context.Background()
|
||||
store, err := openStore(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = store.Close() }()
|
||||
|
||||
dryRun := cmd.GetBool("dry-run")
|
||||
hard := cmd.GetBool("hard")
|
||||
|
||||
stats, err := store.Reset(ctx, hard, dryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dryRun {
|
||||
logger.Info("reset dry run, nothing removed",
|
||||
"entries", stats.Entries, "reactions", stats.Reactions,
|
||||
"visitors", stats.Visitors, "settings", stats.Settings)
|
||||
return nil
|
||||
}
|
||||
logger.Warn("database reset",
|
||||
"entries", stats.Entries, "reactions", stats.Reactions,
|
||||
"visitors", stats.Visitors, "settings", stats.Settings, "hard", hard)
|
||||
|
||||
if cmd.GetBool("reseed") {
|
||||
if err := Seed(ctx, store, cfg.DBDriver); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
logger.Info("wall is empty; restart the app to re-seed it")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -246,6 +308,7 @@ func publishTicks(ctx context.Context, b *Broker, version, host string) {
|
||||
Time: now.Format(time.RFC3339),
|
||||
Version: version,
|
||||
Host: host,
|
||||
Visitors: b.visitorsActive(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+5
-37
@@ -1,12 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jkaninda/okapi"
|
||||
"gorm.io/gorm"
|
||||
@@ -14,37 +11,8 @@ import (
|
||||
|
||||
// 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
|
||||
}
|
||||
// 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) {
|
||||
@@ -69,7 +37,7 @@ func (h *Handler) React(c *okapi.Context) 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)
|
||||
who := visitorFromContext(c).ID
|
||||
if who == "" {
|
||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not identify the browser"})
|
||||
}
|
||||
@@ -88,7 +56,7 @@ func (h *Handler) Unreact(c *okapi.Context) error {
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, okapi.M{"error": err.Error()})
|
||||
}
|
||||
who := reactorID(c)
|
||||
who := visitorFromContext(c).ID
|
||||
if who == "" {
|
||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not identify the browser"})
|
||||
}
|
||||
@@ -101,7 +69,7 @@ func (h *Handler) Unreact(c *okapi.Context) error {
|
||||
}
|
||||
|
||||
// countRequests is middleware that tallies the HTTP requests this replica has
|
||||
// answered since it started. The number lands in /healthz, /api/info and the
|
||||
// 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.
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite" // pure-Go SQLite (no CGO — keeps the static build)
|
||||
"github.com/google/uuid" // visitor identity ids (v4 UUIDs)
|
||||
"github.com/jkaninda/logger"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
@@ -35,8 +37,8 @@ type Entry struct {
|
||||
}
|
||||
|
||||
// Reaction is one emoji reaction to an entry. Visitors react and un-react
|
||||
// without an account, so a short-lived cookie ("who") prevents the same
|
||||
// browser from inflating the count.
|
||||
// without an account; the "who" is their visitor id (see Visitor), so the
|
||||
// same browser cannot inflate the count.
|
||||
type Reaction struct {
|
||||
EntryID uint `gorm:"primaryKey;autoIncrement:false" json:"-"`
|
||||
Emoji string `gorm:"primaryKey;type:varchar(16)" json:"-"`
|
||||
@@ -45,6 +47,23 @@ type Reaction struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// Visitor is a browser the app knows about. The identity cookie carries the
|
||||
// UUID; the row in this table is what makes it valid — a cookie whose id is
|
||||
// not in the database is discarded and regenerated. That keeps identities
|
||||
// honest across database resets and lets stale identities be pruned by the
|
||||
// cleanup job.
|
||||
type Visitor struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
// Name is the last name the visitor signed with; the form pre-fills it.
|
||||
Name string `gorm:"type:varchar(60)" json:"name,omitempty"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `gorm:"index" json:"last_seen"`
|
||||
Signatures int64 `gorm:"not null;default:0" json:"signatures"`
|
||||
}
|
||||
|
||||
// IsZero reports whether v is an unidentified visitor (no valid cookie).
|
||||
func (v *Visitor) IsZero() bool { return v == nil || v.ID == "" }
|
||||
|
||||
// Setting is a key/value row holding app-wide state shared by all replicas.
|
||||
type Setting struct {
|
||||
Key string `gorm:"primaryKey;type:varchar(64)"`
|
||||
@@ -99,6 +118,7 @@ type Stats struct {
|
||||
Pinned int64 `json:"pinned"`
|
||||
Today int64 `json:"today"`
|
||||
Reactions int64 `json:"reactions"`
|
||||
Visitors int64 `json:"visitors"`
|
||||
Daily []DayCount `json:"daily"`
|
||||
}
|
||||
|
||||
@@ -176,7 +196,7 @@ func (s *Store) Ping(ctx context.Context) error {
|
||||
// concurrent AutoMigrate calls race on Postgres catalog inserts.
|
||||
func (s *Store) Migrate() error {
|
||||
return s.Exclusive(context.Background(), func(tx *Store) error {
|
||||
return tx.db.AutoMigrate(&Entry{}, &Setting{}, &Reaction{})
|
||||
return tx.db.AutoMigrate(&Entry{}, &Setting{}, &Reaction{}, &Visitor{})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -299,6 +319,153 @@ func (s *Store) ReactionTotals(ctx context.Context) (int64, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// FindVisitor loads a visitor by id. Returns false when the id is unknown —
|
||||
// the caller then discards the cookie and issues a fresh identity.
|
||||
func (s *Store) FindVisitor(ctx context.Context, id string) (Visitor, bool, error) {
|
||||
var v Visitor
|
||||
err := s.db.WithContext(ctx).First(&v, "id = ?", id).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return v, false, nil
|
||||
}
|
||||
return v, err == nil, err
|
||||
}
|
||||
|
||||
// CreateVisitor inserts a new identity with the given UUID.
|
||||
func (s *Store) CreateVisitor(ctx context.Context, id string) (Visitor, error) {
|
||||
now := time.Now().UTC()
|
||||
v := Visitor{ID: id, FirstSeen: now, LastSeen: now}
|
||||
err := s.db.WithContext(ctx).Create(&v).Error
|
||||
return v, err
|
||||
}
|
||||
|
||||
// TouchVisitor stamps the visitor's last-seen time.
|
||||
func (s *Store) TouchVisitor(ctx context.Context, id string, at time.Time) {
|
||||
s.db.WithContext(ctx).Model(&Visitor{}).Where("id = ?", id).Update("last_seen", at)
|
||||
}
|
||||
|
||||
// VisitorSigned records a new signature: it bumps the counter and remembers
|
||||
// the name, so the form can be pre-filled on the next visit.
|
||||
func (s *Store) VisitorSigned(ctx context.Context, id, name string) {
|
||||
s.db.WithContext(ctx).Model(&Visitor{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{"name": name, "signatures": gorm.Expr("signatures + 1")})
|
||||
}
|
||||
|
||||
// VisitorCount returns the number of identities the app has issued.
|
||||
func (s *Store) VisitorCount(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
err := s.db.WithContext(ctx).Model(&Visitor{}).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// VisitorsSeenAfter counts visitors active since the given time — the
|
||||
// "currently visiting" number, distinct from connected SSE clients.
|
||||
func (s *Store) VisitorsSeenAfter(ctx context.Context, since time.Time) (int64, error) {
|
||||
var n int64
|
||||
err := s.db.WithContext(ctx).Model(&Visitor{}).Where("last_seen >= ?", since).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
|
||||
// AdminVisitors lists the most recently active visitors.
|
||||
func (s *Store) AdminVisitors(ctx context.Context, limit int) ([]Visitor, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
var visitors []Visitor
|
||||
err := s.db.WithContext(ctx).Order("last_seen DESC").Limit(limit).Find(&visitors).Error
|
||||
return visitors, err
|
||||
}
|
||||
|
||||
// PurgeVisitors permanently removes visitors idle since cutoff, together with
|
||||
// their reactions, so a later signup with a fresh cookie starts clean. It
|
||||
// returns the number of visitors removed.
|
||||
func (s *Store) PurgeVisitors(ctx context.Context, cutoff time.Time, dryRun bool) (int64, error) {
|
||||
scope := s.db.WithContext(ctx).Where("last_seen < ?", cutoff)
|
||||
if dryRun {
|
||||
var n int64
|
||||
err := scope.Model(&Visitor{}).Count(&n).Error
|
||||
return n, err
|
||||
}
|
||||
var ids []string
|
||||
if err := scope.Model(&Visitor{}).Pluck("id", &ids).Error; err != nil || len(ids) == 0 {
|
||||
return 0, err
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Where("who IN ?", ids).Delete(&Reaction{}).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
res := s.db.WithContext(ctx).Where("id IN ?", ids).Delete(&Visitor{})
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
// ResetStats reports what Reset removed (or would remove, when DryRun).
|
||||
type ResetStats struct {
|
||||
Entries int64 `json:"entries"`
|
||||
Reactions int64 `json:"reactions"`
|
||||
Visitors int64 `json:"visitors"`
|
||||
Settings int64 `json:"settings"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
// Reset wipes the wall so a demo can start from scratch (typically run as a
|
||||
// one-off Job, followed by a restart to re-seed). Entries and reactions
|
||||
// always go; settings are kept unless hard is set. With dryRun it only
|
||||
// reports what would be removed.
|
||||
func (s *Store) Reset(ctx context.Context, hard, dryRun bool) (ResetStats, error) {
|
||||
var out ResetStats
|
||||
out.DryRun = dryRun
|
||||
err := s.Exclusive(ctx, func(tx *Store) error {
|
||||
count := func(model any, dst *int64, unscoped bool) error {
|
||||
q := tx.db.WithContext(ctx).Model(model)
|
||||
if unscoped {
|
||||
q = q.Unscoped()
|
||||
}
|
||||
return q.Count(dst).Error
|
||||
}
|
||||
// Soft-deleted entries are gone for good too: after a reset there is
|
||||
// nothing left to purge.
|
||||
if err := count(&Entry{}, &out.Entries, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := count(&Reaction{}, &out.Reactions, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := count(&Visitor{}, &out.Visitors, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if hard {
|
||||
if err := count(&Setting{}, &out.Settings, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if dryRun {
|
||||
return nil
|
||||
}
|
||||
wipe := func(model any) error {
|
||||
// "WHERE 1=1" satisfies GORM's global-delete guard.
|
||||
return tx.db.WithContext(ctx).Unscoped().Where("1 = 1").Delete(model).Error
|
||||
}
|
||||
for _, model := range []any{&Entry{}, &Reaction{}, &Visitor{}} {
|
||||
if err := wipe(model); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if hard {
|
||||
return wipe(&Setting{})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// NewVisitorID returns a random UUID for a fresh identity.
|
||||
func NewVisitorID() string { return uuid.NewString() }
|
||||
|
||||
// ValidVisitorID reports whether id is a syntactically valid visitor id.
|
||||
// A malformed cookie is discarded without hitting the database.
|
||||
func ValidVisitorID(id string) bool {
|
||||
return len(id) == 36 && uuid.Validate(id) == nil
|
||||
}
|
||||
|
||||
// Delete soft-deletes an entry by id. Returns true when a row was affected.
|
||||
func (s *Store) Delete(ctx context.Context, id uint64) (bool, error) {
|
||||
res := s.db.WithContext(ctx).Delete(&Entry{}, id)
|
||||
@@ -411,6 +578,7 @@ func (s *Store) Stats(ctx context.Context, days int) (Stats, error) {
|
||||
s.db.WithContext(ctx).Model(&Entry{}).Where("hidden = ?", true).Count(&st.Hidden)
|
||||
s.db.WithContext(ctx).Model(&Entry{}).Where("pinned = ?", true).Count(&st.Pinned)
|
||||
s.db.WithContext(ctx).Model(&Reaction{}).Count(&st.Reactions)
|
||||
s.db.WithContext(ctx).Model(&Visitor{}).Count(&st.Visitors)
|
||||
st.Visible = st.Total - st.Hidden
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jkaninda/okapi"
|
||||
)
|
||||
|
||||
// Visitor identity. Until now the only cookie was the reaction de-dupe token:
|
||||
// accepted blindly, never checked against anything. The wall now issues a
|
||||
// first-party identity cookie and keeps the matching row in the database, so
|
||||
// the backend can tell a browser it has seen before from one it has not.
|
||||
//
|
||||
// The flow, on every API request (see identifyVisitor):
|
||||
//
|
||||
// 1. no cookie / malformed id → mint a new UUID, persist it, set the cookie
|
||||
// 2. cookie with a known id → the visitor is recognised (last_seen stamped)
|
||||
// 3. cookie with an unknown id → the database was reset, the identity was
|
||||
// pruned by the cleanup job, or the value was forged: discard it and
|
||||
// generate a fresh identity as in (1)
|
||||
//
|
||||
// Identities are anonymous (a random UUID), recognised — never authenticated.
|
||||
|
||||
const (
|
||||
visitorCookie = "gb_visitor"
|
||||
visitorCookieTTL = 365 * 24 * time.Hour
|
||||
// visitorFreshWindow is how long an identity counts as "visiting now".
|
||||
visitorFreshWindow = 5 * time.Minute
|
||||
)
|
||||
|
||||
// visitorCtxKey is where identifyVisitor stashes the resolved *Visitor.
|
||||
const visitorCtxKey = "gb.visitor"
|
||||
|
||||
// identifyVisitor is middleware that resolves (or creates) the caller's
|
||||
// visitor identity for every API request and stores it on the context; see
|
||||
// the package comment above for the flow.
|
||||
func (h *Handler) identifyVisitor(c *okapi.Context) error {
|
||||
visitor, fresh, err := h.resolveVisitor(c)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not resolve visitor identity"})
|
||||
}
|
||||
if fresh {
|
||||
h.setVisitorCookie(c, visitor.ID)
|
||||
}
|
||||
c.Set(visitorCtxKey, &visitor)
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// resolveVisitor returns the caller's identity; fresh reports whether a new
|
||||
// visitor row was created and the cookie must be (re)set.
|
||||
func (h *Handler) resolveVisitor(c *okapi.Context) (visitor Visitor, fresh bool, err error) {
|
||||
ctx := c.Request().Context()
|
||||
if id, cerr := c.Cookie(visitorCookie); cerr == nil && ValidVisitorID(id) {
|
||||
visitor, found, err := h.store.FindVisitor(ctx, id)
|
||||
if err != nil {
|
||||
return Visitor{}, false, err
|
||||
}
|
||||
if found {
|
||||
// Refresh the cookie's lifetime while the visitor stays active, and
|
||||
// stamp the visit so presence and pruning have something to work with.
|
||||
h.setVisitorCookie(c, id)
|
||||
h.store.TouchVisitor(ctx, id, time.Now().UTC())
|
||||
return visitor, false, nil
|
||||
}
|
||||
// Known shape, unknown id: fall through and mint a new identity.
|
||||
}
|
||||
visitor, err = h.store.CreateVisitor(ctx, NewVisitorID())
|
||||
if err != nil {
|
||||
return Visitor{}, false, err
|
||||
}
|
||||
return visitor, true, nil
|
||||
}
|
||||
|
||||
// setVisitorCookie writes (or refreshes) the identity cookie.
|
||||
func (h *Handler) setVisitorCookie(c *okapi.Context, id string) {
|
||||
r := c.Request()
|
||||
http.SetCookie(c.ResponseWriter(), &http.Cookie{
|
||||
Name: visitorCookie,
|
||||
Value: id,
|
||||
Path: "/",
|
||||
MaxAge: int(visitorCookieTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// visitorFromContext returns the identity resolved by the middleware; the
|
||||
// zero value means "unidentified" (e.g. a request that bypassed the group).
|
||||
func visitorFromContext(c *okapi.Context) *Visitor {
|
||||
if v, ok := c.Get(visitorCtxKey); ok {
|
||||
if visitor, ok := v.(*Visitor); ok {
|
||||
return visitor
|
||||
}
|
||||
}
|
||||
return &Visitor{}
|
||||
}
|
||||
|
||||
// VisitorMe lets the UI learn its own identity: whether the cookie was just
|
||||
// (re)generated and the remembered name to pre-fill the form with.
|
||||
func (h *Handler) VisitorMe(c *okapi.Context) error {
|
||||
v := visitorFromContext(c)
|
||||
return c.OK(okapi.M{
|
||||
"id": v.ID,
|
||||
"name": v.Name,
|
||||
"first_seen": v.FirstSeen.UTC().Format(time.RFC3339),
|
||||
"signatures": v.Signatures,
|
||||
})
|
||||
}
|
||||
|
||||
// visitorName returns the name under which the visitor last signed, so the
|
||||
// server can attach it to an unsigned signature.
|
||||
func visitorName(v *Visitor) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(v.Name)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jkaninda/okapi"
|
||||
)
|
||||
|
||||
func TestVisitorLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if !ValidVisitorID(NewVisitorID()) {
|
||||
t.Fatal("a freshly minted id should validate")
|
||||
}
|
||||
for _, bad := range []string{"", "not-a-uuid", "550e8400-e29b-41d4-a716-44665544000z"} {
|
||||
if ValidVisitorID(bad) {
|
||||
t.Fatalf("invalid id accepted: %q", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// A cookie the database does not know about must be regenerated.
|
||||
if _, found, err := s.FindVisitor(ctx, NewVisitorID()); err != nil || found {
|
||||
t.Fatalf("unknown visitor: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
id := NewVisitorID()
|
||||
v, err := s.CreateVisitor(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.ID != id || v.Name != "" || v.Signatures != 0 {
|
||||
t.Fatalf("new visitor = %+v", v)
|
||||
}
|
||||
if _, found, err := s.FindVisitor(ctx, id); err != nil || !found {
|
||||
t.Fatalf("known visitor: found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
s.VisitorSigned(ctx, id, "Ada")
|
||||
got, found, _ := s.FindVisitor(ctx, id)
|
||||
if !found || got.Name != "Ada" || got.Signatures != 1 {
|
||||
t.Fatalf("after signing: %+v", got)
|
||||
}
|
||||
|
||||
// Freshness drives the "visiting now" number (2 minutes ago is still fresh).
|
||||
s.db.Model(&Visitor{}).Where("id = ?", id).Update("last_seen", time.Now().Add(-2*time.Minute))
|
||||
if n, _ := s.VisitorsSeenAfter(ctx, time.Now().Add(-visitorFreshWindow)); n != 1 {
|
||||
t.Fatalf("active visitors = %d, want 1", n)
|
||||
}
|
||||
stale := time.Now().Add(-48 * time.Hour)
|
||||
s.db.Model(&Visitor{}).Where("id = ?", id).Update("last_seen", stale)
|
||||
if n, _ := s.VisitorsSeenAfter(ctx, time.Now().Add(-visitorFreshWindow)); n != 0 {
|
||||
t.Fatalf("active visitors after going stale = %d, want 0", n)
|
||||
}
|
||||
|
||||
// Pruning removes the identity and its reactions.
|
||||
e, _ := s.Create(ctx, "ada", "hello", "r1", "")
|
||||
if _, err := s.React(ctx, uint64(e.ID), "heart", id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := s.PurgeVisitors(ctx, time.Now().Add(-24*time.Hour), true); n != 1 {
|
||||
t.Fatalf("dry-run prune = %d, want 1", n)
|
||||
}
|
||||
if n, _ := s.PurgeVisitors(ctx, time.Now().Add(-24*time.Hour), false); n != 1 {
|
||||
t.Fatalf("prune = %d, want 1", n)
|
||||
}
|
||||
if _, found, _ := s.FindVisitor(ctx, id); found {
|
||||
t.Fatal("pruned visitor still found")
|
||||
}
|
||||
if n, _ := s.ReactionTotals(ctx); n != 0 {
|
||||
t.Fatalf("reactions of the pruned visitor = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVisitorCookieFlow exercises the middleware over HTTP: no cookie → one is
|
||||
// issued and persisted; a known cookie is kept; a cookie the database no
|
||||
// longer knows about is regenerated.
|
||||
func TestVisitorCookieFlow(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
h := &Handler{store: s, broker: NewBroker("test", "dev", nil), auth: NewAdminAuth("")}
|
||||
app := okapi.New()
|
||||
api := app.Group("/api", h.identifyVisitor)
|
||||
api.Get("/me", h.VisitorMe)
|
||||
|
||||
call := func(cookie string) (*httptest.ResponseRecorder, map[string]any) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
||||
if cookie != "" {
|
||||
req.Header.Set("Cookie", visitorCookie+"="+cookie)
|
||||
}
|
||||
app.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rec, body
|
||||
}
|
||||
|
||||
// First visit: the backend mints an identity, persists it and sets the cookie.
|
||||
rec, first := call("")
|
||||
issued := ""
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == visitorCookie {
|
||||
issued = c.Value
|
||||
}
|
||||
}
|
||||
if issued == "" || first["id"] != issued {
|
||||
t.Fatalf("first visit: cookie %q, body %v", issued, first)
|
||||
}
|
||||
if _, found, _ := s.FindVisitor(context.Background(), issued); !found {
|
||||
t.Fatal("issued identity was not persisted")
|
||||
}
|
||||
|
||||
// Second visit with the cookie: recognised, identity unchanged.
|
||||
_, second := call(issued)
|
||||
if second["id"] != issued {
|
||||
t.Fatalf("known cookie should keep its identity: %v vs %v", second["id"], issued)
|
||||
}
|
||||
|
||||
// A cookie the database does not know (e.g. after a reset) is regenerated.
|
||||
rec, third := call(NewVisitorID())
|
||||
regenerated := third["id"].(string)
|
||||
if !ValidVisitorID(regenerated) || regenerated == issued {
|
||||
t.Fatalf("unknown cookie should be regenerated, got %v", third)
|
||||
}
|
||||
if !cookieSetTo(rec, visitorCookie, regenerated) {
|
||||
t.Fatalf("regenerated identity was not written back to the cookie: %v", rec.Header())
|
||||
}
|
||||
|
||||
// A malformed cookie never reaches the database and gets a fresh identity.
|
||||
_, fourth := call("not-a-uuid")
|
||||
if !ValidVisitorID(fourth["id"].(string)) {
|
||||
t.Fatalf("malformed cookie should yield a fresh identity, got %v", fourth)
|
||||
}
|
||||
}
|
||||
|
||||
func cookieSetTo(rec *httptest.ResponseRecorder, name, value string) bool {
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == name {
|
||||
return c.Value == value
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestReset(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
e, _ := s.Create(ctx, "ada", "hello", "r1", "")
|
||||
gone, _ := s.Create(ctx, "gone", "deleted", "r1", "")
|
||||
_, _ = s.Delete(ctx, uint64(gone.ID))
|
||||
who, _ := s.CreateVisitor(ctx, NewVisitorID())
|
||||
if _, err := s.React(ctx, uint64(e.ID), "tada", who.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = s.SaveSettings(ctx, Settings{SigningPaused: true, Banner: "hi"})
|
||||
|
||||
// Dry run reports but changes nothing.
|
||||
dry, err := s.Reset(ctx, false, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dry.Entries != 2 || dry.Reactions != 1 || dry.Visitors != 1 || dry.Settings != 0 {
|
||||
t.Fatalf("dry run = %+v", dry)
|
||||
}
|
||||
if n, _ := s.Count(ctx); n != 1 {
|
||||
t.Fatalf("dry run removed entries: %d left", n)
|
||||
}
|
||||
|
||||
// A soft reset keeps the settings, and soft-deleted rows are gone for good.
|
||||
stats, err := s.Reset(ctx, false, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Entries != 2 || stats.Reactions != 1 || stats.Visitors != 1 {
|
||||
t.Fatalf("reset = %+v", stats)
|
||||
}
|
||||
if n, _ := s.Count(ctx); n != 0 {
|
||||
t.Fatalf("entries after reset = %d, want 0", n)
|
||||
}
|
||||
if n, _ := s.ReactionTotals(ctx); n != 0 {
|
||||
t.Fatalf("reactions after reset = %d, want 0", n)
|
||||
}
|
||||
if n, _ := s.VisitorCount(ctx); n != 0 {
|
||||
t.Fatalf("visitors after reset = %d, want 0", n)
|
||||
}
|
||||
if n, _ := s.Purge(ctx, time.Now().Add(time.Hour), false); n != 0 {
|
||||
t.Fatalf("purged after reset = %d, want 0 (soft-deleted rows wiped too)", n)
|
||||
}
|
||||
if st, _ := s.Settings(ctx); !st.SigningPaused || st.Banner != "hi" {
|
||||
t.Fatalf("settings should survive a soft reset, got %+v", st)
|
||||
}
|
||||
|
||||
// Hard reset drops the settings as well.
|
||||
if _, err := s.Reset(ctx, true, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st, _ := s.Settings(ctx); st.SigningPaused || st.Banner != "" {
|
||||
t.Fatalf("settings should be cleared by a hard reset, got %+v", st)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,8 @@
|
||||
td.mono { font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
|
||||
tr.me td { color: #fff; }
|
||||
tr.me td:first-child::after { content: " ← you"; color: var(--accent-2); font-family: inherit; font-size: 11.5px; }
|
||||
td.fresh { color: var(--ok); }
|
||||
td.fresh::before { content: "● "; font-size: 10px; }
|
||||
dl.kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; font-size: 13.5px; }
|
||||
dl.kv dt { color: var(--muted); }
|
||||
dl.kv dd { margin: 0; font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
|
||||
@@ -216,6 +218,8 @@
|
||||
<div id="tab-overview" data-panel>
|
||||
<div class="tiles">
|
||||
<div class="card tile live"><div class="label">Online now</div><div class="value" id="tOnline">–</div></div>
|
||||
<div class="card tile"><div class="label">Visiting</div><div class="value" id="tVisiting" title="Visitor identities seen in the last 5 minutes">–</div></div>
|
||||
<div class="card tile"><div class="label">Visitors</div><div class="value" id="tVisitors" title="Identity cookies the app has issued (rows in the visitors table)">–</div></div>
|
||||
<div class="card tile"><div class="label">Replicas</div><div class="value" id="tReplicas">–</div></div>
|
||||
<div class="card tile"><div class="label">Signatures</div><div class="value" id="tTotal">–</div></div>
|
||||
<div class="card tile"><div class="label">Today</div><div class="value" id="tToday">–</div></div>
|
||||
@@ -242,6 +246,14 @@
|
||||
<dl class="kv" id="runtime"></dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card pad">
|
||||
<h2>Visitors</h2>
|
||||
<p class="hint">Every browser gets an identity cookie (<code>gb_visitor</code>); the backend accepts it only while its id exists in the database — after a <code>guestbook reset</code> every cookie is regenerated on the next request. Idle identities are pruned by the <code>cleanup</code> job (<code>--prune-visitors</code>).</p>
|
||||
<table>
|
||||
<thead><tr><th>Visitor</th><th>Name</th><th>Signatures</th><th>First seen</th><th>Last seen</th></tr></thead>
|
||||
<tbody id="visitorRows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -445,6 +457,8 @@
|
||||
$('appName').textContent = rt.app;
|
||||
setVersion(rt.version);
|
||||
$('tOnline').textContent = online;
|
||||
$('tVisiting').textContent = overview.visitors ?? 0;
|
||||
$('tVisitors').textContent = stats.visitors ?? 0;
|
||||
$('tReplicas').textContent = replicas.length;
|
||||
$('tTotal').textContent = stats.total;
|
||||
$('tToday').textContent = stats.today;
|
||||
@@ -452,6 +466,7 @@
|
||||
$('tPinned').textContent = stats.pinned;
|
||||
$('tHidden').textContent = stats.hidden;
|
||||
renderChart(stats.daily || []);
|
||||
loadVisitors();
|
||||
|
||||
$('replicaHint').innerHTML = rt.broker === 'redis'
|
||||
? 'Presence is shared through Redis, so every replica is listed. The requests counter grows per replica and resets when a rollout replaces it.'
|
||||
@@ -502,6 +517,22 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ---- visitors ----------------------------------------------------------
|
||||
async function loadVisitors() {
|
||||
const r = await req('GET', '/api/admin/visitors?limit=25');
|
||||
if (!r.ok || !r.data) return;
|
||||
const activeSince = new Date(r.data.active_since || 0).getTime();
|
||||
const rows = r.data.visitors || [];
|
||||
$('visitorRows').innerHTML = rows.length ? rows.map((v) => `
|
||||
<tr>
|
||||
<td class="mono">${esc(String(v.id).slice(0, 8))}…</td>
|
||||
<td>${esc(v.name || '–')}</td>
|
||||
<td>${v.signatures}</td>
|
||||
<td title="${esc(new Date(v.first_seen).toLocaleString())}">${ago(v.first_seen)}</td>
|
||||
<td class="${new Date(v.last_seen).getTime() >= activeSince ? 'fresh' : ''}" title="${esc(new Date(v.last_seen).toLocaleString())}">${ago(v.last_seen)}</td>
|
||||
</tr>`).join('') : '<tr><td colspan="5" class="empty">No visitor identities yet — they are created on the first request.</td></tr>';
|
||||
}
|
||||
|
||||
// ---- moderation --------------------------------------------------------
|
||||
const mod = { status: '', q: '', offset: 0 };
|
||||
async function loadRows() {
|
||||
|
||||
+36
-5
@@ -215,6 +215,10 @@
|
||||
.paused b { color: var(--text); }
|
||||
.replicas { font-size: 12.5px; color: var(--muted); padding: 5px 12px; border-radius: 999px;
|
||||
background: var(--card); border: 1px solid var(--card-border); white-space: nowrap; }
|
||||
.clock-meta .you { color: var(--text); font-weight: 600; }
|
||||
.visitors { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; color: var(--muted);
|
||||
padding: 5px 12px; border-radius: 999px; background: var(--card); border: 1px solid var(--card-border);
|
||||
white-space: nowrap; }
|
||||
|
||||
.empty, .loading { text-align: center; color: var(--muted); padding: 40px 20px; font-size: 15px; }
|
||||
|
||||
@@ -257,7 +261,8 @@
|
||||
<div class="status">
|
||||
<span class="ver" id="ver" title="Serving version">v…</span>
|
||||
<span class="replicas" id="replicas" title="Running replicas" hidden></span>
|
||||
<span class="online"><span class="pulse"></span> <span id="online">0</span> online</span>
|
||||
<span class="visitors" id="visitors" title="Visitor identities active in the last 5 minutes (issued by the identity cookie)" hidden>👤 <span id="visitorCount">0</span></span>
|
||||
<span class="online" title="Browsers connected to the live stream right now"><span class="pulse"></span> <span id="online">0</span> online</span>
|
||||
<button class="icon-btn" id="themeBtn" title="Toggle light / dark theme" aria-label="Toggle light / dark theme">🌓</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,6 +285,8 @@
|
||||
<div class="clock-time" id="clockTime">—</div>
|
||||
</div>
|
||||
<div class="clock-meta" id="clockMeta">waiting for server…</div>
|
||||
<!-- Visitor identity (new in v3): the server recognises this browser -->
|
||||
<div class="clock-meta" id="visitorMeta" hidden></div>
|
||||
</div>
|
||||
|
||||
<!-- Home view -->
|
||||
@@ -347,6 +354,7 @@
|
||||
|
||||
const api = {
|
||||
info: () => fetch('/api/info').then(r => r.json()),
|
||||
me: () => fetch('/api/me').then(r => r.json()),
|
||||
time: () => fetch('/api/time').then(r => r.json()),
|
||||
settings: () => fetch('/api/settings').then(r => r.json()),
|
||||
list: (limit, offset) => fetch(`/api/entries?limit=${limit}&offset=${offset}`).then(r => r.json()),
|
||||
@@ -486,6 +494,28 @@
|
||||
$('replicas').hidden = false;
|
||||
$('replicas').textContent = n === 1 ? '1 replica' : n + ' replicas';
|
||||
}
|
||||
function setVisitors(n) {
|
||||
if (!n) return;
|
||||
$('visitors').hidden = false;
|
||||
$('visitorCount').textContent = n;
|
||||
}
|
||||
|
||||
// ---- visitor identity ---------------------------------------------------
|
||||
// The server recognises this browser by the gb_visitor cookie: it checks
|
||||
// the cookie's id against the database and hands out a new one when it is
|
||||
// unknown (first visit, pruned identity, or a wiped database). /api/me
|
||||
// reports who the server thinks we are; the remembered name pre-fills the
|
||||
// form.
|
||||
function showVisitor(v) {
|
||||
if (!v || !v.id) return;
|
||||
const el = $('visitorMeta');
|
||||
el.hidden = false;
|
||||
const bits = [`you are <span class="you">${esc(v.name || 'guest-' + String(v.id).slice(0, 8))}</span>`];
|
||||
if (v.signatures > 0) bits.push(`${v.signatures} signature${v.signatures === 1 ? '' : 's'}`);
|
||||
if (v.first_seen) bits.push(`since ${timeAgo(v.first_seen)}`);
|
||||
el.innerHTML = bits.join(' · ');
|
||||
if (v.name && !$('name').value) $('name').value = v.name;
|
||||
}
|
||||
function applySettings(st) {
|
||||
const banner = (st && st.banner) || '';
|
||||
$('banner').hidden = !banner;
|
||||
@@ -585,8 +615,8 @@
|
||||
let connectedOnce = false;
|
||||
function connectSSE() {
|
||||
const es = new EventSource('/api/stream');
|
||||
es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); setReplicas(d.replicas); });
|
||||
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); setReplicas(d.replicas); });
|
||||
es.addEventListener('welcome', (e) => { const d = JSON.parse(e.data); setVersion(d.version); setOnline(d.online); setReplicas(d.replicas); setVisitors(d.visitors); });
|
||||
es.addEventListener('presence', (e) => { const d = JSON.parse(e.data); setOnline(d.online); setReplicas(d.replicas); setVisitors(d.visitors); });
|
||||
es.addEventListener('created', (e) => { const d = JSON.parse(e.data); if (d.entry && !d.entry.hidden) onCreated(d.entry); });
|
||||
es.addEventListener('updated', () => reloadView());
|
||||
es.addEventListener('deleted', (e) => { const d = JSON.parse(e.data); onDeleted(d.id); });
|
||||
@@ -594,7 +624,7 @@
|
||||
es.addEventListener('settings', (e) => { const d = JSON.parse(e.data); applySettings(d.settings); });
|
||||
// Resync after a reconnect: events sent while disconnected were missed.
|
||||
es.addEventListener('open', () => { if (connectedOnce) { reloadView(); api.settings().then(applySettings).catch(() => {}); } connectedOnce = true; });
|
||||
es.addEventListener('tick', (e) => { const d = JSON.parse(e.data); showTime(d); setVersion(d.version); });
|
||||
es.addEventListener('tick', (e) => { const d = JSON.parse(e.data); showTime(d); setVersion(d.version); setVisitors(d.visitors); });
|
||||
// EventSource auto-reconnects on error; nothing to do here.
|
||||
}
|
||||
|
||||
@@ -620,10 +650,11 @@
|
||||
|
||||
// ---- boot ---------------------------------------------------------------
|
||||
api.info().then((d) => {
|
||||
setVersion(d.version); setOnline(d.online); setReplicas((d.replicas || []).length);
|
||||
setVersion(d.version); setOnline(d.online); setReplicas((d.replicas || []).length); setVisitors(d.visitors);
|
||||
$('adminLink').hidden = !d.admin;
|
||||
if (d.broker) { $('brokerPill').hidden = false; $('brokerPill').textContent = d.broker === 'redis' ? 'Redis pub/sub' : 'In-process broker'; }
|
||||
}).catch(() => {});
|
||||
api.me().then(showVisitor).catch(() => {});
|
||||
api.settings().then(applySettings).catch(() => {});
|
||||
api.time().then(showTime).catch(() => {}); // immediate clock; SSE ticks keep it live
|
||||
route();
|
||||
|
||||
Reference in New Issue
Block a user