ReviewOS

stacks/analyticshq

[feature] Embeddable public stats widgets

#26
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Gap

Public sharing is the full dashboard via ?share=<token> only (routes/analytics.ts:499-543). No embeddable badge/chart. Simple Analytics ships embeddable widgets.

Proposed

Embeddable, read-only widgets (visitor badge, mini time-series) backed by a share token — good for "open stats" pages.

Priority: P2.


From the 2026-07-20 privacy-first competitive audit (vs. Plausible / Fathom / Simple Analytics / Umami).

9 days ago

Shipped in 3a8aad5.

What landed

  • GET /public/{siteId}/badge.svg?token=… — visitor/views badge, self-contained SVG
  • GET /public/{siteId}/sparkline.svg?token=… — mini time-series
  • GET /api/public/{siteId}/summary?token=… — JSON totals + daily series, for custom embeds
  • POST/DELETE /api/sites/{siteId}/widget — mint/rotate/revoke, admin-only, returns a ready-to-paste <img> snippet

These are the only routes in the app besides /collect without .middleware('auth'), so two decisions carry the whole feature.

1. The widget token is not the dashboard share token

The issue proposed widgets "backed by a share token". I deliberately didn't do that.

A badge lives in an <img src> in public page source — its token is public by construction; that's what a badge is. Reusing share_token would mean embedding a visitor count silently publishes the entire dashboard — every path, referrer and country — to anyone who views source. Whoever added the badge would have no reason to suspect it.

So they're separate secrets, minted, rotated and revoked independently. Verified against real Postgres: the share token does not open the widget, and revoking the widget token leaves the share link working.

2. A widget exposes totals and a daily series, and no dimensions

Top paths on a public endpoint leak internal and unreleased URLs — /admin/project-titan, /blog/the-post-we-have-not-announced. Countries and referrers are the same problem in another column.

There's also no filter parameter, which is what keeps this surface clear of the re-identification issue I raised on #23: the k-anonymity question doesn't arise here, because the capability that creates it is absent rather than guarded. That's the better answer where it's available.

Supporting details: the lookback is capped at 365 days (an unbounded days on a public, cacheable endpoint is a free full-table scan for anyone holding the URL); missing site, missing token and wrong token all answer identically, so a public site id can't be used to enumerate which sites have widgets enabled; and token comparison is constant-time over hashes, so both buffers are always 32 bytes — timingSafeEqual throws on a length mismatch and catching that would itself be a length oracle.

The badge is self-contained — no font, no stylesheet, no script, nothing fetched. An <img> wouldn't run them anyway, and depending on one means a badge that breaks silently on a page we don't control. The label goes through an allowlist rather than an escape, since the safe set inside cross-origin SVG text is small and easy to state where the set needing escapes is neither. Counts round down: "1M visitors" at 999,999 is the kind of small lie that gets screenshotted.

Verification. 16 checks against real Postgres. Five sabotages fail by name: reusing the share token for widgets, exposing top paths publicly, plain === token comparison, an unbounded lookback, and dropping the label sanitiser. 302 tests pass.

Worth recording that three of my own test assertions were wrong before they were right — not.toContain('http://') failed on the required SVG namespace, not.toContain('</text><') failed on the badge's own valid markup, and not.toContain('segment') failed on the comment explaining that widgets have no segment filter. All three now assert the actual property rather than a proxy for it.

Deliberately not included

  • No UI for minting the token; the endpoints are API-only like the other features shipped this round.
  • No iframe widget page. The badge and sparkline are images, which is what an "open stats" page actually embeds, and an iframe would add a script-bearing surface on someone else's origin for no gain.

Sign in to comment on this issue.