forked from Mike/freno-dev
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:
@@ -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) => (
|
||||
|
||||
@@ -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": "*"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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/"
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user