downloads: track every door and surface them on /analytics

The analytics page could not answer "how many downloads, of which
product". Three of the four download doors recorded nothing: the tRPC
click proxy only left the generic request row, whose path is the raw URL
with the asset buried in `?batch=1&input={"asset_name":...}`, and the S3
file route that serves Sparkle updates and the Nook app logged nothing
at all. The one door that did log (the Nook redirect) kept a private copy
of the logVisit/enrich/flush sequence, under its own path.

All doors now go through trackDownload, which writes one row per event
under /api/downloads/<asset> and flushes it inside the request — a click
is low-volume and serverless is not guaranteed to run the 30s batch
timer after the response. The file route maps its prefix allowlist to
asset keys, so a version bump stays one asset rather than one per
release. getAnalyticsSummary groups those rows into a downloads block and
the freno.me panel renders it under the overview cards.

History is recovered to the degree it exists: the Nook door's rows and
the tRPC clicks still carry enough to derive from (asset name in the URL).
scripts/backfill-downloads.ts inserts those derived rows with a
`backfill-<source id>` primary key and INSERT OR IGNORE, leaving the
originals untouched, so re-running is a no-op. Run against the live table
it added 66 rows: gaze 42, thenook 19, inputhalo 4, lineage 1.

Tests: new backfill suite (derivation, visitor detail carried over,
idempotency, empty history) and summariseDownloads cases; the Nook route
test now pins the canonical path, and the NOOK-337 allowlist audit reads
the map keys instead of quoted strings. Red-checked by stubbing the
downloads block back to zeros — the summary case fails, then passes.
Full suite matches master.

Co-Authored-By: nw-flash <noreply@nw-flash.local>
MISC-48
This commit is contained in:
2026-10-22 12:59:28 -04:00
parent 0bf15e528d
commit a524f23be2
12 changed files with 495 additions and 58 deletions

View File

@@ -202,6 +202,32 @@ function FrenomePanel(props: FrenomePanelProps) {
/>
</div>
{/* Downloads — every door logs one row at /api/downloads/<asset> */}
<Show when={data.downloads.total > 0}>
<div class="bg-surface0 border-surface1 mb-8 rounded-lg border shadow">
<div class="border-surface1 border-b p-6">
<h2 class="text-text text-2xl font-bold">Downloads</h2>
<p class="text-subtext0 mt-1 text-sm">
{formatNumber(data.downloads.total)} events in the last
{props.days === 1 ? " 24 hours" : ` ${props.days} days`} —
landing page clicks and files served, by product
</p>
</div>
<div class="space-y-3 p-6">
<For each={data.downloads.byAsset}>
{(download) => (
<StatBar
label={download.asset}
count={download.count}
total={data.downloads.total}
color="bg-teal-600"
/>
)}
</For>
</div>
</div>
</Show>
{/* Performance */}
<Show when={performanceStats()} keyed>
{(ps) => (

View File

@@ -1,6 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { env } from "~/env/server";
import { trackDownload } from "~/server/track-download";
/**
* Serves macOS app DMG files and delta updates from S3
@@ -27,10 +28,20 @@ export async function GET(event: APIEvent) {
});
}
const validPrefixes = ["Gaze", "InputHalo", "TheNook", "Nook"];
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
// Prefix → canonical asset key, so a version bump stays one asset in
// analytics rather than one per release.
const assetKeys: Record<string, string> = {
Gaze: "gaze",
InputHalo: "inputhalo",
TheNook: "thenook",
Nook: "thenook"
};
const asset = Object.keys(assetKeys).find((prefix) =>
filename.startsWith(prefix)
);
if (
!isValidPrefix ||
!asset ||
(!filename.endsWith(".dmg") &&
!filename.endsWith(".delta") &&
!filename.endsWith(".zip"))
@@ -84,13 +95,20 @@ export async function GET(event: APIEvent) {
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
return new Response(body, {
await trackDownload(event, assetKeys[asset]!);
// SAFETY: `transformToByteArray` yields `Uint8Array<ArrayBufferLike>`, which the
// DOM lib's `BufferSource` (ArrayBuffer-backed) does not accept, though undici
// takes it as-is at runtime. State the intent at the one boundary it hits.
return new Response(body as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": body.length.toString(),
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
// Deliberately open: these are public installer files with no cookies or
// credentials, fetched by Sparkle and by direct browser navigation.
"Access-Control-Allow-Origin": "*"
}
});

View File

@@ -19,14 +19,24 @@ const SOURCE = readFileSync(
"utf8"
);
/**
* The allowlist moved from an array to a `prefix → asset key` map when download
* tracking was added, so read the keys out of the map rather than matching
* quoted strings.
*/
const ALLOWLIST_KEYS = [
...(/const assetKeys: Record<string, string> = \{([^}]*)\}/.exec(SOURCE)?.[1] ?? "")
.matchAll(/"?([A-Za-z]+)"?\s*:/g)
].map((match) => match[1]);
describe("Downloads proxy filename allowlist (NOOK-337)", () => {
it("allows the Nook app-bundle prefix used by Sparkle delta names", () => {
expect(SOURCE).toContain('"Nook"');
expect(ALLOWLIST_KEYS).toContain("Nook");
});
it("still allows every previously served prefix", () => {
for (const prefix of ["Gaze", "InputHalo", "TheNook"]) {
expect(SOURCE).toContain(`"${prefix}"`);
expect(ALLOWLIST_KEYS).toContain(prefix);
}
});

View File

@@ -111,7 +111,7 @@ describe("GET /api/the-nook/download", () => {
expect(logged.length).toBe(1);
expect(logged[0]).toMatchObject({
path: "/api/the-nook/download",
path: "/api/downloads/thenook",
method: "GET",
referrer: "https://nook.freno.me/"
});

View File

@@ -1,7 +1,5 @@
import type { APIEvent } from "@solidjs/start/server";
import { getRequestIP } from "vinxi/http";
import { logVisit, enrichAnalyticsEntry, flushAnalytics } from "~/server/analytics";
import { parseUtm } from "~/server/utm";
import { trackDownload } from "~/server/track-download";
import { resolveLatestDownload } from "./_download";
/**
@@ -15,13 +13,11 @@ import { resolveLatestDownload } from "./_download";
* Why a redirect and not a direct S3 link: the appcast route and the DMG
* route never logged anything, so step 2 of the funnel (download click →
* DMG served) was invisible. Every click now lands one row in
* VisitorAnalytics under the path below, which is what `topApiCalls`
* already surfaces.
* VisitorAnalytics under `/api/downloads/thenook`, alongside the clicks and
* served files of every other product — see `~/server/track-download`.
*
* Country is read from the edge headers Vercel/Cloudflare set; the
* analytics buffer carries a `country` column that nothing populated
* until now. `?c=` is accepted as an override for previews and manual
* verification.
* Referrer, campaign tags, edge country (with `?c=` override) and user agent
* are read by `trackDownload`, which is shared with the other download doors.
*
* ONLY the handler is exported here: Vinxi's route tree-shake deletes
* exported declarations that are not in the router's `pick=` list, so a
@@ -29,47 +25,10 @@ import { resolveLatestDownload } from "./_download";
* referenced it. All resolution helpers live in `./_download.ts`.
*/
function firstHeader(request: Request, ...names: string[]): string | null {
for (const name of names) {
const value = request.headers.get(name);
if (value) return value;
}
return null;
}
export async function GET(event: APIEvent): Promise<Response> {
const { request } = event;
const url = new URL(request.url);
const forwarded = firstHeader(request, "x-forwarded-for");
const ipAddress =
getRequestIP(event.nativeEvent) ??
(forwarded ? forwarded.split(",")[0]!.trim() : null);
// Best-effort: a failed analytics write must never block a download.
// Flushed (not merely buffered) because a download click is low-volume and
// the batch timer is not guaranteed to survive the response on serverless.
try {
const referrer = firstHeader(request, "referer", "referrer");
await logVisit(
enrichAnalyticsEntry({
path: "/api/the-nook/download",
method: "GET",
referrer,
userAgent: firstHeader(request, "user-agent"),
ipAddress,
// The click carries its own tags when a campaign links straight here;
// otherwise the landing page's tags ride along in the referer.
...parseUtm(request.url, referrer),
country:
url.searchParams.get("c") ??
firstHeader(request, "x-vercel-ip-country", "cf-ipcountry")
})
);
await flushAnalytics();
} catch (error) {
console.error("Failed to log download click:", error);
}
// Recorded before resolving: a click that dies at resolution is exactly the
// failure the funnel needs to show.
await trackDownload(event, "thenook");
let target: string | null = null;
try {