forked from Mike/freno-dev
Gap 1 — click and served share one label: trackDownload now takes a
stage ("click" | "served") carried on a new VisitorAnalytics.stage
column; the Nook door and the tRPC proxy record clicks, the DMG file
route records served. summariseDownloads() and the /analytics downloads
block are untouched — the stage is a separate column, so the path
grouping cannot change.
Gap 2 — checkout start records nothing: the checkout route records a
created Stripe session through the shared trackRequest writer (same
referrer, campaign, country dimensions as every door) under its own
path, and STAGES.checkout reads it instead of the /checkout page proxy.
Gap 3 — loopback addresses, no host dimension: every writer now
resolves the client address through resolveClientIp, which trusts
x-forwarded-for only when the direct peer is the loopback production
proxy (trust boundary stated in request-client.ts), and records the
host on each row through the same site-context extractor routing uses.
stage and host reach the table through VISITOR_ANALYTICS_ADDED_COLUMNS;
the weekly job applies the same bootstrap before reading so it renders
seven stages against a database no deploy has migrated yet.
The weekly read splits downloadClick/dmgServed, scopes visits to
nook.freno.me, and computes its caveats from the window: pre-instrumentation
rows are stated and excluded, and the loopback warning now fires only
when rows recorded since the instrumentation still carry it.
135 lines
3.8 KiB
TypeScript
135 lines
3.8 KiB
TypeScript
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
|
|
* This endpoint is used by Sparkle updater to download updates
|
|
*
|
|
* Handles:
|
|
* - Full DMG files: /api/downloads/Gaze-0.2.2.dmg, /api/downloads/InputHalo-0.1.0.dmg
|
|
* - Delta updates: /api/downloads/Gaze3-2.delta, /api/downloads/Nook17-9.delta
|
|
*
|
|
* The Nook delta files are named after the app bundle ("Nook"), not the DMG
|
|
* ("TheNook"), so both prefixes must be allowed (NOOK-337).
|
|
*
|
|
* URL: https://freno.me/api/downloads/[filename]
|
|
*/
|
|
export async function GET(event: APIEvent) {
|
|
const filename = event.params.filename;
|
|
|
|
if (!filename) {
|
|
return new Response("Filename required", {
|
|
status: 400,
|
|
headers: {
|
|
"Content-Type": "text/plain"
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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 (
|
|
!asset ||
|
|
(!filename.endsWith(".dmg") &&
|
|
!filename.endsWith(".delta") &&
|
|
!filename.endsWith(".zip"))
|
|
) {
|
|
return new Response("Invalid file format", {
|
|
status: 400,
|
|
headers: {
|
|
"Content-Type": "text/plain"
|
|
}
|
|
});
|
|
}
|
|
|
|
const bucket = env.VITE_DOWNLOAD_BUCKET_STRING;
|
|
const key = `downloads/${filename}`;
|
|
|
|
const credentials = {
|
|
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
|
secretAccessKey: env.MY_AWS_SECRET_KEY
|
|
};
|
|
|
|
try {
|
|
const client = new S3Client({
|
|
region: env.AWS_REGION,
|
|
credentials: credentials
|
|
});
|
|
|
|
const command = new GetObjectCommand({
|
|
Bucket: bucket,
|
|
Key: key
|
|
});
|
|
|
|
const response = await client.send(command);
|
|
|
|
if (!response.Body) {
|
|
console.error(`File not found in S3: ${key}`);
|
|
return new Response("File not found", {
|
|
status: 404,
|
|
headers: {
|
|
"Content-Type": "text/plain"
|
|
}
|
|
});
|
|
}
|
|
|
|
const contentType = filename.endsWith(".zip")
|
|
? "application/zip"
|
|
: filename.endsWith(".dmg")
|
|
? "application/x-apple-diskimage"
|
|
: "application/octet-stream";
|
|
|
|
const body = await response.Body.transformToByteArray();
|
|
|
|
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
|
|
|
|
await trackDownload(event, assetKeys[asset]!, "served");
|
|
|
|
// 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": "*"
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error(`Failed to fetch ${filename} from S3:`, error);
|
|
|
|
if (error instanceof Error && error.name === "NoSuchKey") {
|
|
return new Response("File not found in storage", {
|
|
status: 404,
|
|
headers: {
|
|
"Content-Type": "text/plain"
|
|
}
|
|
});
|
|
}
|
|
|
|
return new Response("Internal Server Error", {
|
|
status: 500,
|
|
headers: {
|
|
"Content-Type": "text/plain"
|
|
}
|
|
});
|
|
}
|
|
}
|