diff --git a/README.md b/README.md index 6177f4c..daa56b7 100644 --- a/README.md +++ b/README.md @@ -31,3 +31,28 @@ Cloudflare Pages owns production and preview deployment. The project is `scientf Do not deploy production from a feature branch or store Cloudflare credentials in this repository. See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution workflow. The cross-repository operating model is maintained in [`ScientFactory/Scient`](https://github.com/ScientFactory/Scient). + +## First-party event measurement + +Cloudflare D1 stores four anonymous event types: + +- `page_viewed` +- `download_clicked` +- `download_failed` +- `outbound_link_clicked` + +The implementation intentionally stores no IP address, cookie, fingerprint, referrer, or persistent visitor identifier. Counts therefore represent events rather than unique visitors. `download_failed` is limited to a failure in ScientFactory's redirect service; the website cannot observe a transfer failure after GitHub begins serving an installer. + +The production binding is `DOWNLOAD_DB`, backed by the `scientfactory-downloads` D1 database. Apply new migrations before deploying code that depends on them: + +```sh +bun run db:migrate +``` + +To view the lifetime event summary plus 30-day download, outbound-link, and failure breakdowns: + +```sh +bun run analytics:report +``` + +Local and Cloudflare preview hosts do not write events, which keeps production counts free of development traffic. diff --git a/functions/_lib/events.ts b/functions/_lib/events.ts new file mode 100644 index 0000000..b2e7013 --- /dev/null +++ b/functions/_lib/events.ts @@ -0,0 +1,121 @@ +// FILE: events.ts +// Purpose: Stores a deliberately small, anonymous set of first-party website events. +// Layer: Cloudflare Pages Function utility + +export const SITE_EVENT_NAMES = [ + "page_viewed", + "download_clicked", + "download_failed", + "outbound_link_clicked", +] as const; + +export type SiteEventName = (typeof SITE_EVENT_NAMES)[number]; + +export interface SiteEvent { + readonly eventName: SiteEventName; + readonly pagePath?: string | null; + readonly assetKey?: string | null; + readonly releaseTag?: string | null; + readonly assetName?: string | null; + readonly destinationHost?: string | null; + readonly destinationPath?: string | null; + readonly failureStage?: string | null; + readonly failureReason?: string | null; +} + +export interface SiteEventContext { + readonly request: Request; + readonly env: { readonly DOWNLOAD_DB?: D1Database }; + waitUntil(promise: Promise): void; +} + +const INSERT_EVENT = ` + INSERT INTO site_events ( + event_name, + page_path, + asset_key, + release_tag, + asset_name, + destination_host, + destination_path, + failure_stage, + failure_reason + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +`; + +const PRODUCTION_HOSTS = new Set(["scientfactory.com", "www.scientfactory.com"]); + +function limited(value: string | null | undefined, maximum: number): string | null { + if (!value) return null; + return value.slice(0, maximum); +} + +export function cleanPath(value: string | null | undefined): string | null { + if (!value?.startsWith("/")) return null; + const path = value.split(/[?#]/, 1)[0] ?? "/"; + return limited(path, 512); +} + +export function destinationParts( + value: string, + siteOrigin: string, +): { + destinationHost: string; + destinationPath: string; +} | null { + try { + const destination = new URL(value); + if (destination.protocol !== "https:" && destination.protocol !== "http:") return null; + if (destination.origin === siteOrigin) return null; + + return { + destinationHost: destination.hostname.slice(0, 253), + destinationPath: cleanPath(destination.pathname) ?? "/", + }; + } catch { + return null; + } +} + +export function shouldPersistSiteEvents(request: Request): boolean { + return PRODUCTION_HOSTS.has(new URL(request.url).hostname); +} + +export async function insertSiteEvent(db: D1Database, event: SiteEvent): Promise { + await db + .prepare(INSERT_EVENT) + .bind( + event.eventName, + cleanPath(event.pagePath), + limited(event.assetKey, 64), + limited(event.releaseTag, 80), + limited(event.assetName, 255), + limited(event.destinationHost, 253), + cleanPath(event.destinationPath), + limited(event.failureStage, 80), + limited(event.failureReason, 120), + ) + .run(); +} + +export function queueSiteEvent(context: SiteEventContext, event: SiteEvent): void { + if (!shouldPersistSiteEvents(context.request)) return; + + const db = context.env.DOWNLOAD_DB; + if (!db) { + console.error(JSON.stringify({ message: "Site event database binding is missing" })); + return; + } + + const write = insertSiteEvent(db, event).catch((error: unknown) => { + console.error( + JSON.stringify({ + message: "Site event write failed", + eventName: event.eventName, + error: error instanceof Error ? error.message : String(error), + }), + ); + }); + + context.waitUntil(write); +} diff --git a/functions/api/download/[asset].test.ts b/functions/api/download/[asset].test.ts new file mode 100644 index 0000000..68de73a --- /dev/null +++ b/functions/api/download/[asset].test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { onRequestGet, onRequestHead } from "./[asset]"; + +const releaseFixture = { + tag_name: "v0.5.7", + name: "Scient v0.5.7", + html_url: "https://github.com/ScientFactory/scient-desktop/releases/tag/v0.5.7", + published_at: "2026-07-20T00:00:00Z", + prerelease: false, + assets: [ + { + name: "Scient-0.5.7-arm64.dmg", + browser_download_url: + "https://github.com/ScientFactory/scient-desktop/releases/download/v0.5.7/Scient-0.5.7-arm64.dmg", + content_type: "application/x-apple-diskimage", + size: 125_000_000, + }, + ], +}; + +function createDatabase(run = vi.fn().mockResolvedValue({ success: true })) { + const bind = vi.fn(() => ({ run })); + const prepare = vi.fn(() => ({ bind })); + return { database: { prepare } as unknown as D1Database, prepare, bind, run }; +} + +function createContext( + asset = "macArm64", + database = createDatabase().database, +): Parameters[0] & { waitUntil: ReturnType } { + return { + request: new Request(`https://scientfactory.com/api/download/${asset}`), + env: { DOWNLOAD_DB: database }, + params: { asset }, + data: undefined, + functionPath: "api/download/[asset]", + next: vi.fn(), + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + } as unknown as Parameters[0] & { waitUntil: ReturnType }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("tracked download redirect", () => { + it("records a click and redirects to the official installer", async () => { + const db = createDatabase(); + const context = createContext("macArm64", db.database); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(releaseFixture))); + + const response = await onRequestGet(context); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe(releaseFixture.assets[0]?.browser_download_url); + expect(context.waitUntil).toHaveBeenCalledTimes(1); + await context.waitUntil.mock.calls[0]?.[0]; + expect(db.bind).toHaveBeenCalledWith( + "download_clicked", + "/download", + "macArm64", + "v0.5.7", + "Scient-0.5.7-arm64.dmg", + "github.com", + "/ScientFactory/scient-desktop/releases/download/v0.5.7/Scient-0.5.7-arm64.dmg", + null, + null, + ); + }); + + it("does not let a database failure block a valid redirect", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const db = createDatabase(vi.fn().mockRejectedValue(new Error("database unavailable"))); + const context = createContext("macArm64", db.database); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(releaseFixture))); + + const response = await onRequestGet(context); + + expect(response.status).toBe(302); + await context.waitUntil.mock.calls[0]?.[0]; + }); + + it("records a ScientFactory-side failure when resolution fails", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const db = createDatabase(); + const context = createContext("macArm64", db.database); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 503 })), + ); + + const response = await onRequestGet(context); + + expect(response.status).toBe(503); + await context.waitUntil.mock.calls[0]?.[0]; + expect(db.bind).toHaveBeenCalledWith( + "download_failed", + "/download", + "macArm64", + null, + null, + null, + null, + "release_fetch", + "upstream_unavailable", + ); + }); + + it("rejects unknown asset keys before contacting GitHub", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const response = await onRequestGet(createContext("unknown")); + + expect(response.status).toBe(404); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("supports an untracked HEAD verification", async () => { + const context = createContext(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(releaseFixture))); + + const response = await onRequestHead(context); + + expect(response.status).toBe(302); + expect(context.waitUntil).not.toHaveBeenCalled(); + }); +}); diff --git a/functions/api/download/[asset].ts b/functions/api/download/[asset].ts new file mode 100644 index 0000000..9dbc24b --- /dev/null +++ b/functions/api/download/[asset].ts @@ -0,0 +1,174 @@ +// FILE: [asset].ts +// Purpose: Counts a download intent, then redirects to the matching official GitHub installer. +// Layer: Cloudflare Pages Function + +import { findDownloadAsset, type DownloadAssetKey } from "../../../src/lib/download-assets"; +import { parseRelease, type Release, type ReleaseAsset } from "../../../src/lib/release-schema"; +import { queueSiteEvent } from "../../_lib/events"; + +const GITHUB_RELEASE_URL = + "https://api.github.com/repos/ScientFactory/scient-desktop/releases/latest"; +const DOWNLOAD_ASSET_KEYS = new Set([ + "macArm64", + "macX64", + "windowsX64", + "linuxX64", +]); + +class DownloadResolutionError extends Error { + constructor( + readonly stage: string, + readonly reason: string, + ) { + super(reason); + } +} + +function assetKeyFromContext(context: EventContext) { + const value = context.params.asset; + if (typeof value !== "string" || !DOWNLOAD_ASSET_KEYS.has(value as DownloadAssetKey)) { + return null; + } + return value as DownloadAssetKey; +} + +function isOfficialDownload(asset: ReleaseAsset): boolean { + try { + const destination = new URL(asset.browser_download_url); + return ( + destination.protocol === "https:" && + destination.hostname === "github.com" && + destination.pathname.startsWith("/ScientFactory/scient-desktop/releases/download/") + ); + } catch { + return false; + } +} + +async function resolveDownload(key: DownloadAssetKey): Promise<{ + readonly release: Release; + readonly asset: ReleaseAsset; +}> { + let upstream: Response; + try { + upstream = await fetch(GITHUB_RELEASE_URL, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "ScientFactory-download-service", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + } catch { + throw new DownloadResolutionError("release_fetch", "upstream_request_failed"); + } + + if (!upstream.ok) { + throw new DownloadResolutionError("release_fetch", "upstream_unavailable"); + } + + let release: Release; + try { + release = parseRelease(await upstream.json()); + } catch { + throw new DownloadResolutionError("release_validation", "release_metadata_invalid"); + } + + const asset = findDownloadAsset(release, key); + if (!asset) { + throw new DownloadResolutionError("asset_resolution", "installer_not_found"); + } + if (!isOfficialDownload(asset)) { + throw new DownloadResolutionError("destination_validation", "installer_url_rejected"); + } + + return { release, asset }; +} + +function redirectResponse(asset: ReleaseAsset): Response { + return new Response(null, { + status: 302, + headers: { + "Cache-Control": "no-store", + Location: asset.browser_download_url, + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "X-Robots-Tag": "noindex, nofollow", + }, + }); +} + +function unavailableResponse(): Response { + return Response.json( + { + error: + "This installer is temporarily unavailable. Please return to the download page and try again.", + }, + { + status: 503, + headers: { + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + "Retry-After": "60", + "X-Content-Type-Options": "nosniff", + "X-Robots-Tag": "noindex, nofollow", + }, + }, + ); +} + +async function handleDownload( + context: EventContext, + track: boolean, +): Promise { + const key = assetKeyFromContext(context); + if (!key) return new Response("Not found", { status: 404 }); + + try { + const { release, asset } = await resolveDownload(key); + if (track) { + const destination = new URL(asset.browser_download_url); + queueSiteEvent(context, { + eventName: "download_clicked", + pagePath: "/download", + assetKey: key, + releaseTag: release.tag_name, + assetName: asset.name, + destinationHost: destination.hostname, + destinationPath: destination.pathname, + }); + } + return redirectResponse(asset); + } catch (error) { + const failure = + error instanceof DownloadResolutionError + ? error + : new DownloadResolutionError("download_resolution", "unexpected_failure"); + + if (track) { + queueSiteEvent(context, { + eventName: "download_failed", + pagePath: "/download", + assetKey: key, + failureStage: failure.stage, + failureReason: failure.reason, + }); + } + + console.error( + JSON.stringify({ + message: "Download redirect could not be resolved", + assetKey: key, + stage: failure.stage, + reason: failure.reason, + }), + ); + return unavailableResponse(); + } +} + +export const onRequestGet: PagesFunction = (context) => + handleDownload(context, true); + +// Monitoring can verify the redirect target without adding a click to the product count. +export const onRequestHead: PagesFunction = (context) => + handleDownload(context, false); diff --git a/functions/api/events.test.ts b/functions/api/events.test.ts new file mode 100644 index 0000000..1feed11 --- /dev/null +++ b/functions/api/events.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { onRequestPost } from "./events"; + +function createDatabase() { + const run = vi.fn().mockResolvedValue({ success: true }); + const bind = vi.fn(() => ({ run })); + const prepare = vi.fn(() => ({ bind })); + return { database: { prepare } as unknown as D1Database, bind, prepare, run }; +} + +function createContext( + body: unknown, + options?: { readonly origin?: string; readonly url?: string }, +) { + const database = createDatabase(); + const url = options?.url ?? "https://scientfactory.com/api/events"; + const request = new Request(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: options?.origin ?? new URL(url).origin, + }, + body: JSON.stringify(body), + }); + const context = { + request, + env: { DOWNLOAD_DB: database.database }, + waitUntil: vi.fn(), + } as unknown as Parameters[0] & { + waitUntil: ReturnType; + }; + return { context, database }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("browser event endpoint", () => { + it("records an anonymous page view", async () => { + const { context, database } = createContext({ + event_name: "page_viewed", + page_path: "/docs?private=not-stored", + }); + + const response = await onRequestPost(context); + + expect(response.status).toBe(204); + await context.waitUntil.mock.calls[0]?.[0]; + expect(database.bind).toHaveBeenCalledWith( + "page_viewed", + "/docs", + null, + null, + null, + null, + null, + null, + null, + ); + }); + + it("records only the host and path for an outbound link", async () => { + const { context, database } = createContext({ + event_name: "outbound_link_clicked", + page_path: "/about", + destination_url: "https://github.com/ScientFactory/scient-desktop?token=discarded#readme", + }); + + const response = await onRequestPost(context); + + expect(response.status).toBe(204); + await context.waitUntil.mock.calls[0]?.[0]; + expect(database.bind).toHaveBeenCalledWith( + "outbound_link_clicked", + "/about", + null, + null, + null, + "github.com", + "/ScientFactory/scient-desktop", + null, + null, + ); + }); + + it("rejects server-owned event names from browser payloads", async () => { + const { context, database } = createContext({ + event_name: "download_clicked", + page_path: "/download", + }); + + const response = await onRequestPost(context); + + expect(response.status).toBe(400); + expect(database.prepare).not.toHaveBeenCalled(); + }); + + it("rejects non-object JSON payloads", async () => { + const { context, database } = createContext(null); + + const response = await onRequestPost(context); + + expect(response.status).toBe(400); + expect(database.prepare).not.toHaveBeenCalled(); + }); + + it("rejects cross-origin submissions", async () => { + const { context, database } = createContext( + { event_name: "page_viewed", page_path: "/" }, + { origin: "https://attacker.example" }, + ); + + const response = await onRequestPost(context); + + expect(response.status).toBe(403); + expect(database.prepare).not.toHaveBeenCalled(); + }); + + it("does not pollute production data from local previews", async () => { + const { context, database } = createContext( + { event_name: "page_viewed", page_path: "/" }, + { url: "http://127.0.0.1:8788/api/events" }, + ); + + const response = await onRequestPost(context); + + expect(response.status).toBe(204); + expect(database.prepare).not.toHaveBeenCalled(); + }); +}); diff --git a/functions/api/events.ts b/functions/api/events.ts new file mode 100644 index 0000000..0bafca5 --- /dev/null +++ b/functions/api/events.ts @@ -0,0 +1,76 @@ +// FILE: events.ts +// Purpose: Accepts the two anonymous browser-originated website events. +// Layer: Cloudflare Pages Function + +import { cleanPath, destinationParts, queueSiteEvent, type SiteEvent } from "../_lib/events"; + +const MAX_BODY_BYTES = 4096; +const RESPONSE_HEADERS = { + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + "X-Content-Type-Options": "nosniff", +}; + +function errorResponse(message: string, status: number): Response { + return Response.json({ error: message }, { status, headers: RESPONSE_HEADERS }); +} + +function isSameOriginRequest(request: Request): boolean { + const origin = request.headers.get("Origin"); + return origin !== null && origin === new URL(request.url).origin; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseClientEvent(body: unknown, request: Request): SiteEvent | null { + if (!isRecord(body)) return null; + const pagePath = typeof body.page_path === "string" ? cleanPath(body.page_path) : null; + if (!pagePath) return null; + + if (body.event_name === "page_viewed") { + return { eventName: "page_viewed", pagePath }; + } + + if (body.event_name === "outbound_link_clicked") { + if (typeof body.destination_url !== "string") return null; + const destination = destinationParts(body.destination_url, new URL(request.url).origin); + if (!destination) return null; + return { + eventName: "outbound_link_clicked", + pagePath, + ...destination, + }; + } + + return null; +} + +export const onRequestPost: PagesFunction = async (context) => { + if (!isSameOriginRequest(context.request)) { + return errorResponse("This endpoint only accepts same-origin events.", 403); + } + + const declaredSize = Number(context.request.headers.get("Content-Length") ?? 0); + if (Number.isFinite(declaredSize) && declaredSize > MAX_BODY_BYTES) { + return errorResponse("Event payload is too large.", 413); + } + + let body: unknown; + try { + const raw = await context.request.text(); + if (new TextEncoder().encode(raw).byteLength > MAX_BODY_BYTES) { + return errorResponse("Event payload is too large.", 413); + } + body = JSON.parse(raw) as unknown; + } catch { + return errorResponse("Event payload must be valid JSON.", 400); + } + + const event = parseClientEvent(body, context.request); + if (!event) return errorResponse("Event payload is invalid.", 400); + + queueSiteEvent(context, event); + return new Response(null, { status: 204, headers: { "Cache-Control": "no-store" } }); +}; diff --git a/migrations/0001_event_tracking.sql b/migrations/0001_event_tracking.sql new file mode 100644 index 0000000..b65f687 --- /dev/null +++ b/migrations/0001_event_tracking.sql @@ -0,0 +1,26 @@ +CREATE TABLE site_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_name TEXT NOT NULL CHECK ( + event_name IN ( + 'page_viewed', + 'download_clicked', + 'download_failed', + 'outbound_link_clicked' + ) + ), + occurred_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + page_path TEXT, + asset_key TEXT, + release_tag TEXT, + asset_name TEXT, + destination_host TEXT, + destination_path TEXT, + failure_stage TEXT, + failure_reason TEXT +); + +CREATE INDEX site_events_name_occurred_at + ON site_events (event_name, occurred_at); + +CREATE INDEX site_events_asset_occurred_at + ON site_events (asset_key, occurred_at); diff --git a/package.json b/package.json index 4910e7a..15d556f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "preview": "astro preview", "typecheck": "astro check && tsc --noEmit --project functions/tsconfig.json", "test": "vitest run", + "db:migrate": "wrangler d1 migrations apply scientfactory-downloads --remote", + "analytics:report": "node scripts/analytics-report.mjs", "format": "oxfmt", "format:check": "oxfmt --check", "check": "bun run format:check && bun run typecheck && bun run test && bun run build && bun run build:edge" diff --git a/public/robots.txt b/public/robots.txt index f492f30..f88dac8 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,4 +1,5 @@ User-agent: * Allow: / +Disallow: /api/ Sitemap: https://scientfactory.com/sitemap.xml diff --git a/scripts/analytics-report.mjs b/scripts/analytics-report.mjs new file mode 100644 index 0000000..a6247b2 --- /dev/null +++ b/scripts/analytics-report.mjs @@ -0,0 +1,62 @@ +import { spawnSync } from "node:child_process"; + +const query = ` + SELECT + 'all_time_event' AS report_section, + event_name AS item, + COUNT(*) AS event_count, + MAX(occurred_at) AS latest_event + FROM site_events + GROUP BY event_name + + UNION ALL + + SELECT + '30_day_download' AS report_section, + COALESCE(asset_key, 'unknown') AS item, + COUNT(*) AS event_count, + MAX(occurred_at) AS latest_event + FROM site_events + WHERE event_name = 'download_clicked' + AND occurred_at >= datetime('now', '-30 days') + GROUP BY asset_key + + UNION ALL + + SELECT + '30_day_outbound' AS report_section, + COALESCE(destination_host, 'unknown') || COALESCE(destination_path, '/') AS item, + COUNT(*) AS event_count, + MAX(occurred_at) AS latest_event + FROM site_events + WHERE event_name = 'outbound_link_clicked' + AND occurred_at >= datetime('now', '-30 days') + GROUP BY destination_host, destination_path + + UNION ALL + + SELECT + '30_day_download_failure' AS report_section, + COALESCE(failure_stage, 'unknown') || ':' || COALESCE(failure_reason, 'unknown') AS item, + COUNT(*) AS event_count, + MAX(occurred_at) AS latest_event + FROM site_events + WHERE event_name = 'download_failed' + AND occurred_at >= datetime('now', '-30 days') + GROUP BY failure_stage, failure_reason + + ORDER BY report_section, event_count DESC, item +`; + +const result = spawnSync( + "wrangler", + ["d1", "execute", "scientfactory-downloads", "--remote", "--command", query], + { stdio: "inherit" }, +); + +if (result.error) { + console.error(result.error.message); + process.exitCode = 1; +} else { + process.exitCode = result.status ?? 1; +} diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index ac4aa67..d955cc8 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -93,6 +93,66 @@ const primaryLinks = [ + + diff --git a/src/lib/download-assets.ts b/src/lib/download-assets.ts new file mode 100644 index 0000000..ffab141 --- /dev/null +++ b/src/lib/download-assets.ts @@ -0,0 +1,19 @@ +// FILE: download-assets.ts +// Purpose: Defines the supported desktop installer names without browser-only dependencies. +// Layer: Shared marketing and edge utility + +import type { Release, ReleaseAsset } from "./release-schema"; + +export const DOWNLOAD_ASSETS = { + macArm64: { suffix: "-arm64.dmg", label: "macOS Apple Silicon" }, + macX64: { suffix: "-x64.dmg", label: "macOS Intel" }, + windowsX64: { suffix: "-x64.exe", label: "Windows x64" }, + linuxX64: { suffix: "-x86_64.AppImage", label: "Linux x64" }, +} as const; + +export type DownloadAssetKey = keyof typeof DOWNLOAD_ASSETS; + +export function findDownloadAsset(release: Release, key: DownloadAssetKey): ReleaseAsset | null { + const expected = DOWNLOAD_ASSETS[key]; + return release.assets.find((asset) => asset.name.endsWith(expected.suffix)) ?? null; +} diff --git a/src/lib/releases.ts b/src/lib/releases.ts index 9f654f0..b46fde5 100644 --- a/src/lib/releases.ts +++ b/src/lib/releases.ts @@ -4,6 +4,8 @@ import { parseRelease, type Release, type ReleaseAsset } from "./release-schema"; +export { DOWNLOAD_ASSETS, findDownloadAsset } from "./download-assets"; +export type { DownloadAssetKey } from "./download-assets"; export { parseRelease } from "./release-schema"; export type { Release, ReleaseAsset } from "./release-schema"; @@ -18,15 +20,6 @@ export const REPO_URL = `https://github.com/${REPO}`; export const RELEASES_URL = `${REPO_URL}/releases`; export const LATEST_RELEASE_URL = `${RELEASES_URL}/latest`; -export const DOWNLOAD_ASSETS = { - macArm64: { suffix: "-arm64.dmg", label: "macOS Apple Silicon" }, - macX64: { suffix: "-x64.dmg", label: "macOS Intel" }, - windowsX64: { suffix: "-x64.exe", label: "Windows x64" }, - linuxX64: { suffix: "-x86_64.AppImage", label: "Linux x64" }, -} as const; - -export type DownloadAssetKey = keyof typeof DOWNLOAD_ASSETS; - interface CachedRelease { readonly cachedAt: number; readonly release: Release; @@ -116,11 +109,6 @@ async function fetchReleaseFromNetwork( ); } -export function findDownloadAsset(release: Release, key: DownloadAssetKey): ReleaseAsset | null { - const expected = DOWNLOAD_ASSETS[key]; - return release.assets.find((asset) => asset.name.endsWith(expected.suffix)) ?? null; -} - export function findChecksumAsset(release: Release): ReleaseAsset | null { return release.assets.find((asset) => asset.name === "SHA256SUMS.txt") ?? null; } diff --git a/src/pages/download.astro b/src/pages/download.astro index 161856b..efad731 100644 --- a/src/pages/download.astro +++ b/src/pages/download.astro @@ -250,7 +250,7 @@ const linuxIcon = iconData("Linux"); } availableCount += 1; - card.href = asset.browser_download_url; + card.href = `/api/download/${encodeURIComponent(key)}`; card.dataset.state = "available"; card.removeAttribute("aria-disabled"); if (detail) { diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro index 19026f2..1a83e19 100644 --- a/src/pages/privacy.astro +++ b/src/pages/privacy.astro @@ -10,16 +10,22 @@ import { REPO_URL } from "../lib/releases"; description="A plain-language explanation of how the ScientFactory website handles visitor information." >
-

No analytics or advertising trackers.

+

Limited, first-party measurement without visitor profiles.

- The ScientFactory website currently uses no analytics service, tracking pixels, advertising cookies, or behavioral recording tools. + The website records four anonymous events: a page was viewed, a download was clicked, a ScientFactory download redirect failed, or an external link was clicked. These events help us understand which pages and installers are useful and whether the download service is working. +

+

+ The event database stores the time, page path, and only the details needed for the event—for example, the installer type or the destination website and path. Query strings are discarded. We do not add an IP address, cookie, browser fingerprint, advertising identifier, referrer, or persistent visitor ID to these event records. This means the counts describe events, not unique people or a person's journey through the site. +

+

+ ScientFactory does not use advertising trackers, behavioral session recording, or cross-site tracking on this website.

@@ -37,10 +43,13 @@ import { REPO_URL } from "../lib/releases";
-

Release information comes from GitHub.

+

Installer delivery is completed by GitHub.

The download page checks ScientFactory's public GitHub release information to locate the latest installer. If the website's release endpoint is unavailable, your browser may request that public information directly from GitHub.

+

+ When you choose an installer, your browser first requests a ScientFactory download address. The website records the download click and redirects the browser to the official GitHub release file. A download_failed event means our website could not resolve or start that redirect; it cannot detect a later transfer failure after GitHub begins serving the file. GitHub processes the resulting file request under its own privacy terms. +

Release details may be cached temporarily in your browser's session storage for up to 15 minutes. This avoids repeating the same release lookup and is cleared with the browser session.

@@ -65,7 +74,7 @@ import { REPO_URL } from "../lib/releases";

We will update this page when the website changes.

- If we introduce analytics, accounts, forms, or other ways of collecting visitor information, this notice will be updated to describe them. + If we add accounts, forms, more detailed measurement, or other ways of collecting visitor information, this notice will be updated to describe them.

For non-sensitive questions, use GitHub Issues. Security concerns should be reported privately through GitHub's security reporting flow rather than in a public issue. diff --git a/src/seo.test.ts b/src/seo.test.ts index 031d378..d254213 100644 --- a/src/seo.test.ts +++ b/src/seo.test.ts @@ -22,7 +22,7 @@ const publicURLs = [ describe("search discovery files", () => { it("allows crawling and advertises the canonical sitemap", () => { expect(robots).toBe( - "User-agent: *\nAllow: /\n\nSitemap: https://scientfactory.com/sitemap.xml\n", + "User-agent: *\nAllow: /\nDisallow: /api/\n\nSitemap: https://scientfactory.com/sitemap.xml\n", ); }); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 05f0e44..1f44e7e 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,6 +1,8 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: 2118672e85a5a9b3dc89d6bbc3b77f24) -interface __BaseEnv_Env {} +// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: cc9435d561e3059c6309d1c80ce8629b) +interface __BaseEnv_Env { + DOWNLOAD_DB: D1Database; +} declare namespace Cloudflare { interface Env extends __BaseEnv_Env {} } diff --git a/wrangler.jsonc b/wrangler.jsonc index b7887d3..20b12b2 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -4,6 +4,14 @@ "pages_build_output_dir": "./dist", "compatibility_date": "2026-07-20", "compatibility_flags": ["nodejs_compat"], + "d1_databases": [ + { + "binding": "DOWNLOAD_DB", + "database_name": "scientfactory-downloads", + "database_id": "e106f11e-18e3-4d9d-b61e-7003ac4dd994", + "migrations_dir": "migrations", + }, + ], "observability": { "enabled": true, },