diff --git a/DEMO.md b/DEMO.md index 8e2a545..14012f6 100644 --- a/DEMO.md +++ b/DEMO.md @@ -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. diff --git a/README.md b/README.md index 1c05b31..41bdd69 100644 --- a/README.md +++ b/README.md @@ -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 ` 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: diff --git a/admin.go b/admin.go index 26eec64..7066c14 100644 --- a/admin.go +++ b/admin.go @@ -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) diff --git a/broker.go b/broker.go index 503d731..78d7efc 100644 --- a/broker.go +++ b/broker.go @@ -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()}) } } diff --git a/go.mod b/go.mod index b8a82e9..3e079f3 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/handlers.go b/handlers.go index 87b7c03..d2d812e 100644 --- a/handlers.go +++ b/handlers.go @@ -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: diff --git a/main.go b/main.go index 0eb1abd..e4b96f3 100644 --- a/main.go +++ b/main.go @@ -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 } @@ -242,10 +304,11 @@ func publishTicks(ctx context.Context, b *Broker, version, host string) { return case now := <-t.C: b.PublishLocal(Event{ - Type: "tick", - Time: now.Format(time.RFC3339), - Version: version, - Host: host, + Type: "tick", + Time: now.Format(time.RFC3339), + Version: version, + Host: host, + Visitors: b.visitorsActive(), }) } } diff --git a/reactions.go b/reactions.go index 743b417..4781bdd 100644 --- a/reactions.go +++ b/reactions.go @@ -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. diff --git a/store.go b/store.go index 0e54e54..1125de6 100644 --- a/store.go +++ b/store.go @@ -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() diff --git a/visitors.go b/visitors.go new file mode 100644 index 0000000..2342f09 --- /dev/null +++ b/visitors.go @@ -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) +} diff --git a/visitors_test.go b/visitors_test.go new file mode 100644 index 0000000..82f2ab0 --- /dev/null +++ b/visitors_test.go @@ -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) + } +} diff --git a/web/admin.html b/web/admin.html index c6bfef2..a77a553 100644 --- a/web/admin.html +++ b/web/admin.html @@ -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 @@
Online now
–
+
Visiting
–
+
Visitors
–
Replicas
–
Signatures
–
Today
–
@@ -242,6 +246,14 @@
+
+

Visitors

+

Every browser gets an identity cookie (gb_visitor); the backend accepts it only while its id exists in the database β€” after a guestbook reset every cookie is regenerated on the next request. Idle identities are pruned by the cleanup job (--prune-visitors).

+ + + +
VisitorNameSignaturesFirst seenLast seen
+
@@ -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) => ` + + ${esc(String(v.id).slice(0, 8))}… + ${esc(v.name || '–')} + ${v.signatures} + ${ago(v.first_seen)} + ${ago(v.last_seen)} + `).join('') : 'No visitor identities yet β€” they are created on the first request.'; + } + // ---- moderation -------------------------------------------------------- const mod = { status: '', q: '', offset: 0 }; async function loadRows() { diff --git a/web/index.html b/web/index.html index da5213d..675f32b 100644 --- a/web/index.html +++ b/web/index.html @@ -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 @@
v… - 0 online + + 0 online
@@ -280,6 +285,8 @@
β€”
waiting for server…
+ + @@ -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 ${esc(v.name || 'guest-' + String(v.id).slice(0, 8))}`]; + 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();