@m13v/seo-components 0.38.5 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/seo-components",
3
- "version": "0.38.5",
3
+ "version": "0.39.0",
4
4
  "scripts": {
5
5
  "build:css": "tailwind -i src/_build.css -o dist/styles.css --minify",
6
6
  "lint:mobile-spans": "node scripts/lint-mobile-spans.mjs",
@@ -0,0 +1,192 @@
1
+ import { NextRequest } from "next/server";
2
+ import { capturePostHogServer } from "./posthog-capture";
3
+
4
+ /**
5
+ * Generic email-gated download redirect handler.
6
+ *
7
+ * Companion to `createNewsletterHandler`. The newsletter flow captures the
8
+ * email and emails the user a tokenized download link
9
+ * (e.g. `/api/download?token=<HMAC>`). This factory wires up the GET endpoint
10
+ * that link points at. It:
11
+ *
12
+ * 1. Reads the token from either the configured cookie or the `?token=`
13
+ * query param.
14
+ * 2. Calls the caller's `verifyToken` (HMAC checks live in the consumer
15
+ * because the signing secret is app-specific).
16
+ * 3. On invalid token: 302 to `gateRedirect` (defaults to
17
+ * `/?gate=required&from=download`).
18
+ * 4. On valid token:
19
+ * - resolves the download URL via `resolveDownloadUrl` (e.g. latest
20
+ * GitHub release `.dmg` asset),
21
+ * - fires a server-side PostHog event (default
22
+ * `download_link_clicked_server`), ground-truth, ad-blocker proof,
23
+ * - fires the optional `onClick` callback (DB insert lives here),
24
+ * - 302s to the resolved URL.
25
+ *
26
+ * Bot UAs (LinkedIn / Slack / Twitter link unfurls) are detected and the
27
+ * PostHog event + onClick callback are suppressed for them so funnel stats
28
+ * stay clean. The 302 still fires so previews render.
29
+ */
30
+
31
+ const BOT_UA_RE = /bot|crawler|spider|Twitterbot|LinkedInBot|Slackbot|facebookexternalhit|Discordbot|TelegramBot|WhatsApp|Applebot|Googlebot|Bingbot|YandexBot|DuckDuckBot|redditbot|Pinterest|Embedly|Snapchat|axios\/|python-requests|python-urllib|node-fetch|^node$|dataminr|Anthill|^Mozilla\/5\.0$|Chrome\/70\.|Nexus 5X Build\/MMB29P|AI-Innovation-Radar|^AFMMainUI|LinkResolver|link-resolver|^Mozilla\/5\.0 \(compatible\)$|^curl\//i;
32
+
33
+ export interface DownloadHandlerOk {
34
+ ok: true;
35
+ email: string;
36
+ }
37
+ export interface DownloadHandlerFail {
38
+ ok: false;
39
+ reason: string;
40
+ }
41
+ export type DownloadHandlerVerifyResult = DownloadHandlerOk | DownloadHandlerFail;
42
+
43
+ export interface DownloadHandlerClickInfo {
44
+ email: string;
45
+ version: string | null;
46
+ url: string;
47
+ userAgent: string;
48
+ referer: string;
49
+ ip: string | null;
50
+ country: string | null;
51
+ source: "cookie" | "query";
52
+ isBot: boolean;
53
+ }
54
+
55
+ export interface DownloadHandlerConfig {
56
+ /** Site slug, used as a PostHog property (e.g. "claude-meter"). */
57
+ site: string;
58
+ /** Cookie name to read the install token from. */
59
+ cookieName: string;
60
+ /**
61
+ * Verifies the token (HMAC or otherwise). Returns the captured email on
62
+ * success. The signing secret stays in the consumer app because it is
63
+ * app-specific.
64
+ */
65
+ verifyToken: (token: string | undefined | null) => DownloadHandlerVerifyResult;
66
+ /**
67
+ * Resolves the final destination URL (e.g. latest GitHub release .dmg).
68
+ * Called only when the token is valid, so misses cost nothing on bounces.
69
+ */
70
+ resolveDownloadUrl: () => Promise<{ url: string; version: string | null }>;
71
+ /**
72
+ * Builds the Set-Cookie header value to stamp the install token when the
73
+ * request arrived via `?token=` (so subsequent installs on the same browser
74
+ * skip the URL token). If omitted, no cookie is stamped on the query path.
75
+ */
76
+ buildTokenCookie?: (email: string) => string;
77
+ /**
78
+ * Where to bounce on missing / bad token. Defaults to
79
+ * `/?gate=required&from=download`. Relative so it works behind proxies
80
+ * where `req.url` resolves to the internal binding.
81
+ */
82
+ gateRedirect?: string;
83
+ /**
84
+ * Optional DB-logging hook fired AFTER the destination URL is resolved and
85
+ * the PostHog event is sent, but BEFORE the 302 response is returned.
86
+ * Errors are swallowed so a logging miss can't break the download path.
87
+ * Not called for bots.
88
+ */
89
+ onClick?: (info: DownloadHandlerClickInfo) => Promise<void> | void;
90
+ /** PostHog event name. Defaults to `download_link_clicked_server`. */
91
+ posthogEvent?: string;
92
+ }
93
+
94
+ export function createDownloadHandler(config: DownloadHandlerConfig) {
95
+ const {
96
+ site,
97
+ cookieName,
98
+ verifyToken,
99
+ resolveDownloadUrl,
100
+ buildTokenCookie,
101
+ gateRedirect = "/?gate=required&from=download",
102
+ onClick,
103
+ posthogEvent = "download_link_clicked_server",
104
+ } = config;
105
+
106
+ return async function GET(req: NextRequest): Promise<Response> {
107
+ const cookieToken = req.cookies.get(cookieName)?.value;
108
+ const queryToken = req.nextUrl.searchParams.get("token") ?? undefined;
109
+
110
+ let verdict = verifyToken(cookieToken);
111
+ let acceptedFrom: "cookie" | "query" | null = verdict.ok ? "cookie" : null;
112
+ if (!verdict.ok && queryToken) {
113
+ const queryVerdict = verifyToken(queryToken);
114
+ if (queryVerdict.ok) {
115
+ verdict = queryVerdict;
116
+ acceptedFrom = "query";
117
+ }
118
+ }
119
+
120
+ if (!verdict.ok) {
121
+ return new Response(null, {
122
+ status: 302,
123
+ headers: {
124
+ Location: gateRedirect,
125
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
126
+ "x-install-gate": verdict.reason ?? "missing",
127
+ },
128
+ });
129
+ }
130
+
131
+ const { url, version } = await resolveDownloadUrl();
132
+
133
+ const ua = req.headers.get("user-agent") || "";
134
+ const isBot = !ua || BOT_UA_RE.test(ua);
135
+ const referer = req.headers.get("referer") || "";
136
+ const xff = req.headers.get("x-forwarded-for") || "";
137
+ const ip = xff.split(",")[0]?.trim() || null;
138
+ const country = req.headers.get("x-vercel-ip-country") || req.headers.get("cf-ipcountry") || null;
139
+ const email = verdict.email;
140
+
141
+ if (!isBot) {
142
+ let host: string | undefined;
143
+ try {
144
+ host = req.headers.get("host") || undefined;
145
+ } catch {
146
+ host = undefined;
147
+ }
148
+ void capturePostHogServer({
149
+ event: posthogEvent,
150
+ distinctId: email,
151
+ host,
152
+ properties: {
153
+ email,
154
+ site,
155
+ version,
156
+ source: acceptedFrom,
157
+ referer,
158
+ country,
159
+ },
160
+ });
161
+
162
+ if (onClick) {
163
+ try {
164
+ await onClick({
165
+ email,
166
+ version,
167
+ url,
168
+ userAgent: ua,
169
+ referer,
170
+ ip,
171
+ country,
172
+ source: acceptedFrom ?? "cookie",
173
+ isBot,
174
+ });
175
+ } catch (err) {
176
+ console.error("[download-handler] onClick threw:", err);
177
+ }
178
+ }
179
+ }
180
+
181
+ const headers = new Headers({
182
+ Location: url,
183
+ "Cache-Control": "private, max-age=0, s-maxage=0",
184
+ "x-install-gate": "ok",
185
+ "x-install-gate-source": acceptedFrom ?? "unknown",
186
+ });
187
+ if (acceptedFrom === "query" && buildTokenCookie) {
188
+ headers.append("Set-Cookie", buildTokenCookie(email));
189
+ }
190
+ return new Response(null, { status: 302, headers });
191
+ };
192
+ }
package/src/server.ts CHANGED
@@ -48,6 +48,13 @@ export type { BookCallRedirectConfig } from "./lib/book-call-redirect";
48
48
  export { createDmShortLinkRedirectHandler } from "./lib/dm-short-link-redirect";
49
49
  export type { DmShortLinkRedirectConfig } from "./lib/dm-short-link-redirect";
50
50
 
51
+ export { createDownloadHandler } from "./lib/download-route";
52
+ export type {
53
+ DownloadHandlerConfig,
54
+ DownloadHandlerVerifyResult,
55
+ DownloadHandlerClickInfo,
56
+ } from "./lib/download-route";
57
+
51
58
  export { createResendInboundHandler } from "./lib/resend-inbound-route";
52
59
  export type {
53
60
  ResendInboundConfig,