From aed26f10f84c6c358b7c414dd5a827b5a9063505 Mon Sep 17 00:00:00 2001 From: Andrew Mikofalvy <5668128+amikofalvy@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:10:45 -0700 Subject: [PATCH] feat(open-knowledge): track downloads and updates per version (#2222) * feat(open-knowledge): track downloads and updates per version Server-side, privacy-respecting download and update telemetry for the docs site, with no user IP and no PII. - New docs/src/lib/track.ts posts events to the existing PostHog project from the server, so PostHog never sees the visitor IP. It forces $ip:null and $geoip_disable, strips undefined props, and reuses the posthog-js cookie's distinct_id when present (else a random UUID). Capture runs in after() so it never blocks or breaks a redirect, and never throws. - /download/stable and /download/beta now emit dmg_downloaded (channel plus hostname-only referrer). The stable route is served no-store so the CDN cannot swallow per-download counts. - New /updates/[channel]/[...path] proxy 302s electron-updater requests to the byte-identical GitHub asset and emits app_update_downloaded only for the mac zip, with to_version parsed from the filename and from_version from an x-ok-from-version header. Manifests, blockmaps and the dmg are redirected but not counted. No client points at it yet; it is infrastructure for a later desktop change that repoints the updater feed here. - Download CTAs link the tracked /download/stable route. next/link would prefetch a redirect route handler (firing the redirect and inflating counts) and double-fetch on click, so MarketingButton and DownloadButton render a raw anchor for /download and /updates hrefs, matching app/d/[encoded]/ splash-buttons.tsx. The JSON-LD downloadUrl keeps the real file URL for SEO. - Repoint the public README macOS download link to openknowledge.ai/download/ stable so README and launch traffic is counted. Docs-only, so no changeset. Persistent-install-id active-user tracking stays deferred pending the opt-in/opt-out decision. SPEC under specs/. * chore(open-knowledge): address review on download/update tracking - track.ts: surface PostHog 4xx/5xx responses (check res.ok) instead of silently dropping events. - /updates proxy: validate the x-ok-from-version header before it reaches PostHog, count only a real mac-update zip (parseable version required), serve 404/503 with no-store, and log the stale-LKG beta-tag case. - Tests: add the share-splash download route (cookie set + unconditional capture), the beta 503 fallback path, an x64 artifact, and resolveDistinctId empty-id / multi-cookie cases. GitOrigin-RevId: 6936fd9d3e996e3fc60fb77d49a646e44be983c1 --- README.md | 2 +- docs/src/app/(home)/marketing-button.tsx | 5 +- .../app/(home)/sections/call-to-action.tsx | 4 +- docs/src/app/(home)/sections/hero.tsx | 4 +- docs/src/app/(home)/site-nav.tsx | 6 +- docs/src/app/continue/page.tsx | 4 +- .../app/d/[encoded]/download/route.test.ts | 70 +++++++++ docs/src/app/d/[encoded]/download/route.ts | 9 +- .../src/app/d/[encoded]/splash-share-view.tsx | 10 +- docs/src/app/download/beta/route.test.ts | 38 ++++- docs/src/app/download/beta/route.ts | 10 +- docs/src/app/download/stable/route.test.ts | 33 ++++- docs/src/app/download/stable/route.ts | 12 +- .../updates/[channel]/[...path]/route.test.ts | 139 ++++++++++++++++++ .../app/updates/[channel]/[...path]/route.ts | 88 +++++++++++ docs/src/components/download-button.tsx | 9 +- docs/src/lib/download-links.ts | 2 - docs/src/lib/site.ts | 2 + docs/src/lib/track.test.ts | 114 ++++++++++++++ docs/src/lib/track.ts | 106 +++++++++++++ 20 files changed, 625 insertions(+), 42 deletions(-) create mode 100644 docs/src/app/d/[encoded]/download/route.test.ts create mode 100644 docs/src/app/updates/[channel]/[...path]/route.test.ts create mode 100644 docs/src/app/updates/[channel]/[...path]/route.ts create mode 100644 docs/src/lib/track.test.ts create mode 100644 docs/src/lib/track.ts diff --git a/README.md b/README.md index 5065a313..19691b18 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@
website   •   - macOS app + macOS app   •   web view + cli   •   diff --git a/docs/src/app/(home)/marketing-button.tsx b/docs/src/app/(home)/marketing-button.tsx index b1b1038c..ef5e0434 100644 --- a/docs/src/app/(home)/marketing-button.tsx +++ b/docs/src/app/(home)/marketing-button.tsx @@ -183,6 +183,9 @@ export function MarketingButton({ const ArrowIcon = iconDirection === 'down' ? ArrowDown : ArrowRight; const ChevronsIcon = iconDirection === 'down' ? ChevronsDown : ChevronsRight; const isFileLink = typeof href === 'string' && href.split('?')[0]?.toLowerCase().endsWith('.pdf'); + const isRedirectRoute = + typeof href === 'string' && (href.startsWith('/download/') || href.startsWith('/updates/')); + const useRawAnchor = isFileLink || isRedirectRoute; const computedDownload = typeof download !== 'undefined' ? download : isFileLink ? '' : undefined; const buttonContent = ( @@ -251,7 +254,7 @@ export function MarketingButton({ const defaultRel = target === '_blank' ? 'noopener noreferrer' : undefined; const finalRel = rel || defaultRel; - if (isFileLink) { + if (useRawAnchor) { return ( ); })} - + Download @@ -186,7 +186,7 @@ export function SiteNav() { ); })} - + Download diff --git a/docs/src/app/continue/page.tsx b/docs/src/app/continue/page.tsx index b4f54a0a..687280c7 100644 --- a/docs/src/app/continue/page.tsx +++ b/docs/src/app/continue/page.tsx @@ -9,7 +9,7 @@ import { PENDING_SHARE_COOKIE, PORT_PARAM, } from '@/lib/deferred-share'; -import { SPLASH_DOWNLOAD_URL } from '@/lib/share-splash'; +import { DOWNLOAD_ROUTE } from '@/lib/site'; export const dynamic = 'force-dynamic'; @@ -68,7 +68,7 @@ export default async function ContinuePage({ searchParams }: ContinuePageProps)
Don’t have the app yet?{' '}
Download it for macOS diff --git a/docs/src/app/d/[encoded]/download/route.test.ts b/docs/src/app/d/[encoded]/download/route.test.ts new file mode 100644 index 00000000..d8ecf95d --- /dev/null +++ b/docs/src/app/d/[encoded]/download/route.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, mock, test } from 'bun:test'; + +const SPLASH_URL = + 'https://github.com/inkeep/open-knowledge/releases/latest/download/OpenKnowledge-arm64.dmg'; + +type CaptureOpts = { + event: string; + distinctId: string; + properties?: Record; +}; +let _lastCapture: CaptureOpts | null = null; +mock.module('../../../../lib/track.ts', () => ({ + captureServerEvent: (opts: CaptureOpts) => { + _lastCapture = opts; + }, + resolveDistinctId: () => 'splash-1', +})); + +let _viewKind: 'ok' | 'invalid' | 'unsupported-version' = 'ok'; +mock.module('../../../../lib/share-splash.ts', () => ({ + buildSplashViewModel: () => ({ kind: _viewKind }), + SPLASH_DOWNLOAD_URL: SPLASH_URL, +})); + +mock.module('../../../../lib/deferred-share.ts', () => ({ + buildPendingShareCookie: (encoded: string) => ({ name: 'ok-pending-share', value: encoded }), +})); + +const { GET } = await import('./route.ts'); + +function call(encoded: string): Promise { + return GET(new Request(`https://openknowledge.ai/d/${encoded}/download`), { + params: Promise.resolve({ encoded }), + }); +} + +describe('GET /d/[encoded]/download', () => { + test('valid share: 302 to the DMG, sets the pairing cookie, counts share-splash', async () => { + _viewKind = 'ok'; + _lastCapture = null; + const res = await call('valid-share'); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(SPLASH_URL); + expect(res.headers.get('set-cookie')).toContain('ok-pending-share=valid-share'); + expect(_lastCapture?.event).toBe('dmg_downloaded'); + expect(_lastCapture?.properties?.channel).toBe('stable'); + expect(_lastCapture?.properties?.source).toBe('share-splash'); + expect(_lastCapture?.distinctId).toBe('splash-1'); + }); + + test('invalid share: 302 with NO cookie but still counts the download', async () => { + _viewKind = 'invalid'; + _lastCapture = null; + const res = await call('bad-share'); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(SPLASH_URL); + expect(res.headers.get('set-cookie')).toBeNull(); + expect(_lastCapture?.event).toBe('dmg_downloaded'); + expect(_lastCapture?.properties?.source).toBe('share-splash'); + }); + + test('unsupported-version share: no cookie, still counts', async () => { + _viewKind = 'unsupported-version'; + _lastCapture = null; + const res = await call('old-share'); + expect(res.status).toBe(302); + expect(res.headers.get('set-cookie')).toBeNull(); + expect(_lastCapture?.event).toBe('dmg_downloaded'); + }); +}); diff --git a/docs/src/app/d/[encoded]/download/route.ts b/docs/src/app/d/[encoded]/download/route.ts index 671482f1..2bb4fcc2 100644 --- a/docs/src/app/d/[encoded]/download/route.ts +++ b/docs/src/app/d/[encoded]/download/route.ts @@ -1,9 +1,10 @@ import { NextResponse } from 'next/server'; import { buildPendingShareCookie } from '@/lib/deferred-share'; import { buildSplashViewModel, SPLASH_DOWNLOAD_URL } from '@/lib/share-splash'; +import { captureServerEvent, resolveDistinctId } from '@/lib/track'; export async function GET( - _request: Request, + request: Request, { params }: { params: Promise<{ encoded: string }> }, ): Promise { const { encoded } = await params; @@ -14,5 +15,11 @@ export async function GET( response.cookies.set(buildPendingShareCookie(encoded)); } + captureServerEvent({ + event: 'dmg_downloaded', + distinctId: resolveDistinctId(request), + properties: { channel: 'stable', source: 'share-splash' }, + }); + return response; } diff --git a/docs/src/app/d/[encoded]/splash-share-view.tsx b/docs/src/app/d/[encoded]/splash-share-view.tsx index 2ca01aa1..bb5be801 100644 --- a/docs/src/app/d/[encoded]/splash-share-view.tsx +++ b/docs/src/app/d/[encoded]/splash-share-view.tsx @@ -1,12 +1,8 @@ import { DotIcon, GitBranchIcon } from 'lucide-react'; import Link from 'next/link'; import { OkWordmark } from '@/components/ok-wordmark'; -import { - buildCloneCommand, - SPLASH_DOWNLOAD_URL, - SPLASH_INSTALL_COMMAND, - type SplashView, -} from '@/lib/share-splash'; +import { buildCloneCommand, SPLASH_INSTALL_COMMAND, type SplashView } from '@/lib/share-splash'; +import { DOWNLOAD_ROUTE } from '@/lib/site'; import { DotTexture } from '../../(home)/dot-texture'; import { SiteFooter } from '../../(home)/footer'; import { SplashButtonLabel, splashPrimaryButton } from './splash-buttons'; @@ -133,7 +129,7 @@ export function SplashFallback({ heading }: { heading: string }) {
- + DOWNLOAD FOR MAC diff --git a/docs/src/app/download/beta/route.test.ts b/docs/src/app/download/beta/route.test.ts index fc1b2bda..8f357f7a 100644 --- a/docs/src/app/download/beta/route.test.ts +++ b/docs/src/app/download/beta/route.test.ts @@ -23,30 +23,56 @@ mock.module('../../../lib/download-links.ts', () => ({ }), })); +type CaptureOpts = { + event: string; + distinctId: string; + properties?: Record; +}; +let _lastCapture: CaptureOpts | null = null; +mock.module('../../../lib/track.ts', () => ({ + captureServerEvent: (opts: CaptureOpts) => { + _lastCapture = opts; + }, + resolveDistinctId: () => 'visitor-9', + referrerHostname: () => undefined, +})); + const { GET } = await import('./route.ts'); +function call(): Promise { + return GET(new Request('https://openknowledge.ai/download/beta')); +} + describe('GET /download/beta', () => { - test('302 to the fresh beta URL with CDN-cacheable headers', async () => { + test('302 to the fresh beta URL and fires dmg_downloaded (beta)', async () => { _redirect = { kind: 'fresh', url: TEST_DMG_URL }; - const res = await GET(); + _lastCapture = null; + const res = await call(); expect(res.status).toBe(302); expect(res.headers.get('location')).toBe(TEST_DMG_URL); expect(res.headers.get('cache-control')).toBe(SUCCESS_CACHE_CONTROL); + expect(_lastCapture?.event).toBe('dmg_downloaded'); + expect(_lastCapture?.distinctId).toBe('visitor-9'); + expect(_lastCapture?.properties?.channel).toBe('beta'); }); - test('302 to the stale LKG URL with CDN-cacheable headers', async () => { + test('302 to the stale LKG URL and still counts', async () => { _redirect = { kind: 'stale-lkg', url: TEST_DMG_URL, refreshError: 'network down' }; - const res = await GET(); + _lastCapture = null; + const res = await call(); expect(res.status).toBe(302); expect(res.headers.get('location')).toBe(TEST_DMG_URL); expect(res.headers.get('cache-control')).toBe(SUCCESS_CACHE_CONTROL); + expect(_lastCapture?.properties?.channel).toBe('beta'); }); - test('302 to the releases page uncached on fallback', async () => { + test('302 to the releases page on fallback is NOT counted as a download', async () => { _redirect = { kind: 'fallback', url: RELEASES_PAGE_URL, cause: 'API error' }; - const res = await GET(); + _lastCapture = null; + const res = await call(); expect(res.status).toBe(302); expect(res.headers.get('location')).toBe(RELEASES_PAGE_URL); expect(res.headers.get('cache-control')).toBe(FALLBACK_CACHE_CONTROL); + expect(_lastCapture).toBeNull(); }); }); diff --git a/docs/src/app/download/beta/route.ts b/docs/src/app/download/beta/route.ts index 7d5b6781..79fd8a53 100644 --- a/docs/src/app/download/beta/route.ts +++ b/docs/src/app/download/beta/route.ts @@ -1,10 +1,11 @@ import { createBetaResolver, toRedirectResponse } from '@/lib/download-links'; +import { captureServerEvent, referrerHostname, resolveDistinctId } from '@/lib/track'; export const dynamic = 'force-dynamic'; const resolveBetaRedirect = createBetaResolver(); -export async function GET(): Promise { +export async function GET(request: Request): Promise { const redirect = await resolveBetaRedirect(); if (redirect.kind === 'stale-lkg') { console.warn( @@ -14,5 +15,12 @@ export async function GET(): Promise { if (redirect.kind === 'fallback') { console.error(`[download/beta] falling back to releases page: ${redirect.cause}`); } + if (redirect.kind !== 'fallback') { + captureServerEvent({ + event: 'dmg_downloaded', + distinctId: resolveDistinctId(request), + properties: { channel: 'beta', referrer: referrerHostname(request) }, + }); + } return toRedirectResponse(redirect); } diff --git a/docs/src/app/download/stable/route.test.ts b/docs/src/app/download/stable/route.test.ts index d286c801..d4f49a6a 100644 --- a/docs/src/app/download/stable/route.test.ts +++ b/docs/src/app/download/stable/route.test.ts @@ -1,12 +1,33 @@ -import { describe, expect, test } from 'bun:test'; -import { STABLE_CACHE_CONTROL, STABLE_DMG_URL } from '../../../lib/download-links.ts'; -import { GET } from './route.ts'; +import { describe, expect, mock, test } from 'bun:test'; +import { STABLE_DMG_URL } from '../../../lib/download-links.ts'; + +type CaptureOpts = { + event: string; + distinctId: string; + properties?: Record; +}; + +let _lastCapture: CaptureOpts | null = null; +mock.module('../../../lib/track.ts', () => ({ + captureServerEvent: (opts: CaptureOpts) => { + _lastCapture = opts; + }, + resolveDistinctId: () => 'visitor-1', + referrerHostname: () => 'news.ycombinator.com', +})); + +const { GET } = await import('./route.ts'); describe('GET /download/stable', () => { - test('302 to the stable DMG URL with CDN-cacheable headers', () => { - const res = GET(); + test('302 to the stable DMG URL, uncached, and fires dmg_downloaded', () => { + _lastCapture = null; + const res = GET(new Request('https://openknowledge.ai/download/stable')); expect(res.status).toBe(302); expect(res.headers.get('location')).toBe(STABLE_DMG_URL); - expect(res.headers.get('cache-control')).toBe(STABLE_CACHE_CONTROL); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(_lastCapture?.event).toBe('dmg_downloaded'); + expect(_lastCapture?.distinctId).toBe('visitor-1'); + expect(_lastCapture?.properties?.channel).toBe('stable'); + expect(_lastCapture?.properties?.referrer).toBe('news.ycombinator.com'); }); }); diff --git a/docs/src/app/download/stable/route.ts b/docs/src/app/download/stable/route.ts index 1a31829f..5fd6ed0a 100644 --- a/docs/src/app/download/stable/route.ts +++ b/docs/src/app/download/stable/route.ts @@ -1,13 +1,19 @@ -import { STABLE_CACHE_CONTROL, STABLE_DMG_URL } from '@/lib/download-links'; +import { STABLE_DMG_URL } from '@/lib/download-links'; +import { captureServerEvent, referrerHostname, resolveDistinctId } from '@/lib/track'; export const dynamic = 'force-dynamic'; -export function GET(): Response { +export function GET(request: Request): Response { + captureServerEvent({ + event: 'dmg_downloaded', + distinctId: resolveDistinctId(request), + properties: { channel: 'stable', referrer: referrerHostname(request) }, + }); return new Response(null, { status: 302, headers: { location: STABLE_DMG_URL, - 'cache-control': STABLE_CACHE_CONTROL, + 'cache-control': 'no-store', }, }); } diff --git a/docs/src/app/updates/[channel]/[...path]/route.test.ts b/docs/src/app/updates/[channel]/[...path]/route.test.ts new file mode 100644 index 00000000..4d76af53 --- /dev/null +++ b/docs/src/app/updates/[channel]/[...path]/route.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, mock, test } from 'bun:test'; + +type CaptureOpts = { + event: string; + distinctId: string; + properties?: Record; +}; +let _lastCapture: CaptureOpts | null = null; + +mock.module('../../../../lib/track.ts', () => ({ + captureServerEvent: (opts: CaptureOpts) => { + _lastCapture = opts; + }, + resolveDistinctId: () => 'updater-1', +})); + +const BETA_DMG_URL = + 'https://github.com/inkeep/open-knowledge/releases/download/v0.20.0-beta.4/OpenKnowledge-arm64.dmg'; +type BetaRedirect = { kind: string; url: string; cause?: string }; +let _betaRedirect: BetaRedirect = { kind: 'fresh', url: BETA_DMG_URL }; +mock.module('../../../../lib/download-links.ts', () => ({ + createBetaResolver: () => () => Promise.resolve(_betaRedirect), +})); + +const { GET } = await import('./route.ts'); +const REL = 'https://github.com/inkeep/open-knowledge/releases'; + +function call( + channel: string, + path: string[], + headers: Record = {}, +): Promise { + return GET( + new Request(`https://openknowledge.ai/updates/${channel}/${path.join('/')}`, { headers }), + { + params: Promise.resolve({ channel, path }), + }, + ); +} + +describe('GET /updates/[channel]/[...path]', () => { + test('stable manifest 302s to the latest alias and is NOT counted', async () => { + _lastCapture = null; + const res = await call('stable', ['latest-mac.yml']); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/latest/download/latest-mac.yml`); + expect(_lastCapture).toBeNull(); + }); + + test('beta manifest 302s to the resolved beta tag and is NOT counted', async () => { + _betaRedirect = { kind: 'fresh', url: BETA_DMG_URL }; + _lastCapture = null; + const res = await call('beta', ['beta-mac.yml']); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/download/v0.20.0-beta.4/beta-mac.yml`); + expect(_lastCapture).toBeNull(); + }); + + test('stable zip 302s to the tagged release and counts app_update_downloaded', async () => { + _lastCapture = null; + const file = 'OpenKnowledge-0.20.0-arm64-mac.zip'; + const res = await call('stable', [file], { 'x-ok-from-version': '0.19.1' }); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/download/v0.20.0/${file}`); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(_lastCapture?.event).toBe('app_update_downloaded'); + expect(_lastCapture?.properties?.channel).toBe('stable'); + expect(_lastCapture?.properties?.artifact_type).toBe('zip'); + expect(_lastCapture?.properties?.to_version).toBe('0.20.0'); + expect(_lastCapture?.properties?.from_version).toBe('0.19.1'); + }); + + test('beta zip parses the prerelease version and counts (no from_version header)', async () => { + _lastCapture = null; + const file = 'OpenKnowledge-0.20.0-beta.4-arm64-mac.zip'; + const res = await call('beta', [file]); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/download/v0.20.0-beta.4/${file}`); + expect(_lastCapture?.properties?.to_version).toBe('0.20.0-beta.4'); + expect(_lastCapture?.properties?.from_version).toBeUndefined(); + }); + + test('blockmap 302s but is NOT counted', async () => { + _lastCapture = null; + const file = 'OpenKnowledge-0.20.0-arm64-mac.zip.blockmap'; + const res = await call('stable', [file]); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/download/v0.20.0/${file}`); + expect(_lastCapture).toBeNull(); + }); + + test('human dmg 302s (latest alias) but is NOT counted', async () => { + _lastCapture = null; + const res = await call('stable', ['OpenKnowledge-arm64.dmg']); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/latest/download/OpenKnowledge-arm64.dmg`); + expect(_lastCapture).toBeNull(); + }); + + test('dmg blockmap 302s (latest alias) but is NOT counted', async () => { + _lastCapture = null; + const file = 'OpenKnowledge-arm64.dmg.blockmap'; + const res = await call('stable', [file]); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/latest/download/${file}`); + expect(_lastCapture).toBeNull(); + }); + + test('invalid channel → 404', async () => { + expect((await call('canary', ['latest-mac.yml'])).status).toBe(404); + }); + + test('path traversal / multi-segment → 404', async () => { + expect((await call('stable', ['..', 'secret'])).status).toBe(404); + }); + + test('unknown artifact type → 404', async () => { + expect((await call('stable', ['random.txt'])).status).toBe(404); + }); + + test('x64 zip parses the version and counts', async () => { + _lastCapture = null; + const file = 'OpenKnowledge-0.20.0-x64-mac.zip'; + const res = await call('stable', [file]); + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe(`${REL}/download/v0.20.0/${file}`); + expect(_lastCapture?.properties?.to_version).toBe('0.20.0'); + }); + + test('beta manifest 503s (no-store) on resolver fallback, not counted', async () => { + _betaRedirect = { kind: 'fallback', url: REL, cause: 'API error' }; + _lastCapture = null; + const res = await call('beta', ['beta-mac.yml']); + expect(res.status).toBe(503); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(_lastCapture).toBeNull(); + _betaRedirect = { kind: 'fresh', url: BETA_DMG_URL }; + }); +}); diff --git a/docs/src/app/updates/[channel]/[...path]/route.ts b/docs/src/app/updates/[channel]/[...path]/route.ts new file mode 100644 index 00000000..d9758046 --- /dev/null +++ b/docs/src/app/updates/[channel]/[...path]/route.ts @@ -0,0 +1,88 @@ +import { createBetaResolver } from '@/lib/download-links'; +import { captureServerEvent, resolveDistinctId } from '@/lib/track'; + +export const dynamic = 'force-dynamic'; + +const RELEASES_BASE = 'https://github.com/inkeep/open-knowledge/releases'; +const VALID_CHANNELS = new Set(['stable', 'beta']); +const SAFE_FILENAME = /^[A-Za-z0-9._-]+$/; +const ARTIFACT_VERSION = + /-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)-(?:arm64|x64|universal)-mac\.zip(?:\.blockmap)?$/; +const BETA_TAG_FROM_URL = /\/releases\/download\/([^/]+)\//; +const FROM_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/; + +const resolveBeta = createBetaResolver(); + +type ArtifactType = 'manifest' | 'zip' | 'blockmap' | 'dmg' | 'other'; + +function classify(filename: string): ArtifactType { + if (filename.endsWith('-mac.yml')) return 'manifest'; + if (filename.endsWith('.blockmap')) return 'blockmap'; + if (filename.endsWith('.zip')) return 'zip'; + if (filename.endsWith('.dmg')) return 'dmg'; + return 'other'; +} + +function redirect302(location: string): Response { + return new Response(null, { status: 302, headers: { location, 'cache-control': 'no-store' } }); +} + +function errorResponse(status: number): Response { + return new Response(null, { status, headers: { 'cache-control': 'no-store' } }); +} + +async function latestBetaTag(): Promise { + const redirect = await resolveBeta(); + if (redirect.kind === 'stale-lkg') { + console.warn( + `[updates/beta] serving stale LKG tag after refresh failure: ${redirect.refreshError}`, + ); + } + if (redirect.kind === 'fallback') return null; + return BETA_TAG_FROM_URL.exec(redirect.url)?.[1] ?? null; +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ channel: string; path: string[] }> }, +): Promise { + const { channel, path } = await params; + if (!VALID_CHANNELS.has(channel)) return errorResponse(404); + + const filename = path.join('/'); + if (!SAFE_FILENAME.test(filename)) return errorResponse(404); + + const type = classify(filename); + if (type === 'other') return errorResponse(404); + + const version = ARTIFACT_VERSION.exec(filename)?.[1]; + + let target: string; + if (version) { + target = `${RELEASES_BASE}/download/v${version}/${filename}`; + } else if (channel === 'stable') { + target = `${RELEASES_BASE}/latest/download/${filename}`; + } else { + const tag = await latestBetaTag(); + if (!tag) { + return errorResponse(503); + } + target = `${RELEASES_BASE}/download/${tag}/${filename}`; + } + + if (type === 'zip' && version) { + const rawFrom = request.headers.get('x-ok-from-version'); + captureServerEvent({ + event: 'app_update_downloaded', + distinctId: resolveDistinctId(request), + properties: { + channel, + artifact_type: 'zip', + to_version: version, + from_version: rawFrom && FROM_VERSION.test(rawFrom) ? rawFrom : undefined, + }, + }); + } + + return redirect302(target); +} diff --git a/docs/src/components/download-button.tsx b/docs/src/components/download-button.tsx index ee5c0272..d45b1d5f 100644 --- a/docs/src/components/download-button.tsx +++ b/docs/src/components/download-button.tsx @@ -1,6 +1,5 @@ import { DownloadIcon } from 'lucide-react'; -import Link from 'next/link'; -import { STABLE_DMG_URL } from '@/lib/download-links'; +import { DOWNLOAD_ROUTE } from '@/lib/site'; type DownloadButtonProps = { href?: string; @@ -8,11 +7,11 @@ type DownloadButtonProps = { }; export function DownloadButton({ - href = STABLE_DMG_URL, + href = DOWNLOAD_ROUTE, label = 'DOWNLOAD FOR MAC', }: DownloadButtonProps) { return ( - {label}