Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86215f1eb1 | |||
| a12c27b6bc | |||
| 3a68f64eb6 |
+7
-3
@@ -45,13 +45,17 @@ DATABASE_URL=postgres://postgres:postgres@db:5432/guestbook?sslmode=disable
|
|||||||
# REDIS_URL=redis://redis:6379
|
# REDIS_URL=redis://redis:6379
|
||||||
|
|
||||||
# === Admin console =================
|
# === Admin console =================
|
||||||
# Enables /admin. Store as a secret. Generate with: openssl rand -hex 16
|
# Enables /admin: overview (stats, replicas with per-replica request counters),
|
||||||
|
# moderation (pin / hide / delete, reaction tallies, CSV export) and wall
|
||||||
|
# settings (pause signing, banner, forced light/dark theme).
|
||||||
|
# Store the token as a secret. Generate with: openssl rand -hex 16
|
||||||
# ADMIN_TOKEN=
|
# ADMIN_TOKEN=
|
||||||
|
|
||||||
# CIDRs whose X-Forwarded-For is trusted for the signer IP shown in /admin.
|
# CIDRs whose X-Forwarded-For is trusted for the signer IP shown in /admin.
|
||||||
# Unset trusts the header as-is (fine behind the Miabi gateway).
|
# Unset trusts the header as-is (fine behind the Miabi gateway).
|
||||||
# TRUSTED_PROXIES=10.0.0.0/8
|
# TRUSTED_PROXIES=10.0.0.0/8
|
||||||
|
|
||||||
# Expose /api/debug/* failure injection (crash, slow, 5xx, CPU, memory, logs).
|
# Expose /api/debug/* failure injection (crash, slow, 5xx, CPU, memory, logs)
|
||||||
# Still requires admin auth. Demo environments only.
|
# and the admin "rolling-update wave" tool. Still requires admin auth.
|
||||||
|
# Demo environments only.
|
||||||
# DEBUG_ENDPOINTS=false
|
# DEBUG_ENDPOINTS=false
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ handy for logs, events and scaling.
|
|||||||
| 3 | Secrets + admin console | secret env vars |
|
| 3 | Secrets + admin console | secret env vars |
|
||||||
| 4 | Scale out → broken live updates | replicas |
|
| 4 | Scale out → broken live updates | replicas |
|
||||||
| 5 | Attach Redis → fixed | managed Redis, env prefix |
|
| 5 | Attach Redis → fixed | managed Redis, env prefix |
|
||||||
| 6 | Break it on purpose | restarts, health gating, alerts, logs, analytics |
|
| 6 | Break it on purpose (+ rolling wave) | restarts, health gating, alerts, logs, analytics |
|
||||||
| 7 | Canary v1 → v2 | canary weights, promote |
|
| 7 | Canary v1 → v2 | canary weights, promote |
|
||||||
| 8 | Scheduled cleanup | cron jobs |
|
| 8 | Scheduled cleanup + reset | cron jobs, one-off jobs |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,23 +55,31 @@ docker push <registry>/guestbook:1.0.0 && docker push <registry>/guestbook:2.0.0
|
|||||||
1. App → **Environment** → add `ADMIN_TOKEN` = your token, marked **secret**. Add `DEBUG_ENDPOINTS=true` too (used in act 6).
|
1. App → **Environment** → add `ADMIN_TOKEN` = your token, marked **secret**. Add `DEBUG_ENDPOINTS=true` too (used in act 6).
|
||||||
2. Redeploy, open `/admin` and sign in with the token.
|
2. Redeploy, open `/admin` and sign in with the token.
|
||||||
3. Tour it:
|
3. Tour it:
|
||||||
- **Overview**: signatures per day, replicas, and *this replica*'s runtime (host, DB, uptime, memory).
|
- **Overview**: signatures per day, total reactions, replicas (with each one's **request counter**), and *this replica*'s runtime (host, DB, uptime, memory).
|
||||||
- **Moderation**: each signature shows the signer's IP, forwarded by the gateway. Click one to filter by it. Pin a signature. It jumps to the top of every open wall, live. Hide one and it disappears.
|
- **Moderation**: each signature shows the signer's IP, forwarded by the gateway, plus its reaction tally. Click the IP to filter by it. Pin a signature. It jumps to the top of every open wall, live. Hide one and it disappears.
|
||||||
- **Settings**: set a banner ("Welcome to the Miabi training 👋"), then toggle **Pause signing**. The wall goes read-only instantly in every tab.
|
- **Settings**: set a banner ("Welcome to the Miabi training 👋"), pick a **wall theme** (force light or dark — every open tab flips instantly), then toggle **Pause signing**. The wall goes read-only instantly in every tab.
|
||||||
|
|
||||||
> Talking point: the token never appears in the app's config in plain text.
|
> Talking point: the token never appears in the app's config in plain text.
|
||||||
> Rotating it (edit + redeploy) signs every admin out, because sessions are
|
> Rotating it (edit + redeploy) signs every admin out, because sessions are
|
||||||
> HMAC-signed with it.
|
> HMAC-signed with it.
|
||||||
|
|
||||||
Ask the audience to open the wall on their phones and sign it. The
|
Ask the audience to open the wall on their phones and sign it. The
|
||||||
**online** counter climbs live.
|
**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
|
## 4. Scale out, and watch it break
|
||||||
|
|
||||||
1. App → **Replicas** → scale to **3**.
|
1. App → **Replicas** → scale to **3**.
|
||||||
2. In the admin **Overview**, the *served by* chip changes as you refresh, but the **Replicas** table only ever lists one host. The hint says *in-process broker*.
|
2. In the admin **Overview**, the *served by* chip changes as you refresh, but the **Replicas** table only ever lists one host. The hint says *in-process broker*.
|
||||||
3. Open the wall in two browsers (or phones) until their clock cards show **different hosts**. Sign in one: **the other doesn't update.** Each replica only pushes to its own SSE clients.
|
3. Open the wall in two browsers (or phones) until their clock cards show **different hosts**. Sign in one: **the other doesn't update.** Each replica only pushes to its own SSE clients. Reactions are the same: a ❤️ on one host never shows on the other.
|
||||||
4. Also note the `via replica-x` tag on new signatures: each one records which replica accepted it.
|
4. Also note the `via replica-x` tag on new signatures: each one records which replica accepted it. And `curl -s https://<domain>/healthz` a few times — the `requests` counter climbs on whichever replica answered, so you can watch the load-balancer spread traffic even though live updates stay broken.
|
||||||
|
|
||||||
> Talking point: this is the classic "works on one box" bug. Stateless apps
|
> Talking point: this is the classic "works on one box" bug. Stateless apps
|
||||||
> need shared state for fan-out. Scaling is a platform feature; being
|
> need shared state for fan-out. Scaling is a platform feature; being
|
||||||
@@ -81,8 +89,8 @@ Ask the audience to open the wall on their phones and sign it. The
|
|||||||
|
|
||||||
1. App → **Databases** → attach the Redis with prefix **`REDIS`**. Miabi injects `REDIS_DATABASE_URL`, which the app reads (so does `REDIS_URL`).
|
1. App → **Databases** → attach the Redis with prefix **`REDIS`**. Miabi injects `REDIS_DATABASE_URL`, which the app reads (so does `REDIS_URL`).
|
||||||
2. Redeploy. Startup logs show `live updates ready broker=redis`.
|
2. Redeploy. Startup logs show `live updates ready broker=redis`.
|
||||||
3. Repeat the two-browser test: signatures now appear everywhere. The **Replicas** table lists all 3 hosts with their client counts, and the wall header says *3 replicas*.
|
3. Repeat the two-browser test: signatures **and reactions** now appear everywhere. The **Replicas** table lists all 3 hosts with their client counts **and request counters**, and the wall header says *3 replicas*.
|
||||||
4. **Chaos lab → Traffic probe** → *Run probe*: the bars show requests spread across the three hosts.
|
4. **Chaos lab → Traffic probe** → *Run probe*: the bars show requests spread across the three hosts. Compare with the per-replica `requests` counters in the table — they tell the same story server-side.
|
||||||
|
|
||||||
## 6. Break it on purpose
|
## 6. Break it on purpose
|
||||||
|
|
||||||
@@ -111,28 +119,59 @@ curl -XPOST -H "Authorization: Bearer $T" "$D/api/debug/memory?mb=400&seconds=30
|
|||||||
|
|
||||||
Remember to click **Healthy** afterwards, or redeploy.
|
Remember to click **Healthy** afterwards, or redeploy.
|
||||||
|
|
||||||
|
### The rolling-update wave
|
||||||
|
|
||||||
|
Still in the Chaos lab, **Rolling-update wave → Bounce each replica, one at a
|
||||||
|
time** restarts the fleet exactly like a rolling update does, but driven by the
|
||||||
|
crash endpoint. Keep the **Replicas** table in view: each replica disappears,
|
||||||
|
comes back, and its **request counter restarts from ~0** while the others keep
|
||||||
|
climbing. The wall never goes down — which is the whole point of rolling
|
||||||
|
updates, shown from the inside.
|
||||||
|
|
||||||
|
> Talking point: recreate would drop every replica at once (all counters would
|
||||||
|
> reset together, with a gap of downtime). Rolling keeps capacity by replacing
|
||||||
|
> one at a time. Same mechanism, two strategies.
|
||||||
|
|
||||||
## 7. Canary v1 → v2
|
## 7. Canary v1 → v2
|
||||||
|
|
||||||
1. In a terminal: `scripts/hammer.sh https://<domain>`. It prints a live count and, on Ctrl-C, a tally of `status version host`.
|
1. In a terminal: `scripts/hammer.sh https://<domain>`. It prints a live count and, on Ctrl-C, a tally of `status version host`.
|
||||||
2. Set **deploy strategy** to *canary* (initial weight 10%), and deploy image `2.0.0`.
|
2. Set **deploy strategy** to *canary* (initial weight 10%), and deploy image `2.0.0`.
|
||||||
3. On the wall, reload a few times: ~1 in 10 loads shows the **v2 badge** in a different colour. In the admin **Traffic probe**, the bars split by version.
|
3. On the wall, reload a few times: ~1 in 10 loads shows the **v2 badge** in a different colour **and the new live server-time card**. In the admin **Traffic probe**, the bars split by version; in the **Replicas** table, the two versions sit side by side with their own request counters.
|
||||||
4. **Promote now.** The probe goes 100% v2, and `hammer.sh` should show `0 failed`.
|
4. **Promote now.** The probe goes 100% v2, and `hammer.sh` should show `0 failed`.
|
||||||
|
|
||||||
> The v2 build is the same code with a different `VERSION`. Nothing needs to
|
> The v2 build is the same code with a different `VERSION`. Nothing needs to
|
||||||
> differ for the demo to work.
|
> 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
|
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
|
```bash
|
||||||
guestbook cleanup --purge-after 168h # default: purge deletes older than 7 days
|
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 --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 * * * *`.
|
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
|
`guestbook migrate` works the same way as a one-off **Job**, for teams that
|
||||||
want migrations as an explicit step rather than on boot.
|
want migrations as an explicit step rather than on boot.
|
||||||
@@ -141,6 +180,9 @@ want migrations as an explicit step rather than on boot.
|
|||||||
|
|
||||||
## Reset between sessions
|
## Reset between sessions
|
||||||
|
|
||||||
- Admin → Settings: clear the banner, un-pause.
|
- Admin → Settings: clear the banner, set the theme back to *System*, un-pause.
|
||||||
- Chaos lab: **Healthy**.
|
- Chaos lab: **Healthy**.
|
||||||
- To wipe the wall: detach/recreate the database, or `DELETE FROM entries;` 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.
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ miniature:
|
|||||||
routes keep precedence
|
routes keep precedence
|
||||||
- **Live updates over SSE** — new/removed signatures stream to every open tab,
|
- **Live updates over SSE** — new/removed signatures stream to every open tab,
|
||||||
with a real-time **connected-clients** ("N online") indicator
|
with a real-time **connected-clients** ("N online") indicator
|
||||||
|
- **Emoji reactions** — visitors cheer a signature with ❤️ 🎉 👍 😄 (no
|
||||||
|
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)
|
||||||
|
- **Per-replica request counter** — `/healthz`, `/api/info` and the admin
|
||||||
|
replica table report how many requests *this* replica has answered since
|
||||||
|
boot. Under load-balancing the counts diverge; a rolling update resets them
|
||||||
|
one container at a time, which makes the rollout visible from the outside
|
||||||
- **Version badge + live server-time card** — the serving build's version is
|
- **Version badge + live server-time card** — the serving build's version is
|
||||||
shown in the UI and `/api/info`, and a **live clock** (streamed over SSE with
|
shown in the UI and `/api/info`, and a **live clock** (streamed over SSE with
|
||||||
the server's `version` + `host`) makes a **canary rollout** obvious: the card
|
the server's `version` + `host`) makes a **canary rollout** obvious: the card
|
||||||
@@ -34,13 +51,18 @@ miniature:
|
|||||||
across every replica via pub/sub; without it, the in-process broker makes the
|
across every replica via pub/sub; without it, the in-process broker makes the
|
||||||
classic "works on one replica" bug easy to demonstrate
|
classic "works on one replica" bug easy to demonstrate
|
||||||
- **Admin console** at `/admin`, guarded by a single `ADMIN_TOKEN` secret:
|
- **Admin console** at `/admin`, guarded by a single `ADMIN_TOKEN` secret:
|
||||||
overview (stats, replicas, runtime), moderation (pin / hide / delete, signer
|
overview (stats, replicas with per-replica request counters, runtime),
|
||||||
IP, CSV export), wall settings (pause signing, announcement banner) — all pushed live
|
moderation (pin / hide / delete, signer IP, reaction tallies, CSV export),
|
||||||
|
wall settings (pause signing, announcement banner, forced wall theme) — all
|
||||||
|
pushed live
|
||||||
- **Chaos lab** (`DEBUG_ENDPOINTS=true`): crash, fail health checks, burn CPU,
|
- **Chaos lab** (`DEBUG_ENDPOINTS=true`): crash, fail health checks, burn CPU,
|
||||||
hold memory, burst logs, inject latency/5xx, plus an in-browser traffic probe
|
hold memory, burst logs, inject latency/5xx, a **rolling-update wave** that
|
||||||
that shows load-balancing and canary splits
|
bounces replicas one at a time while the replica table shows each one reset,
|
||||||
- **Scheduled-job friendly CLI** — `guestbook cleanup` (purge/expire) and
|
plus an in-browser traffic probe that shows load-balancing and canary splits
|
||||||
`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.
|
**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
|
- **Single static binary** — the whole UI is embedded with `go:embed`, so the
|
||||||
@@ -60,13 +82,16 @@ and keeps the container tiny.
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|----------|----------------------|------------------------------------|
|
|----------|----------------------|------------------------------------|
|
||||||
| `GET` | `/healthz` | Liveness/readiness (checks the DB), reports `version`, `host`, `redis` |
|
| `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` |
|
| `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/time` | Current server time + `version` + `host` (v2 — canary probe) |
|
||||||
| `GET` | `/api/settings` | Public wall settings: `signing_paused`, `banner` |
|
| `GET` | `/api/settings` | Public wall settings: `signing_paused`, `banner`, `theme` |
|
||||||
| `GET` | `/api/entries` | Visible entries (pinned first) + total; paginated via `?limit=&offset=` |
|
| `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` | Create `{ "name", "message" }` (broadcast live; `503` while paused) |
|
||||||
| `GET` | `/api/stream` | **SSE** stream: `welcome`, `created`, `updated`, `deleted`, `settings`, `presence`, `tick` events |
|
| `POST` | `/api/entries/{id}/reactions?emoji=` | Add this browser's reaction (`heart`·`tada`·`thumbsup`·`smile`); idempotent, broadcasts the new tally |
|
||||||
|
| `DELETE` | `/api/entries/{id}/reactions?emoji=` | Remove this browser's reaction |
|
||||||
|
| `GET` | `/api/stream` | **SSE** stream: `welcome`, `created`, `updated`, `deleted`, `reaction`, `settings`, `presence`, `tick` events |
|
||||||
| `GET` | `/` · `/all` · `/admin` | The web UI (home · all signatures · admin console) |
|
| `GET` | `/` · `/all` · `/admin` | The web UI (home · all signatures · admin console) |
|
||||||
|
|
||||||
### Admin API
|
### Admin API
|
||||||
@@ -77,11 +102,12 @@ or an `Authorization: Bearer <ADMIN_TOKEN>` header. Disabled (`404`) when
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|----------|-------------------------------|-------------|
|
|----------|-------------------------------|-------------|
|
||||||
| `GET` | `/api/admin/overview` | Stats (14-day histogram), replicas, 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/entries` | All entries; `?status=visible\|hidden\|pinned&q=&limit=&offset=` |
|
| `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 }` |
|
| `PATCH` | `/api/admin/entries/{id}` | `{ "pinned"?: bool, "hidden"?: bool }` |
|
||||||
| `DELETE` | `/api/admin/entries/{id}` | Soft-delete an entry |
|
| `DELETE` | `/api/admin/entries/{id}` | Soft-delete an entry |
|
||||||
| `GET`·`PUT` | `/api/admin/settings` | `{ "signing_paused": bool, "banner": string }` |
|
| `GET`·`PUT` | `/api/admin/settings` | `{ "signing_paused": bool, "banner": string, "theme": "system\|light\|dark" }` |
|
||||||
| `GET` | `/api/admin/export.csv` | Every entry as CSV |
|
| `GET` | `/api/admin/export.csv` | Every entry as CSV |
|
||||||
|
|
||||||
### Debug API (chaos)
|
### Debug API (chaos)
|
||||||
@@ -103,16 +129,39 @@ replica that receives it and echoes its `host`.
|
|||||||
|
|
||||||
`/api/stream` is a Server-Sent Events endpoint. On connect the client gets a
|
`/api/stream` is a Server-Sent Events endpoint. On connect the client gets a
|
||||||
`welcome` event (serving version + host + online count); thereafter the server
|
`welcome` event (serving version + host + online count); thereafter the server
|
||||||
pushes `created` / `deleted` events as the wall changes, `presence` events when
|
pushes `created` / `updated` / `deleted` events as the wall changes, `reaction`
|
||||||
the connected-client count changes, and a `tick` event once a second carrying
|
events as tallies move, `settings` events when a moderator pauses signing,
|
||||||
the live server time (with `version` + `host`). The UI uses these to update the
|
changes the banner or forces a theme, `presence` events when the
|
||||||
wall in real time, show a live **“N online”** indicator, and drive the
|
connected-client count changes, and a `tick` event once a second carrying the
|
||||||
**server-time card** — all without polling.
|
live server time (with `version` + `host`). The UI uses these to update the
|
||||||
|
wall in real time, show a live **“N online”** indicator, stream emoji-reaction
|
||||||
|
tallies, and drive the **server-time card** — all without polling.
|
||||||
|
|
||||||
With `REDIS_URL` set, `created`/`updated`/`deleted`/`settings` events go through
|
With `REDIS_URL` set, `created`/`updated`/`deleted`/`reaction`/`settings`
|
||||||
a Redis pub/sub channel so every replica delivers them, and each replica
|
events go through a Redis pub/sub channel so every replica delivers them, and
|
||||||
advertises its client count under a short-TTL key so presence is cluster-wide.
|
each replica advertises its client count **and request count** under a
|
||||||
`tick` events stay per-replica on purpose: the clock shows who served you.
|
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
|
## Configuration
|
||||||
|
|
||||||
@@ -157,7 +206,8 @@ go run . # runs the default "server" command
|
|||||||
go run . server -p 9000 # okapicli flags: choose the port
|
go run . server -p 9000 # okapicli flags: choose the port
|
||||||
go run . --help # list commands and flags
|
go run . --help # list commands and flags
|
||||||
go run . migrate # apply migrations and exit
|
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:
|
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,
|
"stats": stats,
|
||||||
"settings": settings,
|
"settings": settings,
|
||||||
"online": h.broker.Online(),
|
"online": h.broker.Online(),
|
||||||
|
"visitors": h.visitorsActive(ctx),
|
||||||
"replicas": h.broker.Replicas(),
|
"replicas": h.broker.Replicas(),
|
||||||
"debug": h.debug,
|
"debug": h.debug,
|
||||||
"runtime": okapi.M{
|
"runtime": okapi.M{
|
||||||
@@ -93,6 +94,7 @@ func (h *Handler) AdminOverview(c *okapi.Context) error {
|
|||||||
"healthy": !h.unhealthy.Load(),
|
"healthy": !h.unhealthy.Load(),
|
||||||
"started_at": h.started.UTC().Format(time.RFC3339),
|
"started_at": h.started.UTC().Format(time.RFC3339),
|
||||||
"uptime_s": int64(time.Since(h.started).Seconds()),
|
"uptime_s": int64(time.Since(h.started).Seconds()),
|
||||||
|
"requests": h.requests.Load(),
|
||||||
"go": runtime.Version(),
|
"go": runtime.Version(),
|
||||||
"cpus": runtime.NumCPU(),
|
"cpus": runtime.NumCPU(),
|
||||||
"goroutines": runtime.NumGoroutine(),
|
"goroutines": runtime.NumGoroutine(),
|
||||||
@@ -126,6 +128,18 @@ func (h *Handler) AdminListEntries(c *okapi.Context) error {
|
|||||||
return c.OK(okapi.M{"entries": out, "total": total, "limit": limit, "offset": offset})
|
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.
|
// AdminUpdateEntry pins/unpins or hides/unhides an entry and broadcasts it.
|
||||||
func (h *Handler) AdminUpdateEntry(c *okapi.Context) error {
|
func (h *Handler) AdminUpdateEntry(c *okapi.Context) error {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
@@ -167,6 +181,14 @@ func (h *Handler) AdminSaveSettings(c *okapi.Context) error {
|
|||||||
if len(st.Banner) > maxBannerLen {
|
if len(st.Banner) > maxBannerLen {
|
||||||
return c.JSON(http.StatusBadRequest, okapi.M{"error": "banner is too long (max 200 characters)"})
|
return c.JSON(http.StatusBadRequest, okapi.M{"error": "banner is too long (max 200 characters)"})
|
||||||
}
|
}
|
||||||
|
switch st.Theme {
|
||||||
|
case "light", "dark":
|
||||||
|
// forced appearance for every visitor
|
||||||
|
case "", "system":
|
||||||
|
st.Theme = "" // follow each visitor's preference
|
||||||
|
default:
|
||||||
|
return c.JSON(http.StatusBadRequest, okapi.M{"error": "theme must be system, light or dark"})
|
||||||
|
}
|
||||||
if err := h.store.SaveSettings(c.Request().Context(), st); err != nil {
|
if err := h.store.SaveSettings(c.Request().Context(), st); err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save settings"})
|
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save settings"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,27 +17,37 @@ import (
|
|||||||
// - "created" → Entry is set (a new signature)
|
// - "created" → Entry is set (a new signature)
|
||||||
// - "updated" → Entry is set (pinned/hidden by a moderator)
|
// - "updated" → Entry is set (pinned/hidden by a moderator)
|
||||||
// - "deleted" → ID is set (a removed signature)
|
// - "deleted" → ID is set (a removed signature)
|
||||||
// - "settings" → Settings is set (signing paused, banner changed)
|
// - "reaction" → ID/Emoji/Count are set (an entry's tally changed)
|
||||||
// - "presence" → Online/Replicas are set (connected clients changed)
|
// - "settings" → Settings is set (signing paused, banner changed, theme forced)
|
||||||
// - "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 Event struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Entry *Entry `json:"entry,omitempty"`
|
Entry *Entry `json:"entry,omitempty"`
|
||||||
ID uint `json:"id,omitempty"`
|
ID uint `json:"id,omitempty"`
|
||||||
|
Emoji string `json:"emoji,omitempty"`
|
||||||
|
Count int `json:"count,omitempty"`
|
||||||
Settings *Settings `json:"settings,omitempty"`
|
Settings *Settings `json:"settings,omitempty"`
|
||||||
Online int `json:"online,omitempty"`
|
Online int `json:"online,omitempty"`
|
||||||
Replicas int `json:"replicas,omitempty"`
|
Replicas int `json:"replicas,omitempty"`
|
||||||
|
Visitors int64 `json:"visitors,omitempty"`
|
||||||
Time string `json:"time,omitempty"`
|
Time string `json:"time,omitempty"`
|
||||||
Version string `json:"version,omitempty"`
|
Version string `json:"version,omitempty"`
|
||||||
Host string `json:"host,omitempty"`
|
Host string `json:"host,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replica describes one running instance of the app and its SSE clients.
|
// Replica describes one running instance of the app and its SSE clients.
|
||||||
|
// Requests is the number of HTTP requests it has answered since boot: under
|
||||||
|
// load-balancing the counts diverge, and a rollout resets them one replica at
|
||||||
|
// a time — the rolling update made visible.
|
||||||
type Replica struct {
|
type Replica struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Online int `json:"online"`
|
Online int `json:"online"`
|
||||||
Seen time.Time `json:"seen"`
|
Requests int64 `json:"requests"`
|
||||||
|
Seen time.Time `json:"seen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -61,6 +71,10 @@ type Broker struct {
|
|||||||
host, version string
|
host, version string
|
||||||
rdb *redis.Client
|
rdb *redis.Client
|
||||||
dirty chan struct{}
|
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
|
cmu sync.RWMutex
|
||||||
replicas []Replica
|
replicas []Replica
|
||||||
@@ -68,12 +82,15 @@ type Broker struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewBroker creates an in-process Broker. Call UseRedis to make it cluster-wide.
|
// NewBroker creates an in-process Broker. Call UseRedis to make it cluster-wide.
|
||||||
func NewBroker(host, version string) *Broker {
|
// requests reports how many HTTP requests this replica has served so far (may
|
||||||
|
// be nil); it is advertised with the presence heartbeat.
|
||||||
|
func NewBroker(host, version string, requests func() int64) *Broker {
|
||||||
return &Broker{
|
return &Broker{
|
||||||
subs: make(map[chan Event]struct{}),
|
subs: make(map[chan Event]struct{}),
|
||||||
host: host,
|
host: host,
|
||||||
version: version,
|
version: version,
|
||||||
dirty: make(chan struct{}, 1),
|
dirty: make(chan struct{}, 1),
|
||||||
|
requests: requests,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +220,11 @@ func (b *Broker) Replicas() []Replica {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *Broker) self() Replica {
|
func (b *Broker) self() Replica {
|
||||||
return Replica{Host: b.host, Version: b.version, Online: b.localCount(), Seen: time.Now().UTC()}
|
var reqs int64
|
||||||
|
if b.requests != nil {
|
||||||
|
reqs = b.requests()
|
||||||
|
}
|
||||||
|
return Replica{Host: b.host, Version: b.version, Online: b.localCount(), Requests: reqs, Seen: time.Now().UTC()}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Broker) localCount() int {
|
func (b *Broker) localCount() int {
|
||||||
@@ -214,7 +235,7 @@ func (b *Broker) localCount() int {
|
|||||||
|
|
||||||
func (b *Broker) presenceChanged() {
|
func (b *Broker) presenceChanged() {
|
||||||
if b.rdb == nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
@@ -223,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 {
|
func (b *Broker) publishRaw(ctx context.Context, ev Event) error {
|
||||||
payload, err := json.Marshal(ev)
|
payload, err := json.Marshal(ev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -306,7 +336,7 @@ func (b *Broker) refresh(ctx context.Context) {
|
|||||||
b.cmu.Unlock()
|
b.cmu.Unlock()
|
||||||
|
|
||||||
if changed {
|
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()})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestLocalBroker(t *testing.T) {
|
func TestLocalBroker(t *testing.T) {
|
||||||
b := NewBroker("host-a", "1.0.0")
|
b := NewBroker("host-a", "1.0.0", nil)
|
||||||
ch := b.Subscribe()
|
ch := b.Subscribe()
|
||||||
defer b.Unsubscribe(ch)
|
defer b.Unsubscribe(ch)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ go 1.26.3
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/glebarez/sqlite v1.11.0
|
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/go-utils v0.1.4
|
||||||
github.com/jkaninda/logger v0.0.5
|
github.com/jkaninda/logger v0.0.5
|
||||||
github.com/jkaninda/okapi v1.0.0
|
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/jsonpointer v0.22.5 // indirect
|
||||||
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // 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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||||
|
|||||||
+46
-16
@@ -28,6 +28,9 @@ type Handler struct {
|
|||||||
|
|
||||||
unhealthy atomic.Bool
|
unhealthy atomic.Bool
|
||||||
chaos chaosState
|
chaos chaosState
|
||||||
|
// requests counts HTTP requests answered by this replica since boot. A
|
||||||
|
// rolling update resets it one container at a time; see countRequests.
|
||||||
|
requests atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// serverTime is the shape of the live clock payload (v2 feature). It carries
|
// serverTime is the shape of the live clock payload (v2 feature). It carries
|
||||||
@@ -71,12 +74,13 @@ func (h *Handler) Health(c *okapi.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
return c.OK(okapi.M{
|
return c.OK(okapi.M{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"db": "up",
|
"db": "up",
|
||||||
"redis": h.broker.RedisStatus(c.Request().Context()),
|
"redis": h.broker.RedisStatus(c.Request().Context()),
|
||||||
"app": h.appName,
|
"app": h.appName,
|
||||||
"version": h.version,
|
"version": h.version,
|
||||||
"host": h.host,
|
"host": h.host,
|
||||||
|
"requests": h.requests.Load(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,16 +89,30 @@ func (h *Handler) Health(c *okapi.Context) error {
|
|||||||
// are currently connected.
|
// are currently connected.
|
||||||
func (h *Handler) Info(c *okapi.Context) error {
|
func (h *Handler) Info(c *okapi.Context) error {
|
||||||
return c.OK(okapi.M{
|
return c.OK(okapi.M{
|
||||||
"app": h.appName,
|
"app": h.appName,
|
||||||
"version": h.version,
|
"version": h.version,
|
||||||
"online": h.broker.Online(),
|
"online": h.broker.Online(),
|
||||||
"host": h.host,
|
"host": h.host,
|
||||||
"broker": h.broker.Mode(),
|
"broker": h.broker.Mode(),
|
||||||
"replicas": h.broker.Replicas(),
|
"replicas": h.broker.Replicas(),
|
||||||
"admin": h.auth.Enabled(),
|
"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.
|
// PublicSettings returns the wall state visitors need: paused flag and banner.
|
||||||
func (h *Handler) PublicSettings(c *okapi.Context) error {
|
func (h *Handler) PublicSettings(c *okapi.Context) error {
|
||||||
st, err := h.store.Settings(c.Request().Context())
|
st, err := h.store.Settings(c.Request().Context())
|
||||||
@@ -166,9 +184,6 @@ func (h *Handler) CreateEntry(c *okapi.Context) error {
|
|||||||
name := strings.TrimSpace(req.Name)
|
name := strings.TrimSpace(req.Name)
|
||||||
message := strings.TrimSpace(req.Message)
|
message := strings.TrimSpace(req.Message)
|
||||||
|
|
||||||
if name == "" {
|
|
||||||
name = "Anonymous"
|
|
||||||
}
|
|
||||||
if r := []rune(name); len(r) > maxNameLen {
|
if r := []rune(name); len(r) > maxNameLen {
|
||||||
name = string(r[:maxNameLen])
|
name = string(r[:maxNameLen])
|
||||||
}
|
}
|
||||||
@@ -181,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))
|
entry, err := h.store.Create(ctx, name, message, h.host, clientIP(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save entry"})
|
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})
|
h.broker.Publish(Event{Type: "created", Entry: &entry})
|
||||||
return c.Created(entry)
|
return c.Created(entry)
|
||||||
@@ -227,6 +256,7 @@ func (h *Handler) Stream(c *okapi.Context) error {
|
|||||||
"online": h.broker.Online(),
|
"online": h.broker.Online(),
|
||||||
"replicas": len(h.broker.Replicas()),
|
"replicas": len(h.broker.Replicas()),
|
||||||
"host": h.host,
|
"host": h.host,
|
||||||
|
"visitors": h.visitorsActive(ctx),
|
||||||
}}
|
}}
|
||||||
select {
|
select {
|
||||||
case msgs <- welcome:
|
case msgs <- welcome:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
// /healthz readiness probe, and a single static binary that ships the whole
|
// /healthz readiness probe, and a single static binary that ships the whole
|
||||||
// frontend embedded — no Node build step.
|
// frontend embedded — no Node build step.
|
||||||
//
|
//
|
||||||
// Subcommands (okapicli): server (default), migrate, cleanup.
|
// Subcommands (okapicli): server (default), migrate, cleanup, reset.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -61,6 +61,14 @@ func main() {
|
|||||||
}).
|
}).
|
||||||
Duration("purge-after", "", 7*24*time.Hour, "Permanently remove entries deleted longer ago than this").
|
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("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")
|
Bool("dry-run", "", false, "Report what would be removed without changing anything")
|
||||||
|
|
||||||
cli.DefaultCommand("server")
|
cli.DefaultCommand("server")
|
||||||
@@ -85,7 +93,7 @@ func runServer(cmd *okapicli.Command, cfg Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
host, _ := os.Hostname()
|
host, _ := os.Hostname()
|
||||||
broker := NewBroker(host, cfg.Version)
|
broker := NewBroker(host, cfg.Version, nil)
|
||||||
if cfg.RedisURL != "" {
|
if cfg.RedisURL != "" {
|
||||||
if err := broker.UseRedis(cfg.RedisURL); err != nil {
|
if err := broker.UseRedis(cfg.RedisURL); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -106,6 +114,8 @@ func runServer(cmd *okapicli.Command, cfg Config) error {
|
|||||||
debug: cfg.DebugEndpoints,
|
debug: cfg.DebugEndpoints,
|
||||||
started: time.Now(),
|
started: time.Now(),
|
||||||
}
|
}
|
||||||
|
broker.requests = h.requests.Load
|
||||||
|
broker.visitors = func() int64 { return h.visitorsActive(context.Background()) }
|
||||||
if !h.auth.Enabled() {
|
if !h.auth.Enabled() {
|
||||||
logger.Warn("admin console disabled: set ADMIN_TOKEN to enable /admin")
|
logger.Warn("admin console disabled: set ADMIN_TOKEN to enable /admin")
|
||||||
}
|
}
|
||||||
@@ -116,7 +126,7 @@ func runServer(cmd *okapicli.Command, cfg Config) error {
|
|||||||
app := cmd.Okapi()
|
app := cmd.Okapi()
|
||||||
app.WithPort(cmd.GetInt("port"))
|
app.WithPort(cmd.GetInt("port"))
|
||||||
app.WithDebug()
|
app.WithDebug()
|
||||||
app.Use(okapi.LoggerMiddleware, okapi.RequestID())
|
app.Use(okapi.LoggerMiddleware, okapi.RequestID(), h.countRequests)
|
||||||
registerRoutes(app, h)
|
registerRoutes(app, h)
|
||||||
|
|
||||||
return cmd.CLI().RunServer(&okapicli.RunOptions{
|
return cmd.CLI().RunServer(&okapicli.RunOptions{
|
||||||
@@ -138,25 +148,32 @@ func runServer(cmd *okapicli.Command, cfg Config) error {
|
|||||||
func registerRoutes(app *okapi.Okapi, h *Handler) {
|
func registerRoutes(app *okapi.Okapi, h *Handler) {
|
||||||
app.Get("/healthz", h.Health)
|
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("/info", h.Info)
|
||||||
api.Get("/time", h.Time)
|
api.Get("/time", h.Time)
|
||||||
api.Get("/settings", h.PublicSettings)
|
api.Get("/settings", h.PublicSettings)
|
||||||
|
api.Get("/me", h.VisitorMe)
|
||||||
api.Get("/entries", h.ListEntries)
|
api.Get("/entries", h.ListEntries)
|
||||||
api.Post("/entries", h.CreateEntry)
|
api.Post("/entries", h.CreateEntry)
|
||||||
|
api.Post("/entries/{id:int}/reactions", h.React)
|
||||||
|
api.Delete("/entries/{id:int}/reactions", h.Unreact)
|
||||||
api.Get("/stream", h.Stream)
|
api.Get("/stream", h.Stream)
|
||||||
|
|
||||||
api.Get("/admin/session", h.AdminSession)
|
api.Get("/admin/session", h.AdminSession)
|
||||||
api.Post("/admin/login", h.AdminLogin)
|
api.Post("/admin/login", h.AdminLogin)
|
||||||
api.Post("/admin/logout", h.AdminLogout)
|
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("/overview", h.AdminOverview)
|
||||||
admin.Get("/entries", h.AdminListEntries)
|
admin.Get("/entries", h.AdminListEntries)
|
||||||
admin.Patch("/entries/{id:int}", h.AdminUpdateEntry)
|
admin.Patch("/entries/{id:int}", h.AdminUpdateEntry)
|
||||||
admin.Delete("/entries/{id:int}", h.DeleteEntry)
|
admin.Delete("/entries/{id:int}", h.DeleteEntry)
|
||||||
admin.Get("/settings", h.AdminSettings)
|
admin.Get("/settings", h.AdminSettings)
|
||||||
admin.Put("/settings", h.AdminSaveSettings)
|
admin.Put("/settings", h.AdminSaveSettings)
|
||||||
|
admin.Get("/visitors", h.AdminVisitors)
|
||||||
admin.Get("/export.csv", h.AdminExport)
|
admin.Get("/export.csv", h.AdminExport)
|
||||||
|
|
||||||
debug := app.Group("/api/debug", h.auth.Require, h.RequireDebug)
|
debug := app.Group("/api/debug", h.auth.Require, h.RequireDebug)
|
||||||
@@ -176,6 +193,12 @@ func registerRoutes(app *okapi.Okapi, h *Handler) {
|
|||||||
return c.Data(http.StatusOK, "text/html; charset=utf-8", adminPage)
|
return c.Data(http.StatusOK, "text/html; charset=utf-8", adminPage)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Browsers ask for /favicon.ico on their own; the logo serves as the icon.
|
||||||
|
app.Get("/favicon.ico", func(c *okapi.Context) error {
|
||||||
|
c.Redirect(http.StatusFound, "/badges/icon.svg")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
// Okapi serves real files directly and falls back to index.html for any
|
// Okapi serves real files directly and falls back to index.html for any
|
||||||
// other path so the client-side router can take over.
|
// other path so the client-side router can take over.
|
||||||
app.WebFS("/", webFS, okapi.WebConfig{Root: "web"})
|
app.WebFS("/", webFS, okapi.WebConfig{Root: "web"})
|
||||||
@@ -202,8 +225,56 @@ func runCleanup(cmd *okapicli.Command, cfg Config) error {
|
|||||||
return err
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,10 +304,11 @@ func publishTicks(ctx context.Context, b *Broker, version, host string) {
|
|||||||
return
|
return
|
||||||
case now := <-t.C:
|
case now := <-t.C:
|
||||||
b.PublishLocal(Event{
|
b.PublishLocal(Event{
|
||||||
Type: "tick",
|
Type: "tick",
|
||||||
Time: now.Format(time.RFC3339),
|
Time: now.Format(time.RFC3339),
|
||||||
Version: version,
|
Version: version,
|
||||||
Host: host,
|
Host: host,
|
||||||
|
Visitors: b.visitorsActive(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/jkaninda/okapi"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reactions let visitors cheer a signature without an account: one tap adds,
|
||||||
|
// tapping again removes, and the tally streams to every open tab over SSE.
|
||||||
|
// 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) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", errors.New("invalid id")
|
||||||
|
}
|
||||||
|
emoji := c.Query("emoji")
|
||||||
|
if !validReaction(emoji) {
|
||||||
|
return 0, "", errors.New("unknown emoji (want one of: heart, tada, thumbsup, smile)")
|
||||||
|
}
|
||||||
|
return id, emoji, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// React adds a reaction to an entry and broadcasts the new tally live.
|
||||||
|
func (h *Handler) React(c *okapi.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
id, emoji, err := parseReactionRequest(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, okapi.M{"error": err.Error()})
|
||||||
|
}
|
||||||
|
if _, err := h.store.Get(ctx, id); errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return c.JSON(http.StatusNotFound, okapi.M{"error": "entry not found"})
|
||||||
|
}
|
||||||
|
who := visitorFromContext(c).ID
|
||||||
|
if who == "" {
|
||||||
|
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not identify the browser"})
|
||||||
|
}
|
||||||
|
count, err := h.store.React(ctx, id, emoji, who)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not save the reaction"})
|
||||||
|
}
|
||||||
|
h.broker.Publish(Event{Type: "reaction", ID: uint(id), Emoji: emoji, Count: count})
|
||||||
|
return c.OK(okapi.M{"entry_id": id, "emoji": emoji, "count": count})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreact removes the browser's reaction and broadcasts the new tally live.
|
||||||
|
func (h *Handler) Unreact(c *okapi.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
id, emoji, err := parseReactionRequest(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, okapi.M{"error": err.Error()})
|
||||||
|
}
|
||||||
|
who := visitorFromContext(c).ID
|
||||||
|
if who == "" {
|
||||||
|
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not identify the browser"})
|
||||||
|
}
|
||||||
|
count, err := h.store.Unreact(ctx, id, emoji, who)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, okapi.M{"error": "could not remove the reaction"})
|
||||||
|
}
|
||||||
|
h.broker.Publish(Event{Type: "reaction", ID: uint(id), Emoji: emoji, Count: count})
|
||||||
|
return c.OK(okapi.M{"entry_id": id, "emoji": emoji, "count": count})
|
||||||
|
}
|
||||||
|
|
||||||
|
// countRequests is middleware that tallies the HTTP requests this replica has
|
||||||
|
// answered since 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.
|
||||||
|
func (h *Handler) countRequests(c *okapi.Context) error {
|
||||||
|
h.requests.Add(1)
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReactions(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
e, err := s.Create(ctx, "ada", "hello miabi", "r1", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two visitors react, one of them twice (idempotent).
|
||||||
|
for _, tc := range []struct {
|
||||||
|
who, emoji string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"alice", "heart", 1},
|
||||||
|
{"bob", "heart", 2},
|
||||||
|
{"bob", "heart", 2},
|
||||||
|
{"alice", "tada", 1},
|
||||||
|
} {
|
||||||
|
got, err := s.React(ctx, uint64(e.ID), tc.emoji, tc.who)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("React(%s, %s) = %d, want %d", tc.who, tc.emoji, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
counts, err := s.ReactionCounts(ctx, uint64(e.ID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if counts["heart"] != 2 || counts["tada"] != 1 {
|
||||||
|
t.Fatalf("counts = %+v, want heart=2 tada=1", counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreacting twice is a no-op after the first removal.
|
||||||
|
if got, _ := s.Unreact(ctx, uint64(e.ID), "heart", "bob"); got != 1 {
|
||||||
|
t.Fatalf("unreact = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got, _ := s.Unreact(ctx, uint64(e.ID), "heart", "bob"); got != 1 {
|
||||||
|
t.Fatalf("second unreact = %d, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List and Get attach the tally; totals count every reaction.
|
||||||
|
wall, err := s.List(ctx, 10, 0)
|
||||||
|
if err != nil || len(wall) != 1 {
|
||||||
|
t.Fatalf("list: %v (%d entries)", err, len(wall))
|
||||||
|
}
|
||||||
|
if wall[0].Reactions["heart"] != 1 || wall[0].Reactions["tada"] != 1 {
|
||||||
|
t.Fatalf("list reactions = %+v", wall[0].Reactions)
|
||||||
|
}
|
||||||
|
one, err := s.Get(ctx, uint64(e.ID))
|
||||||
|
if err != nil || one.Reactions["heart"] != 1 {
|
||||||
|
t.Fatalf("get reactions = %+v, err=%v", one.Reactions, err)
|
||||||
|
}
|
||||||
|
if n, _ := s.ReactionTotals(ctx); n != 2 {
|
||||||
|
t.Fatalf("totals = %d, want 2", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting a reaction must not touch the entry itself.
|
||||||
|
if n, _ := s.Count(ctx); n != 1 {
|
||||||
|
t.Fatalf("entries = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReactionWhitelist(t *testing.T) {
|
||||||
|
for _, emoji := range ReactionEmojis() {
|
||||||
|
if !validReaction(emoji) {
|
||||||
|
t.Fatalf("whitelist rejects its own emoji %q", emoji)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if validReaction("poop") || validReaction("") {
|
||||||
|
t.Fatal("whitelist accepted a non-whitelisted emoji")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsTheme(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
|
||||||
|
if st, _ := s.Settings(ctx); st.Theme != "" {
|
||||||
|
t.Fatalf("default theme = %q, want empty (system)", st.Theme)
|
||||||
|
}
|
||||||
|
want := Settings{SigningPaused: false, Banner: "hi", Theme: "light"}
|
||||||
|
if err := s.SaveSettings(ctx, want); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, _ := s.Settings(ctx); got != want {
|
||||||
|
t.Fatalf("got %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -10,9 +11,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite" // pure-Go SQLite (no CGO — keeps the static build)
|
"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"
|
"github.com/jkaninda/logger"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Entry is a single signature on the guestbook wall.
|
// Entry is a single signature on the guestbook wall.
|
||||||
@@ -27,8 +30,40 @@ type Entry struct {
|
|||||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"-"`
|
UpdatedAt time.Time `json:"-"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
// Reactions carries the per-emoji tally (e.g. {"heart":3,"tada":1}); it is
|
||||||
|
// filled by list endpoints, never stored on the entries table itself.
|
||||||
|
Reactions map[string]int `gorm:"-" json:"reactions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reaction is one emoji reaction to an entry. Visitors react and un-react
|
||||||
|
// 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:"-"`
|
||||||
|
Who string `gorm:"primaryKey;type:varchar(64)" json:"-"`
|
||||||
|
CreatedAt time.Time `json:"-"`
|
||||||
|
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.
|
// Setting is a key/value row holding app-wide state shared by all replicas.
|
||||||
type Setting struct {
|
type Setting struct {
|
||||||
Key string `gorm:"primaryKey;type:varchar(64)"`
|
Key string `gorm:"primaryKey;type:varchar(64)"`
|
||||||
@@ -40,6 +75,9 @@ type Setting struct {
|
|||||||
type Settings struct {
|
type Settings struct {
|
||||||
SigningPaused bool `json:"signing_paused"`
|
SigningPaused bool `json:"signing_paused"`
|
||||||
Banner string `json:"banner"`
|
Banner string `json:"banner"`
|
||||||
|
// Theme is the wall appearance forced by a moderator: "" (or "system"),
|
||||||
|
// "light" or "dark". Visitors can still override it locally.
|
||||||
|
Theme string `json:"theme"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EntryFilter selects entries for the admin console.
|
// EntryFilter selects entries for the admin console.
|
||||||
@@ -56,14 +94,32 @@ type DayCount struct {
|
|||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emojis a visitor can react with; also the whitelist for the API.
|
||||||
|
var reactionEmojis = []string{"heart", "tada", "thumbsup", "smile"}
|
||||||
|
|
||||||
|
// ReactionEmojis returns the whitelist, for the info endpoint.
|
||||||
|
func ReactionEmojis() []string { return reactionEmojis }
|
||||||
|
|
||||||
|
// validReaction reports whether emoji is on the whitelist.
|
||||||
|
func validReaction(emoji string) bool {
|
||||||
|
for _, e := range reactionEmojis {
|
||||||
|
if e == emoji {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Stats summarises the wall for the admin overview.
|
// Stats summarises the wall for the admin overview.
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
Visible int64 `json:"visible"`
|
Visible int64 `json:"visible"`
|
||||||
Hidden int64 `json:"hidden"`
|
Hidden int64 `json:"hidden"`
|
||||||
Pinned int64 `json:"pinned"`
|
Pinned int64 `json:"pinned"`
|
||||||
Today int64 `json:"today"`
|
Today int64 `json:"today"`
|
||||||
Daily []DayCount `json:"daily"`
|
Reactions int64 `json:"reactions"`
|
||||||
|
Visitors int64 `json:"visitors"`
|
||||||
|
Daily []DayCount `json:"daily"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsKey = "settings"
|
const settingsKey = "settings"
|
||||||
@@ -140,7 +196,7 @@ func (s *Store) Ping(ctx context.Context) error {
|
|||||||
// concurrent AutoMigrate calls race on Postgres catalog inserts.
|
// concurrent AutoMigrate calls race on Postgres catalog inserts.
|
||||||
func (s *Store) Migrate() error {
|
func (s *Store) Migrate() error {
|
||||||
return s.Exclusive(context.Background(), func(tx *Store) error {
|
return s.Exclusive(context.Background(), func(tx *Store) error {
|
||||||
return tx.db.AutoMigrate(&Entry{}, &Setting{})
|
return tx.db.AutoMigrate(&Entry{}, &Setting{}, &Reaction{}, &Visitor{})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +216,10 @@ func (s *Store) List(ctx context.Context, limit, offset int) ([]Entry, error) {
|
|||||||
Limit(limit).
|
Limit(limit).
|
||||||
Offset(offset).
|
Offset(offset).
|
||||||
Find(&entries).Error
|
Find(&entries).Error
|
||||||
return entries, err
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return entries, s.loadReactions(ctx, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create inserts a new entry and returns it with its generated id/timestamp.
|
// Create inserts a new entry and returns it with its generated id/timestamp.
|
||||||
@@ -170,6 +229,243 @@ func (s *Store) Create(ctx context.Context, name, message, replica, ip string) (
|
|||||||
return e, err
|
return e, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadReactions fills the Reactions map of every entry in one grouped query.
|
||||||
|
func (s *Store) loadReactions(ctx context.Context, entries []Entry) error {
|
||||||
|
ids := make([]uint, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
ids[i] = e.ID
|
||||||
|
entries[i].Reactions = map[string]int{}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
EntryID uint
|
||||||
|
Emoji string
|
||||||
|
N int
|
||||||
|
}
|
||||||
|
if err := s.db.WithContext(ctx).Model(&Reaction{}).
|
||||||
|
Select("entry_id, emoji, count(*) AS n").
|
||||||
|
Where("entry_id IN ?", ids).
|
||||||
|
Group("entry_id, emoji").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
byID := make(map[uint]int, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
byID[e.ID] = i
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if i, ok := byID[r.EntryID]; ok {
|
||||||
|
entries[i].Reactions[r.Emoji] = r.N
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// React registers a reaction and returns the emoji's new total for the entry.
|
||||||
|
// It is idempotent for a given (entry, emoji, who): reacting twice is a no-op.
|
||||||
|
func (s *Store) React(ctx context.Context, entryID uint64, emoji, who string) (int, error) {
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Clauses(clause.OnConflict{DoNothing: true}).
|
||||||
|
Create(&Reaction{EntryID: uint(entryID), Emoji: emoji, Who: who}).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return s.reactionTotal(ctx, entryID, emoji)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreact removes a reaction (idempotent) and returns the emoji's new total.
|
||||||
|
func (s *Store) Unreact(ctx context.Context, entryID uint64, emoji, who string) (int, error) {
|
||||||
|
err := s.db.WithContext(ctx).
|
||||||
|
Where("entry_id = ? AND emoji = ? AND who = ?", entryID, emoji, who).
|
||||||
|
Delete(&Reaction{}).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return s.reactionTotal(ctx, entryID, emoji)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) reactionTotal(ctx context.Context, entryID uint64, emoji string) (int, error) {
|
||||||
|
var n int64
|
||||||
|
err := s.db.WithContext(ctx).Model(&Reaction{}).
|
||||||
|
Where("entry_id = ? AND emoji = ?", entryID, emoji).
|
||||||
|
Count(&n).Error
|
||||||
|
return int(n), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReactionCounts returns the per-emoji tally for one entry.
|
||||||
|
func (s *Store) ReactionCounts(ctx context.Context, entryID uint64) (map[string]int, error) {
|
||||||
|
out := map[string]int{}
|
||||||
|
var rows []struct {
|
||||||
|
Emoji string
|
||||||
|
N int
|
||||||
|
}
|
||||||
|
err := s.db.WithContext(ctx).Model(&Reaction{}).
|
||||||
|
Select("emoji, count(*) AS n").
|
||||||
|
Where("entry_id = ?", entryID).
|
||||||
|
Group("emoji").
|
||||||
|
Scan(&rows).Error
|
||||||
|
for _, r := range rows {
|
||||||
|
out[r.Emoji] = r.N
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReactionTotals returns the total number of reactions across the wall.
|
||||||
|
func (s *Store) ReactionTotals(ctx context.Context) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := s.db.WithContext(ctx).Model(&Reaction{}).Count(&n).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.
|
// 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) {
|
func (s *Store) Delete(ctx context.Context, id uint64) (bool, error) {
|
||||||
res := s.db.WithContext(ctx).Delete(&Entry{}, id)
|
res := s.db.WithContext(ctx).Delete(&Entry{}, id)
|
||||||
@@ -206,10 +502,14 @@ func (s *Store) CountVisible(ctx context.Context) (int64, error) {
|
|||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns a single entry by id.
|
// Get returns a single entry by id, with its reaction counts.
|
||||||
func (s *Store) Get(ctx context.Context, id uint64) (Entry, error) {
|
func (s *Store) Get(ctx context.Context, id uint64) (Entry, error) {
|
||||||
var e Entry
|
var e Entry
|
||||||
err := s.db.WithContext(ctx).First(&e, id).Error
|
err := s.db.WithContext(ctx).First(&e, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
e.Reactions, err = s.ReactionCounts(ctx, id)
|
||||||
return e, err
|
return e, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +535,10 @@ func (s *Store) AdminList(ctx context.Context, f EntryFilter) ([]Entry, int64, e
|
|||||||
}
|
}
|
||||||
var entries []Entry
|
var entries []Entry
|
||||||
err := q.Order("created_at DESC").Limit(f.Limit).Offset(f.Offset).Find(&entries).Error
|
err := q.Order("created_at DESC").Limit(f.Limit).Offset(f.Offset).Find(&entries).Error
|
||||||
return entries, total, err
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return entries, total, s.loadReactions(ctx, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFlags updates the moderation flags that are non-nil and returns the entry.
|
// SetFlags updates the moderation flags that are non-nil and returns the entry.
|
||||||
@@ -274,6 +577,8 @@ 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("hidden = ?", true).Count(&st.Hidden)
|
||||||
s.db.WithContext(ctx).Model(&Entry{}).Where("pinned = ?", true).Count(&st.Pinned)
|
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
|
st.Visible = st.Total - st.Hidden
|
||||||
|
|
||||||
now := time.Now().UTC()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
-3
@@ -108,6 +108,8 @@
|
|||||||
td.mono { font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
|
td.mono { font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
|
||||||
tr.me td { color: #fff; }
|
tr.me td { color: #fff; }
|
||||||
tr.me td:first-child::after { content: " ← you"; color: var(--accent-2); font-family: inherit; font-size: 11.5px; }
|
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 { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0; font-size: 13.5px; }
|
||||||
dl.kv dt { color: var(--muted); }
|
dl.kv dt { color: var(--muted); }
|
||||||
dl.kv dd { margin: 0; font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
|
dl.kv dd { margin: 0; font-family: var(--mono); font-size: 12.5px; word-break: break-all; }
|
||||||
@@ -216,9 +218,12 @@
|
|||||||
<div id="tab-overview" data-panel>
|
<div id="tab-overview" data-panel>
|
||||||
<div class="tiles">
|
<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 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">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">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>
|
<div class="card tile"><div class="label">Today</div><div class="value" id="tToday">–</div></div>
|
||||||
|
<div class="card tile"><div class="label">Reactions</div><div class="value" id="tReacts">–</div></div>
|
||||||
<div class="card tile"><div class="label">Pinned</div><div class="value" id="tPinned">–</div></div>
|
<div class="card tile"><div class="label">Pinned</div><div class="value" id="tPinned">–</div></div>
|
||||||
<div class="card tile"><div class="label">Hidden</div><div class="value" id="tHidden">–</div></div>
|
<div class="card tile"><div class="label">Hidden</div><div class="value" id="tHidden">–</div></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,7 +237,7 @@
|
|||||||
<h2>Replicas</h2>
|
<h2>Replicas</h2>
|
||||||
<p class="hint" id="replicaHint"></p>
|
<p class="hint" id="replicaHint"></p>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Host</th><th>Version</th><th>Clients</th><th>Seen</th></tr></thead>
|
<thead><tr><th>Host</th><th>Version</th><th>Clients</th><th title="HTTP requests answered since this replica started — a rollout resets them one at a time">Requests</th><th>Seen</th></tr></thead>
|
||||||
<tbody id="replicaRows"></tbody>
|
<tbody id="replicaRows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,6 +246,14 @@
|
|||||||
<dl class="kv" id="runtime"></dl>
|
<dl class="kv" id="runtime"></dl>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -278,6 +291,15 @@
|
|||||||
<input id="banner" maxlength="200" placeholder="e.g. Welcome to the Miabi training 👋" />
|
<input id="banner" maxlength="200" placeholder="e.g. Welcome to the Miabi training 👋" />
|
||||||
<button class="primary" type="submit">Save</button>
|
<button class="primary" type="submit">Save</button>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="field" for="theme" style="margin-top:16px">Wall theme</label>
|
||||||
|
<div class="field-row">
|
||||||
|
<select id="theme">
|
||||||
|
<option value="system">System — follow each visitor's preference</option>
|
||||||
|
<option value="light">Force light</option>
|
||||||
|
<option value="dark">Force dark</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="margin:8px 0 0">Pushed live to every open tab. Visitors can toggle light/dark themselves unless you force one here.</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -348,6 +370,15 @@
|
|||||||
<div class="probe-bars" id="probeBars"></div>
|
<div class="probe-bars" id="probeBars"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card pad" style="margin-bottom:16px">
|
||||||
|
<h2>Rolling-update wave</h2>
|
||||||
|
<p class="hint">Watch what Miabi does during a <b>rolling update</b>: this tool bounces <i>one replica at a time</i> with the chaos endpoint while polling the replica table. Each restarted replica comes back with its request counter reset and, during a canary, a new version. Needs debug endpoints; run it with Redis attached so every replica is listed.</p>
|
||||||
|
<div class="toolbar" style="margin-bottom:0">
|
||||||
|
<button class="primary" id="waveRun">Bounce each replica, one at a time</button>
|
||||||
|
<span class="chip" id="waveStat" hidden></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card pad">
|
<div class="card pad">
|
||||||
<h2>Activity</h2>
|
<h2>Activity</h2>
|
||||||
<div class="log" id="log"><div class="t">Actions and their responses appear here.</div></div>
|
<div class="log" id="log"><div class="t">Actions and their responses appear here.</div></div>
|
||||||
@@ -421,25 +452,31 @@
|
|||||||
if (!r.ok || !r.data) return;
|
if (!r.ok || !r.data) return;
|
||||||
overview = r.data;
|
overview = r.data;
|
||||||
const { stats, runtime: rt, replicas, online, debug } = overview;
|
const { stats, runtime: rt, replicas, online, debug } = overview;
|
||||||
|
$('waveRun').disabled = !debug;
|
||||||
$('servedBy').textContent = rt.host;
|
$('servedBy').textContent = rt.host;
|
||||||
$('appName').textContent = rt.app;
|
$('appName').textContent = rt.app;
|
||||||
setVersion(rt.version);
|
setVersion(rt.version);
|
||||||
$('tOnline').textContent = online;
|
$('tOnline').textContent = online;
|
||||||
|
$('tVisiting').textContent = overview.visitors ?? 0;
|
||||||
|
$('tVisitors').textContent = stats.visitors ?? 0;
|
||||||
$('tReplicas').textContent = replicas.length;
|
$('tReplicas').textContent = replicas.length;
|
||||||
$('tTotal').textContent = stats.total;
|
$('tTotal').textContent = stats.total;
|
||||||
$('tToday').textContent = stats.today;
|
$('tToday').textContent = stats.today;
|
||||||
|
$('tReacts').textContent = stats.reactions ?? 0;
|
||||||
$('tPinned').textContent = stats.pinned;
|
$('tPinned').textContent = stats.pinned;
|
||||||
$('tHidden').textContent = stats.hidden;
|
$('tHidden').textContent = stats.hidden;
|
||||||
renderChart(stats.daily || []);
|
renderChart(stats.daily || []);
|
||||||
|
loadVisitors();
|
||||||
|
|
||||||
$('replicaHint').innerHTML = rt.broker === 'redis'
|
$('replicaHint').innerHTML = rt.broker === 'redis'
|
||||||
? 'Presence is shared through Redis, so every replica is listed.'
|
? 'Presence is shared through Redis, so every replica is listed. The requests counter grows per replica and resets when a rollout replaces it.'
|
||||||
: 'In-process broker: only this replica is visible. Attach Redis (<code>REDIS_URL</code>) to share live updates across replicas.';
|
: 'In-process broker: only this replica is visible. Attach Redis (<code>REDIS_URL</code>) to share live updates across replicas.';
|
||||||
$('replicaRows').innerHTML = replicas.map((r) => `
|
$('replicaRows').innerHTML = replicas.map((r) => `
|
||||||
<tr class="${r.host === rt.host ? 'me' : ''}">
|
<tr class="${r.host === rt.host ? 'me' : ''}">
|
||||||
<td class="mono">${esc(r.host)}</td>
|
<td class="mono">${esc(r.host)}</td>
|
||||||
<td><span class="ver" style="background:hsl(${hueFor(r.version)} 70% 52%);font-size:11px;padding:2px 8px">v${esc(r.version)}</span></td>
|
<td><span class="ver" style="background:hsl(${hueFor(r.version)} 70% 52%);font-size:11px;padding:2px 8px">v${esc(r.version)}</span></td>
|
||||||
<td>${r.online}</td>
|
<td>${r.online}</td>
|
||||||
|
<td>${r.requests ?? '–'}</td>
|
||||||
<td>${ago(r.seen)}</td>
|
<td>${ago(r.seen)}</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
|
|
||||||
@@ -452,6 +489,7 @@
|
|||||||
['Live updates', rt.broker === 'redis' ? 'Redis pub/sub' : 'in-process'],
|
['Live updates', rt.broker === 'redis' ? 'Redis pub/sub' : 'in-process'],
|
||||||
['Redis', status(rt.redis)],
|
['Redis', status(rt.redis)],
|
||||||
['Uptime', uptime(rt.uptime_s)],
|
['Uptime', uptime(rt.uptime_s)],
|
||||||
|
['Requests served', rt.requests ?? 0],
|
||||||
['Go', `${esc(rt.go)} · ${rt.cpus} CPU`],
|
['Go', `${esc(rt.go)} · ${rt.cpus} CPU`],
|
||||||
['Goroutines', rt.goroutines],
|
['Goroutines', rt.goroutines],
|
||||||
['Heap / Sys', `${rt.heap_mb.toFixed(1)} / ${rt.sys_mb.toFixed(1)} MiB`],
|
['Heap / Sys', `${rt.heap_mb.toFixed(1)} / ${rt.sys_mb.toFixed(1)} MiB`],
|
||||||
@@ -479,6 +517,22 @@
|
|||||||
}).join('');
|
}).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 --------------------------------------------------------
|
// ---- moderation --------------------------------------------------------
|
||||||
const mod = { status: '', q: '', offset: 0 };
|
const mod = { status: '', q: '', offset: 0 };
|
||||||
async function loadRows() {
|
async function loadRows() {
|
||||||
@@ -496,6 +550,8 @@
|
|||||||
function rowHTML(e) {
|
function rowHTML(e) {
|
||||||
const rep = e.replica ? `<span class="rep" title="Accepted by">${esc(e.replica)}</span>` : '';
|
const rep = e.replica ? `<span class="rep" title="Accepted by">${esc(e.replica)}</span>` : '';
|
||||||
const ip = e.ip ? `<span class="ip" data-ip="${esc(e.ip)}" title="Submitter IP — click to filter">${esc(e.ip)}</span>` : '';
|
const ip = e.ip ? `<span class="ip" data-ip="${esc(e.ip)}" title="Submitter IP — click to filter">${esc(e.ip)}</span>` : '';
|
||||||
|
const reacts = Object.entries(e.reactions || {}).map(([k, n]) => `${({ heart: '❤️', tada: '🎉', thumbsup: '👍', smile: '😄' })[k] || k} ${n}`).join(' · ');
|
||||||
|
const reactsChip = reacts ? `<span class="rep" title="Reactions">${esc(reacts)}</span>` : '';
|
||||||
return `<div class="card row ${e.hidden ? 'is-hidden' : ''} ${e.pinned ? 'is-pinned' : ''}" data-id="${e.id}">
|
return `<div class="card row ${e.hidden ? 'is-hidden' : ''} ${e.pinned ? 'is-pinned' : ''}" data-id="${e.id}">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<span class="who">${esc(e.name)}</span>
|
<span class="who">${esc(e.name)}</span>
|
||||||
@@ -503,6 +559,7 @@
|
|||||||
<span class="id">#${e.id}</span>
|
<span class="id">#${e.id}</span>
|
||||||
${e.pinned ? '<span class="badge pin">Pinned</span>' : ''}
|
${e.pinned ? '<span class="badge pin">Pinned</span>' : ''}
|
||||||
${e.hidden ? '<span class="badge hid">Hidden</span>' : ''}
|
${e.hidden ? '<span class="badge hid">Hidden</span>' : ''}
|
||||||
|
${reactsChip}
|
||||||
${ip}
|
${ip}
|
||||||
${rep}
|
${rep}
|
||||||
</div>
|
</div>
|
||||||
@@ -552,14 +609,16 @@
|
|||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
$('paused').checked = !!r.data.signing_paused;
|
$('paused').checked = !!r.data.signing_paused;
|
||||||
$('banner').value = r.data.banner || '';
|
$('banner').value = r.data.banner || '';
|
||||||
|
$('theme').value = r.data.theme || 'system';
|
||||||
$('bannerCount').textContent = $('banner').value.length + ' / 200';
|
$('bannerCount').textContent = $('banner').value.length + ' / 200';
|
||||||
}
|
}
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
const r = await req('PUT', '/api/admin/settings', { signing_paused: $('paused').checked, banner: $('banner').value });
|
const r = await req('PUT', '/api/admin/settings', { signing_paused: $('paused').checked, banner: $('banner').value, theme: $('theme').value });
|
||||||
toast(r.ok ? 'Settings saved and broadcast' : ((r.data && r.data.error) || 'Could not save'), !r.ok);
|
toast(r.ok ? 'Settings saved and broadcast' : ((r.data && r.data.error) || 'Could not save'), !r.ok);
|
||||||
}
|
}
|
||||||
$('banner').addEventListener('input', (e) => { $('bannerCount').textContent = e.target.value.length + ' / 200'; });
|
$('banner').addEventListener('input', (e) => { $('bannerCount').textContent = e.target.value.length + ' / 200'; });
|
||||||
$('paused').addEventListener('change', saveSettings);
|
$('paused').addEventListener('change', saveSettings);
|
||||||
|
$('theme').addEventListener('change', saveSettings);
|
||||||
$('settingsForm').addEventListener('submit', (e) => { e.preventDefault(); saveSettings(); });
|
$('settingsForm').addEventListener('submit', (e) => { e.preventDefault(); saveSettings(); });
|
||||||
|
|
||||||
// ---- chaos lab ---------------------------------------------------------
|
// ---- chaos lab ---------------------------------------------------------
|
||||||
@@ -615,6 +674,65 @@
|
|||||||
log('gave up watching after 120s', 'w');
|
log('gave up watching after 120s', 'w');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- rolling-update wave -----------------------------------------------
|
||||||
|
// Crashes replicas one at a time and waits for each to rejoin (its request
|
||||||
|
// counter restarts from ~0), mimicking — and explaining — what the platform
|
||||||
|
// does during a rolling update. Self-healing does the rest.
|
||||||
|
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||||
|
async function fetchOverviewQuiet() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/overview', { cache: 'no-store' });
|
||||||
|
return res.ok ? await res.json() : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
$('waveRun').addEventListener('click', async () => {
|
||||||
|
if (!overview || !overview.debug) return;
|
||||||
|
if (!confirm('Restart every replica, one at a time, using the crash endpoint? The app self-heals — this is what a rolling update looks like from the inside.')) return;
|
||||||
|
$('waveRun').disabled = true;
|
||||||
|
$('waveStat').hidden = false;
|
||||||
|
try {
|
||||||
|
// Snapshot the fleet (in local mode this is just the answering replica).
|
||||||
|
const first = await fetchOverviewQuiet();
|
||||||
|
const targets = [...new Set((first ? first.replicas : []).map((r) => r.host))];
|
||||||
|
if (!targets.length) targets.push((first && first.runtime.host) || '');
|
||||||
|
let bounced = 0, hops = 0;
|
||||||
|
while (bounced < targets.length && hops < targets.length * 3 + 3) {
|
||||||
|
hops++;
|
||||||
|
const ov = await fetchOverviewQuiet();
|
||||||
|
if (!ov) { await sleep(1500); continue; }
|
||||||
|
const host = ov.runtime.host;
|
||||||
|
const idx = targets.indexOf(host);
|
||||||
|
if (idx < 0) { // already-bounced or unknown replica answered: keep hunting
|
||||||
|
$('waveStat').textContent = `looking for ${targets[bounced]}… (at ${host})`;
|
||||||
|
await req('POST', '/api/debug/crash'); // bounce it too, then move on
|
||||||
|
await sleep(4000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
targets.splice(idx, 1);
|
||||||
|
$('waveStat').textContent = `wave ${bounced + 1} → bouncing ${host}`;
|
||||||
|
log(`wave ${bounced + 1}: crashing ${host} (served ${ov.runtime.requests ?? '?'} requests since boot)`, 'w');
|
||||||
|
await req('POST', '/api/debug/crash');
|
||||||
|
// Wait until a *different* replica (or the same name with a fresh
|
||||||
|
// counter) answers again — that is the restart made visible.
|
||||||
|
const deadline = Date.now() + 90000;
|
||||||
|
let back = false;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await sleep(1500);
|
||||||
|
const cur = await fetchOverviewQuiet();
|
||||||
|
if (!cur) continue;
|
||||||
|
if (cur.runtime.host !== host || (cur.runtime.requests ?? 0) < (ov.runtime.requests ?? 0)) { back = true; break; }
|
||||||
|
}
|
||||||
|
log(back ? `${host} is back (request counter reset)` : `${host} did not come back within 90s`, back ? 's' : 'e');
|
||||||
|
bounced++;
|
||||||
|
}
|
||||||
|
$('waveStat').textContent = `wave done — ${bounced} replica${bounced === 1 ? '' : 's'} bounced`;
|
||||||
|
log(`rolling wave finished: ${bounced} replica(s) bounced, app stayed up`, 's');
|
||||||
|
} finally {
|
||||||
|
$('waveRun').disabled = !(overview && overview.debug);
|
||||||
|
loadOverview();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$('probeRun').addEventListener('click', async () => {
|
$('probeRun').addEventListener('click', async () => {
|
||||||
const url = $('probeTarget').value;
|
const url = $('probeTarget').value;
|
||||||
const n = Math.min(1000, Math.max(1, +$('probeN').value || 1));
|
const n = Math.min(1000, Math.max(1, +$('probeN').value || 1));
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#8b5cf6"/>
|
||||||
|
<stop offset="1" stop-color="#a855f7"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="14" fill="url(#g)"/>
|
||||||
|
<path d="M20 34h24v4a8 8 0 0 1-8 8h-8a8 8 0 0 1-8-8v-4z" fill="#fff" opacity=".92"/>
|
||||||
|
<path d="M25 22c0-3 3-3 3-6m5 6c0-3 3-3 3-6" stroke="#fff" stroke-width="3.4" stroke-linecap="round" fill="none" opacity=".92"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 531 B |
+163
-10
@@ -5,8 +5,13 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Miabi Guestbook</title>
|
<title>Miabi Guestbook</title>
|
||||||
<meta name="description" content="A tiny Okapi + Postgres example app, deployed on Miabi." />
|
<meta name="description" content="A tiny Okapi + Postgres example app, deployed on Miabi." />
|
||||||
|
<meta name="theme-color" content="#0b0a12" />
|
||||||
|
<link rel="icon" href="/badges/icon.svg" type="image/svg+xml" />
|
||||||
<style>
|
<style>
|
||||||
:root {
|
/* Dark is the default look; body.light switches to the light palette.
|
||||||
|
The moderator can also force one for everyone (wall setting "theme"). */
|
||||||
|
:root, body:not(.light) {
|
||||||
|
color-scheme: dark;
|
||||||
--bg: #0b0a12;
|
--bg: #0b0a12;
|
||||||
--bg-2: #12101d;
|
--bg-2: #12101d;
|
||||||
--card: rgba(255, 255, 255, 0.04);
|
--card: rgba(255, 255, 255, 0.04);
|
||||||
@@ -17,9 +22,31 @@
|
|||||||
--accent-2: #a855f7;
|
--accent-2: #a855f7;
|
||||||
--danger: #f43f5e;
|
--danger: #f43f5e;
|
||||||
--online: #22c55e;
|
--online: #22c55e;
|
||||||
|
--input-bg: rgba(0, 0, 0, 0.25);
|
||||||
|
--via-bg: rgba(0, 0, 0, 0.2);
|
||||||
|
--glow-a: rgba(139, 92, 246, 0.18);
|
||||||
|
--glow-b: rgba(168, 85, 247, 0.14);
|
||||||
--radius: 16px;
|
--radius: 16px;
|
||||||
--shadow: 0 20px 60px -20px rgba(139, 92, 246, 0.35);
|
--shadow: 0 20px 60px -20px rgba(139, 92, 246, 0.35);
|
||||||
}
|
}
|
||||||
|
body.light {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f4f2fb;
|
||||||
|
--bg-2: #e9e5f7;
|
||||||
|
--card: rgba(255, 255, 255, 0.72);
|
||||||
|
--card-border: rgba(15, 10, 40, 0.1);
|
||||||
|
--text: #221c3d;
|
||||||
|
--muted: #645e7d;
|
||||||
|
--accent: #7c3aed;
|
||||||
|
--accent-2: #9333ea;
|
||||||
|
--danger: #e11d48;
|
||||||
|
--online: #16a34a;
|
||||||
|
--input-bg: rgba(255, 255, 255, 0.8);
|
||||||
|
--via-bg: rgba(124, 58, 237, 0.08);
|
||||||
|
--glow-a: rgba(139, 92, 246, 0.22);
|
||||||
|
--glow-b: rgba(168, 85, 247, 0.16);
|
||||||
|
--shadow: 0 20px 50px -24px rgba(124, 58, 237, 0.35);
|
||||||
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { margin: 0; padding: 0; }
|
html, body { margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
@@ -27,8 +54,8 @@
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background:
|
background:
|
||||||
radial-gradient(900px 500px at 15% -10%, rgba(139, 92, 246, 0.18), transparent 60%),
|
radial-gradient(900px 500px at 15% -10%, var(--glow-a), transparent 60%),
|
||||||
radial-gradient(700px 500px at 110% 10%, rgba(168, 85, 247, 0.14), transparent 55%),
|
radial-gradient(700px 500px at 110% 10%, var(--glow-b), transparent 55%),
|
||||||
linear-gradient(180deg, var(--bg), var(--bg-2));
|
linear-gradient(180deg, var(--bg), var(--bg-2));
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -59,6 +86,14 @@
|
|||||||
background: var(--online); opacity: .5; animation: ping 1.6s cubic-bezier(0,0,.2,1) infinite;
|
background: var(--online); opacity: .5; animation: ping 1.6s cubic-bezier(0,0,.2,1) infinite;
|
||||||
}
|
}
|
||||||
@keyframes ping { 0% { transform: scale(1); opacity: .5; } 75%, 100% { transform: scale(2.4); opacity: 0; } }
|
@keyframes ping { 0% { transform: scale(1); opacity: .5; } 75%, 100% { transform: scale(2.4); opacity: 0; } }
|
||||||
|
.icon-btn {
|
||||||
|
font: inherit; font-size: 14px; line-height: 1; cursor: pointer; color: var(--text);
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 30px; height: 30px; border-radius: 999px;
|
||||||
|
background: var(--card); border: 1px solid var(--card-border);
|
||||||
|
transition: border-color .15s, transform .15s;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { border-color: var(--accent); transform: translateY(-1px); }
|
||||||
|
|
||||||
header { text-align: center; margin-bottom: 26px; }
|
header { text-align: center; margin-bottom: 26px; }
|
||||||
h1 {
|
h1 {
|
||||||
@@ -100,7 +135,7 @@
|
|||||||
.row { display: flex; gap: 12px; flex-wrap: wrap; }
|
.row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
input, textarea {
|
input, textarea {
|
||||||
width: 100%; font: inherit; color: var(--text);
|
width: 100%; font: inherit; color: var(--text);
|
||||||
background: rgba(0, 0, 0, 0.25); border: 1px solid var(--card-border);
|
background: var(--input-bg); border: 1px solid var(--card-border);
|
||||||
border-radius: 12px; padding: 13px 15px; outline: none; resize: vertical;
|
border-radius: 12px; padding: 13px 15px; outline: none; resize: vertical;
|
||||||
transition: border-color .15s, box-shadow .15s;
|
transition: border-color .15s, box-shadow .15s;
|
||||||
}
|
}
|
||||||
@@ -145,7 +180,7 @@
|
|||||||
.entry .when { color: var(--muted); }
|
.entry .when { color: var(--muted); }
|
||||||
.entry .via {
|
.entry .via {
|
||||||
margin-left: auto; font-size: 11.5px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
margin-left: auto; font-size: 11.5px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
background: rgba(0,0,0,.2); border: 1px solid var(--card-border); padding: 2px 8px; border-radius: 999px;
|
background: var(--via-bg); border: 1px solid var(--card-border); padding: 2px 8px; border-radius: 999px;
|
||||||
max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
}
|
}
|
||||||
li.entry.pinned { border-color: rgba(139, 92, 246, 0.55); background: rgba(139, 92, 246, 0.08); }
|
li.entry.pinned { border-color: rgba(139, 92, 246, 0.55); background: rgba(139, 92, 246, 0.08); }
|
||||||
@@ -154,6 +189,21 @@
|
|||||||
text-transform: uppercase; color: var(--accent-2);
|
text-transform: uppercase; color: var(--accent-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entry .reacts { display: flex; gap: 6px; margin-top: 12px; flex-wrap: wrap; }
|
||||||
|
.react {
|
||||||
|
font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; color: var(--muted);
|
||||||
|
display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 999px;
|
||||||
|
background: var(--card); border: 1px solid var(--card-border);
|
||||||
|
transition: border-color .15s, color .15s, transform .1s;
|
||||||
|
}
|
||||||
|
.react:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }
|
||||||
|
.react:active:not(:disabled) { transform: scale(.93); }
|
||||||
|
.react.on { color: var(--accent-2); border-color: rgba(139, 92, 246, .55); background: rgba(139, 92, 246, .1); }
|
||||||
|
.react:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
.react .n { font-variant-numeric: tabular-nums; }
|
||||||
|
.react.bump .n { animation: bump .3s ease; }
|
||||||
|
@keyframes bump { 40% { transform: scale(1.4); } }
|
||||||
|
|
||||||
.banner {
|
.banner {
|
||||||
display: flex; gap: 10px; align-items: flex-start; padding: 13px 18px; margin-bottom: 18px;
|
display: flex; gap: 10px; align-items: flex-start; padding: 13px 18px; margin-bottom: 18px;
|
||||||
border-color: rgba(245, 158, 11, 0.45); background: rgba(245, 158, 11, 0.1); font-size: 14.5px; line-height: 1.5;
|
border-color: rgba(245, 158, 11, 0.45); background: rgba(245, 158, 11, 0.1); font-size: 14.5px; line-height: 1.5;
|
||||||
@@ -165,6 +215,10 @@
|
|||||||
.paused b { color: var(--text); }
|
.paused b { color: var(--text); }
|
||||||
.replicas { font-size: 12.5px; color: var(--muted); padding: 5px 12px; border-radius: 999px;
|
.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; }
|
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; }
|
.empty, .loading { text-align: center; color: var(--muted); padding: 40px 20px; font-size: 15px; }
|
||||||
|
|
||||||
@@ -207,7 +261,9 @@
|
|||||||
<div class="status">
|
<div class="status">
|
||||||
<span class="ver" id="ver" title="Serving version">v…</span>
|
<span class="ver" id="ver" title="Serving version">v…</span>
|
||||||
<span class="replicas" id="replicas" title="Running replicas" hidden></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -229,6 +285,8 @@
|
|||||||
<div class="clock-time" id="clockTime">—</div>
|
<div class="clock-time" id="clockTime">—</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="clock-meta" id="clockMeta">waiting for server…</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>
|
</div>
|
||||||
|
|
||||||
<!-- Home view -->
|
<!-- Home view -->
|
||||||
@@ -296,12 +354,17 @@
|
|||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
info: () => fetch('/api/info').then(r => r.json()),
|
info: () => fetch('/api/info').then(r => r.json()),
|
||||||
|
me: () => fetch('/api/me').then(r => r.json()),
|
||||||
time: () => fetch('/api/time').then(r => r.json()),
|
time: () => fetch('/api/time').then(r => r.json()),
|
||||||
settings: () => fetch('/api/settings').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()),
|
list: (limit, offset) => fetch(`/api/entries?limit=${limit}&offset=${offset}`).then(r => r.json()),
|
||||||
create: (body) => fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
|
create: (body) => fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
|
||||||
|
react: (id, emoji) => fetch(`/api/entries/${id}/reactions?emoji=${emoji}`, { method: 'POST' }),
|
||||||
|
unreact:(id, emoji) => fetch(`/api/entries/${id}/reactions?emoji=${emoji}`, { method: 'DELETE' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMOJI = { heart: '❤️', tada: '🎉', thumbsup: '👍', smile: '😄' };
|
||||||
|
|
||||||
let total = 0; // total signatures (kept in sync via SSE)
|
let total = 0; // total signatures (kept in sync via SSE)
|
||||||
let allOffset = 0; // current offset on the "All" page
|
let allOffset = 0; // current offset on the "All" page
|
||||||
|
|
||||||
@@ -325,6 +388,23 @@
|
|||||||
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 3200);
|
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 3200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- appearance (light / dark) -----------------------------------------
|
||||||
|
// Priority: moderator-forced theme > visitor's own choice > OS preference.
|
||||||
|
let serverTheme = '';
|
||||||
|
function applyTheme() {
|
||||||
|
const local = localStorage.getItem('gb-theme');
|
||||||
|
const mode = serverTheme || local || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||||
|
document.body.classList.toggle('light', mode === 'light');
|
||||||
|
}
|
||||||
|
$('themeBtn').addEventListener('click', () => {
|
||||||
|
const next = document.body.classList.contains('light') ? 'dark' : 'light';
|
||||||
|
localStorage.setItem('gb-theme', next);
|
||||||
|
if (!serverTheme) applyTheme();
|
||||||
|
else toast(`The moderator forced the ${serverTheme} theme — your choice will apply when it's released`);
|
||||||
|
});
|
||||||
|
matchMedia('(prefers-color-scheme: light)').addEventListener('change', applyTheme);
|
||||||
|
applyTheme();
|
||||||
|
|
||||||
function cardHTML(e) {
|
function cardHTML(e) {
|
||||||
const via = e.replica && e.replica !== 'seed'
|
const via = e.replica && e.replica !== 'seed'
|
||||||
? `<span class="via" title="Accepted by replica ${esc(e.replica)}">via ${esc(e.replica)}</span>` : '';
|
? `<span class="via" title="Accepted by replica ${esc(e.replica)}">via ${esc(e.replica)}</span>` : '';
|
||||||
@@ -337,9 +417,56 @@
|
|||||||
<span class="when">· ${timeAgo(e.created_at)}</span>
|
<span class="when">· ${timeAgo(e.created_at)}</span>
|
||||||
${via}
|
${via}
|
||||||
</div>
|
</div>
|
||||||
|
${reactsHTML(e)}
|
||||||
</li>`;
|
</li>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- reactions ----------------------------------------------------------
|
||||||
|
// One tap adds the browser's reaction, tapping again removes it. The own
|
||||||
|
// choices are kept in localStorage; the authoritative tally comes from the
|
||||||
|
// server (list payloads + live "reaction" SSE events).
|
||||||
|
function myReactions() { try { return JSON.parse(localStorage.getItem('gb-reacts') || '{}'); } catch { return {}; } }
|
||||||
|
function saveMyReaction(id, emoji, on) {
|
||||||
|
const m = myReactions();
|
||||||
|
if (on) m[id + ':' + emoji] = 1; else delete m[id + ':' + emoji];
|
||||||
|
localStorage.setItem('gb-reacts', JSON.stringify(m));
|
||||||
|
}
|
||||||
|
function reactsHTML(e) {
|
||||||
|
const mine = myReactions();
|
||||||
|
const counts = e.reactions || {};
|
||||||
|
const chips = Object.keys(EMOJI).map((k) => {
|
||||||
|
const n = counts[k] || 0;
|
||||||
|
const on = mine[e.id + ':' + k] ? ' on' : '';
|
||||||
|
return `<button class="react${on}" data-emoji="${k}" title="React ${EMOJI[k]}">${EMOJI[k]} <span class="n">${n}</span></button>`;
|
||||||
|
}).join('');
|
||||||
|
return `<div class="reacts">${chips}</div>`;
|
||||||
|
}
|
||||||
|
function updateReaction(id, emoji, count) {
|
||||||
|
document.querySelectorAll(`.entry[data-id="${id}"]`).forEach((li) => {
|
||||||
|
const btn = li.querySelector(`.react[data-emoji="${emoji}"]`);
|
||||||
|
if (!btn) return;
|
||||||
|
btn.querySelector('.n').textContent = count;
|
||||||
|
btn.classList.toggle('on', !!myReactions()[id + ':' + emoji]);
|
||||||
|
btn.classList.remove('bump'); void btn.offsetWidth; btn.classList.add('bump');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('.react');
|
||||||
|
if (!btn || btn.disabled) return;
|
||||||
|
const li = btn.closest('.entry'); if (!li) return;
|
||||||
|
const id = li.dataset.id, emoji = btn.dataset.emoji;
|
||||||
|
const adding = !btn.classList.contains('on');
|
||||||
|
saveMyReaction(id, emoji, adding);
|
||||||
|
btn.classList.toggle('on', adding); // instant feedback; SSE brings the tally
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = adding ? await api.react(id, emoji) : await api.unreact(id, emoji);
|
||||||
|
if (!res.ok) saveMyReaction(id, emoji, !adding); // revert on failure
|
||||||
|
} catch { saveMyReaction(id, emoji, !adding); }
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.classList.toggle('on', !!myReactions()[id + ':' + emoji]);
|
||||||
|
});
|
||||||
|
|
||||||
function renderList(ul, loading, entries, emptyMsg) {
|
function renderList(ul, loading, entries, emptyMsg) {
|
||||||
loading.style.display = 'none';
|
loading.style.display = 'none';
|
||||||
if (!entries.length) { ul.innerHTML = `<div class="empty">${emptyMsg}</div>`; return; }
|
if (!entries.length) { ul.innerHTML = `<div class="empty">${emptyMsg}</div>`; return; }
|
||||||
@@ -367,6 +494,28 @@
|
|||||||
$('replicas').hidden = false;
|
$('replicas').hidden = false;
|
||||||
$('replicas').textContent = n === 1 ? '1 replica' : n + ' replicas';
|
$('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) {
|
function applySettings(st) {
|
||||||
const banner = (st && st.banner) || '';
|
const banner = (st && st.banner) || '';
|
||||||
$('banner').hidden = !banner;
|
$('banner').hidden = !banner;
|
||||||
@@ -374,6 +523,8 @@
|
|||||||
const paused = !!(st && st.signing_paused);
|
const paused = !!(st && st.signing_paused);
|
||||||
$('paused').hidden = !paused;
|
$('paused').hidden = !paused;
|
||||||
$('form').hidden = paused;
|
$('form').hidden = paused;
|
||||||
|
serverTheme = (st && st.theme) || '';
|
||||||
|
applyTheme();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- views --------------------------------------------------------------
|
// ---- views --------------------------------------------------------------
|
||||||
@@ -464,15 +615,16 @@
|
|||||||
let connectedOnce = false;
|
let connectedOnce = false;
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
const es = new EventSource('/api/stream');
|
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('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); });
|
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('created', (e) => { const d = JSON.parse(e.data); if (d.entry && !d.entry.hidden) onCreated(d.entry); });
|
||||||
es.addEventListener('updated', () => reloadView());
|
es.addEventListener('updated', () => reloadView());
|
||||||
es.addEventListener('deleted', (e) => { const d = JSON.parse(e.data); onDeleted(d.id); });
|
es.addEventListener('deleted', (e) => { const d = JSON.parse(e.data); onDeleted(d.id); });
|
||||||
|
es.addEventListener('reaction', (e) => { const d = JSON.parse(e.data); updateReaction(d.id, d.emoji, d.count); });
|
||||||
es.addEventListener('settings', (e) => { const d = JSON.parse(e.data); applySettings(d.settings); });
|
es.addEventListener('settings', (e) => { const d = JSON.parse(e.data); applySettings(d.settings); });
|
||||||
// Resync after a reconnect: events sent while disconnected were missed.
|
// 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('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.
|
// EventSource auto-reconnects on error; nothing to do here.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,10 +650,11 @@
|
|||||||
|
|
||||||
// ---- boot ---------------------------------------------------------------
|
// ---- boot ---------------------------------------------------------------
|
||||||
api.info().then((d) => {
|
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;
|
$('adminLink').hidden = !d.admin;
|
||||||
if (d.broker) { $('brokerPill').hidden = false; $('brokerPill').textContent = d.broker === 'redis' ? 'Redis pub/sub' : 'In-process broker'; }
|
if (d.broker) { $('brokerPill').hidden = false; $('brokerPill').textContent = d.broker === 'redis' ? 'Redis pub/sub' : 'In-process broker'; }
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
api.me().then(showVisitor).catch(() => {});
|
||||||
api.settings().then(applySettings).catch(() => {});
|
api.settings().then(applySettings).catch(() => {});
|
||||||
api.time().then(showTime).catch(() => {}); // immediate clock; SSE ticks keep it live
|
api.time().then(showTime).catch(() => {}); // immediate clock; SSE ticks keep it live
|
||||||
route();
|
route();
|
||||||
|
|||||||
Reference in New Issue
Block a user