| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | import { personalText, profileText } from '../src/data/profile'; |
| 15 | |
| 16 | interface 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 | |
| 24 | const COOKIE = 'henslee_pass'; |
| 25 | const PASS_TTL_SECONDS = 60 * 60 * 24 * 30; |
| 26 | const GATED_PATHS = new Set(['/', '/index', '/index.html']); |
| 27 | |
| 28 | const GATE_PAGE_PATHS = new Set(['/gate', '/gate.html']); |
| 29 | const VERIFY_PATH = '/__gate/verify'; |
| 30 | const STATS_PREFIX = '/__stats/'; |
| 31 | const AI_PATH = '/__ai/chat'; |
| 32 | const DEEPSEEK = 'https://api.deepseek.com/chat/completions'; |
| 33 | const AI_LIMITS = { perMinute: 12, perHour: 60, maxTurns: 16, maxChars: 600, maxHistoryChars: 2000, maxTokens: 600 }; |
| 34 | const 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 | |
| 36 | Rules: |
| 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 | |
| 47 | PROFILE |
| 48 | `; |
| 49 | |
| 50 | |
| 51 | const PROFILE = `${profileText()}\n${personalText()}`; |
| 52 | const RYBBIT = 'https://a.hygo.ai/api'; |
| 53 | const STATS_TZ = 'America/Los_Angeles'; |
| 54 | const STATS_TTL_SECONDS = 60; |
| 55 | |
| 56 | const STATS_SITES: Record<string, { siteId: number; since: string }> = { |
| 57 | spiritfacts: { siteId: 3, since: '2026-06-01' }, |
| 58 | }; |
| 59 | const SITEVERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; |
| 60 | |
| 61 | type SiteverifyResult = { |
| 62 | success: boolean; |
| 63 | 'error-codes'?: string[]; |
| 64 | hostname?: string; |
| 65 | action?: string; |
| 66 | }; |
| 67 | |
| 68 | function 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 | |
| 85 | const enc = new TextEncoder(); |
| 86 | |
| 87 | function 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 | |
| 93 | function 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 | |
| 102 | function 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 | |
| 109 | async 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 | |
| 115 | function 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 | |
| 125 | async 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 | |
| 137 | function 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 | |
| 142 | function 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 | |
| 149 | function 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 | |
| 158 | async 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). |
| 214 | async 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: |
| 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 | |
| 226 | |
| 227 | |
| 228 | async 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 | |
| 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 | |
| 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 | |
| 304 | |
| 305 | |
| 306 | |
| 307 | async 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: |
| 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 | |
| 370 | async 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 | |
| 388 | export 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>; |