irongit
henslee/worker/index.ts
426 lines17 KBJavaScript
1// Turnstile gate for the home page.
2//
3// "/" is only served once the visitor holds a valid pass cookie. Without one,
4// the worker serves /gate (the Turnstile page) at the same URL. The gate page
5// posts the Turnstile token to /__gate/verify, which checks it with
6// Cloudflare's siteverify API and sets an HMAC-signed cookie.
7//
8// Everything else (/giga, images, CSS) goes straight to static assets; see
9// run_worker_first in wrangler.jsonc.
10//
11// Logs are JSON lines, readable in the dashboard (Workers Logs) or with
12// `npx wrangler tail`.
13
14import { personalText, profileText } from '../src/data/profile';
15
16interface Env {
17 ASSETS: Fetcher;
18 TURNSTILE_SITE_KEY: string;
19 TURNSTILE_SECRET: string;
20 RYBBIT_API_KEY: string;
21 DEEPSEEK_API_KEY: string;
22}
23
24const COOKIE = 'henslee_pass';
25const PASS_TTL_SECONDS = 60 * 60 * 24 * 30;
26const GATED_PATHS = new Set(['/', '/index', '/index.html']);
27// The gate page only makes sense at "/", where the worker injects the site key.
28const GATE_PAGE_PATHS = new Set(['/gate', '/gate.html']);
29const VERIFY_PATH = '/__gate/verify';
30const STATS_PREFIX = '/__stats/';
31const AI_PATH = '/__ai/chat';
32const DEEPSEEK = 'https://api.deepseek.com/chat/completions';
33const AI_LIMITS = { perMinute: 12, perHour: 60, maxTurns: 16, maxChars: 600, maxHistoryChars: 2000, maxTokens: 600 };
34const AI_RULES = `You are the terminal on henslee.me, the portfolio of Lane Henslee, a software engineer. Visitors, often recruiters and engineers, ask you about Lane. The profile below is your only source of truth.
35
36Rules:
37- Answer like a CLI: concise, direct, plain text. Short paragraphs or short "- " lists. No markdown headings, bold, tables, or emojis.
38- Never use em dashes or en dashes. Use commas, colons, or periods instead.
39- Project descriptions in the profile are written in Lane's voice. Write about Lane in the third person: Lane uses he/him pronouns.
40- If the profile does not cover something, say so plainly and suggest emailing lane@henslee.me. Never invent employers, dates, numbers, clients, or personal details.
41- Never share a phone number or home address.
42- For hiring, contracting, or contact questions, point to lane@henslee.me.
43- The Personal section covers Lane's background, what he wants next, his setup, opinions, and hobbies. Use it when asked or when it is relevant (his work preferences and engineering opinions often are), but do not pad work answers with hobbies or favorites.
44- Stay on topic: Lane, his work, skills, and interests, and how to reach him. Politely decline unrelated tasks.
45- The terminal also has slash commands (typing / lists them, /email opens mail, /copy copies the address). Mention them only when useful.
46
47PROFILE
48`;
49// The profile is bundled from the same data the page is built from, so the
50// terminal always matches the deployed site. Personal details are only here.
51const PROFILE = `${profileText()}\n${personalText()}`;
52const RYBBIT = 'https://a.hygo.ai/api';
53const STATS_TZ = 'America/Los_Angeles';
54const STATS_TTL_SECONDS = 60;
55// Projects whose live traffic the portfolio charts. Key is the URL slug.
56const STATS_SITES: Record<string, { siteId: number; since: string }> = {
57 spiritfacts: { siteId: 3, since: '2026-06-01' },
58};
59const SITEVERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
60
61type SiteverifyResult = {
62 success: boolean;
63 'error-codes'?: string[];
64 hostname?: string;
65 action?: string;
66};
67
68function log(level: 'info' | 'warn' | 'error', event: string, req: Request, extra: Record<string, unknown> = {}) {
69 const cf = (req as Request & { cf?: IncomingRequestCfProperties }).cf;
70 const line = JSON.stringify({
71 level,
72 event,
73 path: new URL(req.url).pathname,
74 method: req.method,
75 country: cf?.country,
76 colo: cf?.colo,
77 ua: req.headers.get('user-agent')?.slice(0, 160),
78 ...extra,
79 });
80 if (level === 'error') console.error(line);
81 else if (level === 'warn') console.warn(line);
82 else console.log(line);
83}
84
85const enc = new TextEncoder();
86
87function b64url(bytes: ArrayBuffer): string {
88 let s = '';
89 for (const b of new Uint8Array(bytes)) s += String.fromCharCode(b);
90 return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
91}
92
93function fromB64url(s: string): Uint8Array | null {
94 try {
95 const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/'));
96 return Uint8Array.from(bin, (c) => c.charCodeAt(0));
97 } catch {
98 return null;
99 }
100}
101
102function hmacKey(secret: string) {
103 return crypto.subtle.importKey('raw', enc.encode(`henslee-gate:${secret}`), { name: 'HMAC', hash: 'SHA-256' }, false, [
104 'sign',
105 'verify',
106 ]);
107}
108
109async function makePass(secret: string): Promise<string> {
110 const payload = `v1.${Math.floor(Date.now() / 1000) + PASS_TTL_SECONDS}`;
111 const sig = await crypto.subtle.sign('HMAC', await hmacKey(secret), enc.encode(payload));
112 return `${payload}.${b64url(sig)}`;
113}
114
115function readCookie(req: Request, name: string): string | null {
116 const header = req.headers.get('cookie');
117 if (!header) return null;
118 for (const part of header.split(';')) {
119 const i = part.indexOf('=');
120 if (i > -1 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
121 }
122 return null;
123}
124
125async function hasValidPass(req: Request, env: Env): Promise<boolean> {
126 const value = readCookie(req, COOKIE);
127 if (!value) return false;
128 const [version, exp, sig] = value.split('.');
129 if (version !== 'v1' || !exp || !sig) return false;
130 if (!/^\d+$/.test(exp) || Number(exp) < Date.now() / 1000) return false;
131 const sigBytes = fromB64url(sig);
132 if (!sigBytes) return false;
133 // subtle.verify compares in constant time.
134 return crypto.subtle.verify('HMAC', await hmacKey(env.TURNSTILE_SECRET), sigBytes, enc.encode(`${version}.${exp}`));
135}
136
137function isTestSecret(secret: string) {
138 // Cloudflare's documented test secrets (1x.., 2x.., 3x..) report hostname "example.com".
139 return /^[123]x0{20,}/.test(secret);
140}
141
142function json(body: unknown, status: number, headers: HeadersInit = {}) {
143 return new Response(JSON.stringify(body), {
144 status,
145 headers: { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers },
146 });
147}
148
149function noStore(res: Response): Response {
150 const out = new Response(res.body, res);
151 out.headers.set('cache-control', 'private, no-store');
152 out.headers.set('vary', 'cookie');
153 // "/" can be either page, so an asset ETag would describe the wrong one.
154 out.headers.delete('etag');
155 return out;
156}
157
158async function verify(req: Request, env: Env): Promise<Response> {
159 if (!env.TURNSTILE_SECRET) {
160 log('error', 'gate.misconfigured', req, { missing: 'TURNSTILE_SECRET' });
161 return json({ ok: false, codes: ['server-misconfigured'] }, 500);
162 }
163 if (!req.headers.get('content-type')?.includes('application/json')) {
164 return json({ ok: false, codes: ['bad-content-type'] }, 415);
165 }
166 const body = (await req.json().catch(() => null)) as { token?: unknown } | null;
167 const token = body?.token;
168 if (typeof token !== 'string' || token.length === 0 || token.length > 2048) {
169 log('warn', 'gate.bad_request', req);
170 return json({ ok: false, codes: ['missing-token'] }, 400);
171 }
172
173 const form = new FormData();
174 form.append('secret', env.TURNSTILE_SECRET);
175 form.append('response', token);
176 const ip = req.headers.get('cf-connecting-ip');
177 if (ip) form.append('remoteip', ip);
178 form.append('idempotency_key', crypto.randomUUID());
179
180 let result: SiteverifyResult;
181 const started = Date.now();
182 try {
183 const r = await fetch(SITEVERIFY, { method: 'POST', body: form });
184 result = (await r.json()) as SiteverifyResult;
185 } catch (err) {
186 log('error', 'gate.siteverify_unreachable', req, { error: String(err) });
187 return json({ ok: false, codes: ['siteverify-unreachable'] }, 502);
188 }
189 const ms = Date.now() - started;
190
191 const host = new URL(req.url).hostname;
192 const hostOk = isTestSecret(env.TURNSTILE_SECRET) || !result.hostname || result.hostname === host;
193 if (!result.success || !hostOk) {
194 log('warn', 'gate.rejected', req, { codes: result['error-codes'], hostname: result.hostname, ms });
195 return json({ ok: false, codes: result['error-codes'] ?? (hostOk ? [] : ['hostname-mismatch']) }, 403);
196 }
197
198 log('info', 'gate.passed', req, { action: result.action, ms });
199 const secure = new URL(req.url).protocol === 'https:' || host === 'localhost';
200 const cookie = [
201 `${COOKIE}=${await makePass(env.TURNSTILE_SECRET)}`,
202 'Path=/',
203 `Max-Age=${PASS_TTL_SECONDS}`,
204 'HttpOnly',
205 'SameSite=Lax',
206 secure ? 'Secure' : '',
207 ]
208 .filter(Boolean)
209 .join('; ');
210 return json({ ok: true }, 200, { 'set-cookie': cookie });
211}
212
213// Small per-visitor rate limit backed by the edge cache (approximate, per colo).
214async function rateLimited(ip: string, bucket: string, limit: number, windowSeconds: number) {
215 const cache = caches.default;
216 const slot = Math.floor(Date.now() / 1000 / windowSeconds);
217 const key = new Request(`https://henslee.me/__rl/${bucket}/${encodeURIComponent(ip)}/${slot}`);
218 const hit = await cache.match(key);
219 const count = hit ? Number(await hit.text()) || 0 : 0;
220 if (count >= limit) return true;
221 await cache.put(key, new Response(String(count + 1), { headers: { 'cache-control': `public, max-age=${windowSeconds}` } }));
222 return false;
223}
224
225// AI console: answers questions about Lane with DeepSeek, grounded in the
226// profile built from the site's data (the /llms.txt text plus personal details). The key never leaves the
227// worker; only gate-passed visitors can call it; replies stream as plain text.
228async function serveAi(req: Request, env: Env): Promise<Response> {
229 if (!(await hasValidPass(req, env))) return json({ ok: false, error: 'gate' }, 403);
230 if (!env.DEEPSEEK_API_KEY) {
231 log('error', 'ai.misconfigured', req, { missing: 'DEEPSEEK_API_KEY' });
232 return json({ ok: false, error: 'server-misconfigured' }, 500);
233 }
234 const ip = req.headers.get('cf-connecting-ip') ?? 'unknown';
235 if (
236 (await rateLimited(ip, 'ai-m', AI_LIMITS.perMinute, 60)) ||
237 (await rateLimited(ip, 'ai-h', AI_LIMITS.perHour, 3600))
238 ) {
239 log('warn', 'ai.rate_limited', req);
240 return json({ ok: false, error: 'rate-limited' }, 429);
241 }
242
243 type Msg = { role: 'user' | 'assistant'; content: string };
244 const body = (await req.json().catch(() => null)) as { messages?: Msg[] } | null;
245 const history = (body?.messages ?? [])
246 .filter((m) => (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string')
247 .slice(-AI_LIMITS.maxTurns)
248 .map((m) => ({ role: m.role, content: m.content.slice(0, AI_LIMITS.maxHistoryChars) }));
249 const last = history[history.length - 1];
250 if (!last || last.role !== 'user' || !last.content.trim() || last.content.length > AI_LIMITS.maxChars) {
251 return json({ ok: false, error: 'bad-request' }, 400);
252 }
253
254 const started = Date.now();
255 const upstream = await fetch(DEEPSEEK, {
256 method: 'POST',
257 headers: { authorization: `Bearer ${env.DEEPSEEK_API_KEY}`, 'content-type': 'application/json' },
258 body: JSON.stringify({
259 model: 'deepseek-chat',
260 stream: true,
261 temperature: 0.4,
262 max_tokens: AI_LIMITS.maxTokens,
263 messages: [{ role: 'system', content: AI_RULES + PROFILE }, ...history],
264 }),
265 });
266 if (!upstream.ok || !upstream.body) {
267 log('error', 'ai.upstream_failed', req, { status: upstream.status, ms: Date.now() - started });
268 return json({ ok: false, error: 'upstream' }, 502);
269 }
270 log('info', 'ai.chat', req, { turns: history.length, chars: last.content.length });
271
272 // DeepSeek streams OpenAI-style SSE; pass along just the text deltas.
273 const decoder = new TextDecoder();
274 const encoder = new TextEncoder();
275 let buffer = '';
276 const stream = upstream.body.pipeThrough(
277 new TransformStream<Uint8Array, Uint8Array>({
278 transform(chunk, controller) {
279 buffer += decoder.decode(chunk, { stream: true });
280 const lines = buffer.split('\n');
281 buffer = lines.pop() ?? '';
282 for (const line of lines) {
283 const data = line.startsWith('data:') ? line.slice(5).trim() : '';
284 if (!data || data === '[DONE]') continue;
285 try {
286 const text = JSON.parse(data).choices?.[0]?.delta?.content;
287 if (text) controller.enqueue(encoder.encode(text));
288 } catch {
289 /* partial or keep-alive line */
290 }
291 }
292 },
293 flush() {
294 log('info', 'ai.done', req, { ms: Date.now() - started });
295 },
296 }),
297 );
298 return new Response(stream, {
299 headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' },
300 });
301}
302
303// Live traffic for a project card: daily visitors since launch, totals, and
304// visitors on the site right now. The Rybbit key stays here; the page only
305// gets aggregates. Only visitors who passed the gate can call it, and results
306// are cached at the edge for a minute.
307async function serveStats(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
308 const slug = new URL(req.url).pathname.slice(STATS_PREFIX.length);
309 const site = STATS_SITES[slug];
310 if (!site) return json({ ok: false, error: 'unknown-site' }, 404);
311 if (!(await hasValidPass(req, env))) return json({ ok: false, error: 'gate' }, 403);
312 if (!env.RYBBIT_API_KEY) {
313 log('error', 'stats.misconfigured', req, { missing: 'RYBBIT_API_KEY' });
314 return json({ ok: false, error: 'server-misconfigured' }, 500);
315 }
316
317 const cache = caches.default;
318 const cacheKey = new Request(`https://henslee.me/__stats-cache/${slug}`);
319 const hit = await cache.match(cacheKey);
320 if (hit) {
321 log('info', 'stats.cache_hit', req, { slug });
322 return new Response(hit.body, hit);
323 }
324
325 const today = new Date().toLocaleDateString('en-CA', { timeZone: STATS_TZ });
326 const range = `start_date=${site.since}&end_date=${today}&time_zone=${encodeURIComponent(STATS_TZ)}`;
327 const get = async (path: string) => {
328 const r = await fetch(`${RYBBIT}/sites/${site.siteId}${path}`, {
329 headers: { authorization: `Bearer ${env.RYBBIT_API_KEY}` },
330 });
331 if (!r.ok) throw new Error(`${path.split('?')[0]} ${r.status}`);
332 return r.json() as Promise<Record<string, unknown>>;
333 };
334
335 const started = Date.now();
336 try {
337 const [series, overview, live] = await Promise.all([
338 get(`/overview/time-series?${range}&bucket=day`),
339 get(`/overview?${range}`),
340 get(`/live-user-count?minutes=5`),
341 ]);
342 type Day = { time: string; users: number; pageviews: number };
343 const days = ((series.data as Day[]) ?? []).map((d) => ({
344 date: d.time.slice(0, 10),
345 users: d.users ?? 0,
346 pageviews: d.pageviews ?? 0,
347 }));
348 const totals = overview.data as { users: number; pageviews: number; pages_per_session: number };
349 const body = {
350 ok: true,
351 since: site.since,
352 updated: new Date().toISOString(),
353 live: (live.count as number) ?? 0,
354 totals: { users: totals.users, pageviews: totals.pageviews, pagesPerVisit: totals.pages_per_session },
355 days,
356 };
357 log('info', 'stats.fetched', req, { slug, days: days.length, ms: Date.now() - started });
358 const res = json(body, 200, { 'cache-control': `public, max-age=${STATS_TTL_SECONDS}` });
359 ctx.waitUntil(cache.put(cacheKey, res.clone()));
360 // The browser copy should not be shared or cached past the edge minute.
361 const out = new Response(res.body, res);
362 out.headers.set('cache-control', 'private, max-age=30');
363 return out;
364 } catch (err) {
365 log('error', 'stats.upstream_failed', req, { slug, error: String(err), ms: Date.now() - started });
366 return json({ ok: false, error: 'upstream' }, 502);
367 }
368}
369
370async function serveGate(req: Request, env: Env): Promise<Response> {
371 const asset = await env.ASSETS.fetch(new Request(new URL('/gate', req.url), { headers: req.headers }));
372 if (!asset.ok) {
373 log('error', 'gate.asset_missing', req, { status: asset.status });
374 return new Response('The security check page is missing from this deploy. Rebuild and redeploy the site.', {
375 status: 500,
376 });
377 }
378 const page = new HTMLRewriter()
379 .on('#widget', {
380 element(el) {
381 if (env.TURNSTILE_SITE_KEY) el.setAttribute('data-sitekey', env.TURNSTILE_SITE_KEY);
382 },
383 })
384 .transform(asset);
385 return noStore(page);
386}
387
388export default {
389 async fetch(req, env, ctx): Promise<Response> {
390 const url = new URL(req.url);
391 try {
392 if (url.pathname === AI_PATH) {
393 if (req.method !== 'POST') return json({ ok: false, error: 'method-not-allowed' }, 405, { allow: 'POST' });
394 return await serveAi(req, env);
395 }
396
397 if (url.pathname.startsWith(STATS_PREFIX)) {
398 if (req.method !== 'GET') return json({ ok: false, error: 'method-not-allowed' }, 405, { allow: 'GET' });
399 return await serveStats(req, env, ctx);
400 }
401
402 if (url.pathname === VERIFY_PATH) {
403 if (req.method !== 'POST') return json({ ok: false, codes: ['method-not-allowed'] }, 405, { allow: 'POST' });
404 return await verify(req, env);
405 }
406
407 if (GATE_PAGE_PATHS.has(url.pathname)) {
408 return Response.redirect(new URL('/', url).toString(), 302);
409 }
410
411 if (GATED_PATHS.has(url.pathname)) {
412 if (await hasValidPass(req, env)) {
413 log('info', 'gate.cookie_ok', req);
414 return noStore(await env.ASSETS.fetch(req));
415 }
416 log('info', 'gate.challenge_served', req, { had_cookie: readCookie(req, COOKIE) !== null });
417 return await serveGate(req, env);
418 }
419
420 return env.ASSETS.fetch(req);
421 } catch (err) {
422 log('error', 'worker.unhandled', req, { error: String(err), stack: (err as Error)?.stack });
423 return new Response('Something went wrong loading this page. Refresh to try again.', { status: 500 });
424 }
425 },
426} satisfies ExportedHandler<Env>;