diff --git a/package-lock.json b/package-lock.json index fedca0d..a657c0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.2.8", "@fontsource/source-code-pro": "^5.2.7", + "@hono/node-server": "^1.19.9", "@preact/preset-vite": "^2.10.3", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", @@ -18,8 +19,12 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", + "@teekit/kettle": "file:../teekitpriv/packages/kettle", + "@teekit/kettle-sgx": "file:../teekitpriv/packages/kettle-sgx", + "@teekit/tunnel": "file:../teekitpriv/packages/tunnel", "dompurify": "^3.3.1", "dotenv": "^17.3.1", + "hono": "^4.12.3", "lucide-react": "^0.575.0", "marked": "^17.0.3", "preact": "^10.28.3" @@ -32,6 +37,95 @@ "vite": "^7.3.1" } }, + "../teekitpriv/packages/kettle": { + "name": "@teekit/kettle", + "version": "0.0.5", + "dependencies": { + "@scure/base": "^2.0.0", + "@teekit/qvl": "0.0.5", + "@teekit/tunnel": "0.0.5", + "cbor-x": "^1.6.0", + "colorette": "^2.0.20", + "hono": "^4.7.5", + "yargs": "^18.0.0" + }, + "bin": { + "kettle": "bin/kettle.mjs" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@types/node": "^24.3.1", + "@types/ws": "^8.18.1", + "@types/yargs": "^17.0.34", + "ava": "^6.4.1", + "esbuild": "^0.24.0", + "eslint": "^9.33.0", + "globals": "^16.3.0", + "tsx": "^4.20.5", + "typescript": "^5.9.2", + "workerd": "^1.20251105.0", + "ws": "^8.18.3" + }, + "optionalDependencies": { + "@esbuild/linux-x64": "^0.25.12", + "@rollup/rollup-linux-x64-gnu": "^4.50.1" + } + }, + "../teekitpriv/packages/kettle-sgx": { + "name": "@teekit/kettle-sgx", + "version": "0.0.1", + "dependencies": { + "@noble/hashes": "^1.7.0", + "@scure/base": "^2.0.0" + }, + "bin": { + "kettle-sgx": "scripts/kettle-sgx.sh" + }, + "devDependencies": { + "@types/node": "^24.3.1", + "@types/ws": "^8.18.1", + "ava": "^6.4.1", + "colorette": "^2.0.20", + "esbuild": "^0.24.0", + "tsx": "^4.20.5", + "typescript": "^5.9.2", + "ws": "^8.18.3" + } + }, + "../teekitpriv/packages/tunnel": { + "name": "@teekit/tunnel", + "version": "0.0.5", + "dependencies": { + "@cloudflare/workers-types": "^4.20251011.0", + "@noble/ciphers": "^2.0.1", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^1.8.0", + "@teekit/qvl": "0.0.5", + "cbor-x": "^1.6.0", + "isomorphic-ws": "^5.0.0", + "node-mocks-http": "^1.17.2", + "uint8array-extras": "^1.5.0", + "ws": "^8.18.3" + }, + "devDependencies": { + "@hono/node-server": "^1.19.5", + "@hono/node-ws": "^1.2.0", + "@types/debug": "^4.1.12", + "@types/express": "^5.0.3", + "@types/node": "^24.3.1", + "@types/ws": "^8.18.1", + "ava": "^6.4.1", + "concurrently": "^9.2.1", + "esbuild": "^0.24.0", + "express": "^5.1.0", + "hono": "^4.6.9", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -961,6 +1055,18 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2189,6 +2295,18 @@ "win32" ] }, + "node_modules/@teekit/kettle": { + "resolved": "../teekitpriv/packages/kettle", + "link": true + }, + "node_modules/@teekit/kettle-sgx": { + "resolved": "../teekitpriv/packages/kettle-sgx", + "link": true + }, + "node_modules/@teekit/tunnel": { + "resolved": "../teekitpriv/packages/tunnel", + "link": true + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2579,6 +2697,15 @@ "he": "bin/he" } }, + "node_modules/hono": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", + "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 7bc7184..f4fa666 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "server": "tsx server/index.ts", "dev": "vite", "build": "tsc && tsc -p tsconfig.server.json && vite build", + "build:worker": "tsc -p tsconfig.server.json && kettle build-app server/index.ts && kettle build-worker", "preview": "vite preview", "deploy": "fly deploy", "lint": "biome check .", @@ -22,6 +23,10 @@ "dependencies": { "@fontsource-variable/inter": "^5.2.8", "@fontsource/source-code-pro": "^5.2.7", + "@hono/node-server": "^1.19.9", + "@teekit/kettle": "file:../teekitpriv/packages/kettle", + "@teekit/kettle-sgx": "file:../teekitpriv/packages/kettle-sgx", + "@teekit/tunnel": "file:../teekitpriv/packages/tunnel", "@preact/preset-vite": "^2.10.3", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", @@ -32,6 +37,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "dompurify": "^3.3.1", "dotenv": "^17.3.1", + "hono": "^4.12.3", "lucide-react": "^0.575.0", "marked": "^17.0.3", "preact": "^10.28.3" diff --git a/server/config.ts b/server/config.ts index 9924b73..414f5ff 100644 --- a/server/config.ts +++ b/server/config.ts @@ -15,6 +15,3 @@ export const GITHUB_FETCH_TIMEOUT_MS = 15_000; export const MAX_BODY_BYTES = 2 * 1024 * 1024; // 2 MB export const ALLOWED_ORIGINS = new Set(['https://input.md', `http://localhost:${CLIENT_PORT}`]); - -export const CONTENT_SECURITY_POLICY = - "default-src 'self'; script-src 'self' 'sha256-wBdtWdXsHnAU2DdByySW4LlXFAScrBvmBgkXtydwJdg='; style-src 'self' 'unsafe-inline'; img-src 'self' https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com https://gist.githubusercontent.com; font-src 'self'"; diff --git a/server/cors.ts b/server/cors.ts index 41f8394..6162559 100644 --- a/server/cors.ts +++ b/server/cors.ts @@ -1,14 +1,10 @@ -import type http from 'node:http'; +import { cors } from 'hono/cors'; import { ALLOWED_ORIGINS } from './config'; -export function applyCors(req: http.IncomingMessage, res: http.ServerResponse): void { - const origin = req.headers.origin; - res.setHeader('Vary', 'Origin'); - if (origin && ALLOWED_ORIGINS.has(origin)) { - res.setHeader('Access-Control-Allow-Origin', origin); - res.setHeader('Access-Control-Allow-Credentials', 'true'); - res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization'); - res.setHeader('Access-Control-Max-Age', '600'); - } -} +export const corsMiddleware = cors({ + origin: (origin) => (ALLOWED_ORIGINS.has(origin) ? origin : ''), + credentials: true, + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'], + maxAge: 600, +}); diff --git a/server/errors.ts b/server/errors.ts deleted file mode 100644 index 4965155..0000000 --- a/server/errors.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class ClientError extends Error { - statusCode: number; - - constructor(message: string, statusCode = 400) { - super(message); - this.name = 'ClientError'; - this.statusCode = statusCode; - } -} diff --git a/server/github_client.ts b/server/github_client.ts index c6b9162..6558b5b 100644 --- a/server/github_client.ts +++ b/server/github_client.ts @@ -1,7 +1,7 @@ import crypto from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { GITHUB_FETCH_TIMEOUT_MS } from './config'; -import { ClientError } from './errors'; +import { HTTPException } from 'hono/http-exception'; import { base64url, requireEnv } from './http_helpers'; import type { TokenCacheRecord } from './types'; @@ -95,7 +95,7 @@ async function getInstallationToken(installationId: string, repositoryIds?: numb if (!res.ok) { const body = await res.text().catch(() => ''); console.error(`Failed to mint installation token: ${res.status} ${res.statusText}`, body); - throw new ClientError(`GitHub API error: ${res.status}`, 502); + throw new HTTPException(502, { message: `GitHub API error: ${res.status}` }); } const data = (await res.json()) as { token: string; expires_at: string }; @@ -135,7 +135,7 @@ export async function githubFetchWithInstallationToken( const body = await res.text().catch(() => ''); console.error(`GitHub API error on ${ghPath}: ${res.status} ${res.statusText}`, body); const statusCode = res.status >= 400 && res.status < 500 ? res.status : 502; - throw new ClientError(`GitHub API error: ${res.status}`, statusCode); + throw new HTTPException(statusCode as 400, { message: `GitHub API error: ${res.status}` }); } return res; diff --git a/server/http_helpers.ts b/server/http_helpers.ts index 57a827f..9207095 100644 --- a/server/http_helpers.ts +++ b/server/http_helpers.ts @@ -1,31 +1,4 @@ -import type http from 'node:http'; -import { MAX_BODY_BYTES } from './config'; -import { ClientError } from './errors'; - -export function json(res: http.ServerResponse, statusCode: number, data: unknown): void { - res.writeHead(statusCode, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(data)); -} - -export async function readJson(req: http.IncomingMessage): Promise | null> { - const chunks: Buffer[] = []; - let totalBytes = 0; - - for await (const chunk of req) { - totalBytes += (chunk as Buffer).length; - if (totalBytes > MAX_BODY_BYTES) throw new ClientError('Request body too large'); - chunks.push(chunk as Buffer); - } - - const raw = Buffer.concat(chunks).toString('utf8'); - if (!raw) return null; - - try { - return JSON.parse(raw) as Record; - } catch { - throw new ClientError('Invalid JSON body'); - } -} +import { HTTPException } from 'hono/http-exception'; export function requireEnv(name: string): string { const value = process.env[name]; @@ -35,7 +8,7 @@ export function requireEnv(name: string): string { export function requireString(body: Record | null, key: string): string { const value = body?.[key]; - if (typeof value !== 'string' || !value.trim()) throw new ClientError(`${key} is required`); + if (typeof value !== 'string' || !value.trim()) throw new HTTPException(400, { message: `${key} is required` }); return value; } diff --git a/server/index.ts b/server/index.ts index 118d0a2..163702a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,56 +1,92 @@ import './env'; -import http from 'node:http'; -import { PORT } from './config'; -import { applyCors } from './cors'; -import { ClientError } from './errors'; +import type http from 'node:http'; +import { getRequestListener, serve } from '@hono/node-server'; +import type { QuoteData } from '@teekit/tunnel'; +import { TunnelServer } from '@teekit/tunnel'; +import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import { HTTPException } from 'hono/http-exception'; +import { MAX_BODY_BYTES, PORT } from './config'; +import { corsMiddleware } from './cors'; import { startGistCacheCleanup } from './gist_cache'; import { startInstallationTokenCacheCleanup } from './github_client'; -import { json } from './http_helpers'; -import { startRateLimitCleanup } from './rate_limit'; -import { handleApiRequest } from './routes'; -import { applySecurityHeaders } from './security_headers'; +import { rateLimitMiddleware, startRateLimitCleanup } from './rate_limit'; +import { api } from './routes'; +import { securityHeaders } from './security_headers'; import { startSessionCleanup } from './session'; -import { serveStatic } from './static_files'; +import { serveStaticMiddleware, spaFallback } from './static_files'; startInstallationTokenCacheCleanup(); startGistCacheCleanup(); startRateLimitCleanup(); startSessionCleanup(); -const server = http.createServer(async (req: http.IncomingMessage, res: http.ServerResponse) => { - try { - applySecurityHeaders(res); - applyCors(req, res); +const app = new Hono(); - if (req.method === 'OPTIONS') { - res.end(); - return; - } +// Global middleware +app.use('*', securityHeaders); +app.use('*', corsMiddleware); - const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); - const pathname = url.pathname; +// Rate limiting and body size limit on API routes +app.use('/api/*', rateLimitMiddleware); +app.use('/api/*', bodyLimit({ maxSize: MAX_BODY_BYTES })); - const handledApi = await handleApiRequest(req, res, url, pathname); - if (handledApi) return; +// API routes +app.route('/api', api); - if (req.method === 'GET') { - if (await serveStatic(res, pathname)) return; - if (await serveStatic(res, '/index.html')) return; - } +// Static files + SPA fallback +app.use('*', serveStaticMiddleware); +app.use('*', spaFallback); - json(res, 404, { error: 'Not found' }); - } catch (err) { - if (err instanceof ClientError) { - json(res, err.statusCode, { error: err.message }); - return; - } - - console.error('Unhandled server error:', err); - json(res, 500, { error: 'Internal server error' }); +// Error handler +app.onError((err, c) => { + if (err instanceof HTTPException) { + return c.json({ error: err.message }, err.status); } + console.error('Unhandled server error:', err); + return c.json({ error: 'Internal server error' }, 500); }); -server.listen(PORT, '0.0.0.0', () => { +// Convert Hono app to a Node.js-compatible request listener for TunnelServer +const nodeHandler = getRequestListener(app.fetch); + +async function startServer(): Promise { + const getQuote = process.env.TEEKIT_GET_QUOTE_URL; + + if (getQuote) { + // Running inside a TEE — use TunnelServer for encrypted channels + const tunnelServer = await TunnelServer.initialize( + nodeHandler as any, + async (x25519PublicKey: Uint8Array): Promise => { + const res = await fetch(getQuote, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + report_data: Buffer.from(x25519PublicKey).toString('base64'), + }), + }); + if (!res.ok) { + throw new Error(`Failed to get quote: ${res.status} ${res.statusText}`); + } + return (await res.json()) as QuoteData; + }, + ); + return new Promise((resolve) => { + tunnelServer.server.listen(PORT, '0.0.0.0', () => { + resolve(tunnelServer.server); + }); + }); + } + + // Standard mode — use Hono's built-in serve + return new Promise((resolve) => { + const s = serve({ fetch: app.fetch, port: PORT, hostname: '0.0.0.0' }, () => { + resolve(s); + }); + }); +} + +startServer().then((server) => { const configured = Boolean( process.env.GITHUB_APP_ID && (process.env.GITHUB_APP_PRIVATE_KEY || process.env.GITHUB_APP_PRIVATE_KEY_PATH) && @@ -58,14 +94,15 @@ server.listen(PORT, '0.0.0.0', () => { process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET, ); - console.log(`GitHub App auth server listening on http://0.0.0.0:${PORT} (configured=${configured})`); -}); + const mode = process.env.TEEKIT_GET_QUOTE_URL ? 'teekit' : 'standard'; + console.log(`GitHub App auth server listening on http://0.0.0.0:${PORT} (configured=${configured}, mode=${mode})`); -function gracefulShutdown(signal: string): void { - console.log(`\n${signal} received, shutting down gracefully...`); - server.close(() => process.exit(0)); - setTimeout(() => process.exit(1), 5000).unref(); -} + function gracefulShutdown(signal: string): void { + console.log(`\n${signal} received, shutting down gracefully...`); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(1), 5000).unref(); + } -process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); -process.on('SIGINT', () => gracefulShutdown('SIGINT')); + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); + process.on('SIGINT', () => gracefulShutdown('SIGINT')); +}); diff --git a/server/rate_limit.ts b/server/rate_limit.ts index 63317bc..8ea7295 100644 --- a/server/rate_limit.ts +++ b/server/rate_limit.ts @@ -1,5 +1,5 @@ -import type http from 'node:http'; -import { json } from './http_helpers'; +import { getConnInfo } from '@hono/node-server/conninfo'; +import { createMiddleware } from 'hono/factory'; import type { RateLimitEntry } from './types'; const RATE_LIMIT_MAX = 30; @@ -20,24 +20,24 @@ export function startRateLimitCleanup(): void { ).unref(); } -function getClientIp(req: http.IncomingMessage): string { - const forwarded = req.headers['x-forwarded-for']; - if (typeof forwarded === 'string') { +function getClientIp(c: Parameters[0]>[0]): string { + const forwarded = c.req.header('x-forwarded-for'); + if (forwarded) { const first = forwarded.split(',')[0].trim(); if (first) return first; } - return req.socket.remoteAddress || 'unknown'; + const info = getConnInfo(c); + return info.remote.address || 'unknown'; } -export function checkRateLimit(req: http.IncomingMessage, res: http.ServerResponse): boolean { - const ip = getClientIp(req); +export const rateLimitMiddleware = createMiddleware(async (c, next) => { + const ip = getClientIp(c); const now = Date.now(); let entry = rateLimitWindows.get(ip); if (!entry || now >= entry.resetAtMs) { if (!entry && rateLimitWindows.size >= MAX_RATE_LIMIT_ENTRIES) { - json(res, 429, { error: 'Too many requests' }); - return false; + return c.json({ error: 'Too many requests' }, 429); } entry = { count: 0, resetAtMs: now + RATE_LIMIT_WINDOW_MS }; rateLimitWindows.set(ip, entry); @@ -46,10 +46,9 @@ export function checkRateLimit(req: http.IncomingMessage, res: http.ServerRespon entry.count++; if (entry.count > RATE_LIMIT_MAX) { const retryAfter = Math.ceil((entry.resetAtMs - now) / 1000); - res.setHeader('Retry-After', String(retryAfter)); - json(res, 429, { error: 'Too many requests' }); - return false; + c.header('Retry-After', String(retryAfter)); + return c.json({ error: 'Too many requests' }, 429); } - return true; -} + await next(); +}); diff --git a/server/routes.ts b/server/routes.ts index 4221df4..90ae5f6 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1,10 +1,10 @@ -import type http from 'node:http'; +import { Hono } from 'hono'; +import type { Context } from 'hono'; import { APP_URL, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_FETCH_TIMEOUT_MS, GITHUB_TOKEN } from './config'; -import { ClientError } from './errors'; +import { HTTPException } from 'hono/http-exception'; import { getGistCacheEntry, isFresh, markRevalidated, setGistCacheEntry } from './gist_cache'; import { createAppJwt, encodePathPreserveSlashes, githubFetchWithInstallationToken } from './github_client'; -import { json, readJson, requireEnv, requireString } from './http_helpers'; -import { checkRateLimit } from './rate_limit'; +import { requireEnv, requireString } from './http_helpers'; import { clearRememberedInstallationForUser, consumeOAuthState, @@ -18,22 +18,6 @@ import { } from './session'; import type { Session } from './types'; -interface RouteContext { - req: http.IncomingMessage; - res: http.ServerResponse; - url: URL; - pathname: string; - match: RegExpMatchArray; -} - -type RouteHandler = (ctx: RouteContext) => Promise; - -interface RouteDef { - method: string; - pattern: RegExp; - handler: RouteHandler; -} - type OAuthTokenResponse = { access_token?: string; token_type?: string; @@ -45,21 +29,15 @@ type GitHubApiError = { message?: string; }; -function redirect(res: http.ServerResponse, location: string): void { - res.statusCode = 302; - res.setHeader('Location', location); - res.end(); -} - -function requestBaseUrl(req: http.IncomingMessage): string { - const proto = req.headers['x-forwarded-proto']; - const scheme = typeof proto === 'string' ? proto.split(',')[0].trim() : 'http'; - const host = req.headers.host ?? 'localhost'; +function requestBaseUrl(c: Context): string { + const proto = c.req.header('x-forwarded-proto'); + const scheme = proto ? proto.split(',')[0].trim() : 'http'; + const host = c.req.header('host') ?? 'localhost'; return `${scheme}://${host}`; } -function oauthBaseUrl(req: http.IncomingMessage): string { - return APP_URL || requestBaseUrl(req); +function oauthBaseUrl(c: Context): string { + return APP_URL || requestBaseUrl(c); } function normalizeReturnTo(raw: string | null): string { @@ -69,16 +47,16 @@ function normalizeReturnTo(raw: string | null): string { return raw; } -function requireAuthSession(ctx: RouteContext): Session { - const session = getSession(ctx.req); - if (!session) throw new ClientError('Unauthorized', 401); +function requireAuthSession(c: Context): Session { + const session = getSession(c); + if (!session) throw new HTTPException(401, { message: 'Unauthorized' }); return session; } -function requireMatchedInstallation(ctx: RouteContext, session: Session, matchIndex: number): string { - const installationId = ctx.match[matchIndex]; +function requireMatchedInstallation(c: Context, session: Session, paramName: string): string { + const installationId = c.req.param(paramName); if (!session.installationId || session.installationId !== installationId) { - throw new ClientError('Forbidden', 403); + throw new HTTPException(403, { message: 'Forbidden' }); } return installationId; } @@ -100,20 +78,19 @@ async function githubFetchWithUserToken(session: Session, path: string, init: Re } async function proxyGitHubJson( - ctx: RouteContext, + c: Context, session: Session, path: string, init: RequestInit = {}, -): Promise { +): Promise { const ghRes = await githubFetchWithUserToken(session, path, init); const data = (await ghRes.json().catch(() => null)) as unknown; if (!ghRes.ok) { const err = data as GitHubApiError | null; - if (ghRes.status === 401) throw new ClientError('Unauthorized', 401); - json(ctx.res, ghRes.status, { error: err?.message ?? 'GitHub API error' }); - return; + if (ghRes.status === 401) throw new HTTPException(401, { message: 'Unauthorized' }); + return c.json({ error: err?.message ?? 'GitHub API error' }, ghRes.status as 400); } - json(ctx.res, 200, data); + return c.json(data, 200); } async function fetchPublicGitHub(path: string, init: RequestInit = {}): Promise { @@ -131,46 +108,59 @@ async function fetchPublicGitHub(path: string, init: RequestInit = {}): Promise< }); } -async function handleAuthStart(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; +type GitTreeEntry = { path: string; type: string; sha: string }; + +function mdFilesFromTree(tree: GitTreeEntry[]): { name: string; path: string; sha: string }[] { + const files: { name: string; path: string; sha: string }[] = []; + for (const entry of tree) { + if (entry.type !== 'blob' || !entry.path.toLowerCase().endsWith('.md')) continue; + const slash = entry.path.lastIndexOf('/'); + files.push({ name: slash === -1 ? entry.path : entry.path.slice(slash + 1), path: entry.path, sha: entry.sha }); + } + files.sort((a, b) => a.path.localeCompare(b.path)); + return files; +} + +// --- Route handlers --- + +const api = new Hono(); + +// Auth routes + +api.get('/auth/github/start', async (c) => { if (!GITHUB_CLIENT_ID || !GITHUB_CLIENT_SECRET) { - json(ctx.res, 503, { error: 'GitHub OAuth is not configured' }); - return; + return c.json({ error: 'GitHub OAuth is not configured' }, 503); } - const returnTo = normalizeReturnTo(ctx.url.searchParams.get('return_to')); + const returnTo = normalizeReturnTo(c.req.query('return_to') ?? null); const state = createOAuthState(returnTo); - const redirectUri = `${oauthBaseUrl(ctx.req)}/api/auth/github/callback`; + const redirectUri = `${oauthBaseUrl(c)}/api/auth/github/callback`; console.log(`[auth] OAuth start: redirect_uri=${redirectUri}, return_to=${returnTo}`); const authUrl = new URL('https://github.com/login/oauth/authorize'); authUrl.searchParams.set('client_id', GITHUB_CLIENT_ID); authUrl.searchParams.set('redirect_uri', redirectUri); authUrl.searchParams.set('scope', 'gist read:user'); authUrl.searchParams.set('state', state); - redirect(ctx.res, authUrl.toString()); -} + return c.redirect(authUrl.toString(), 302); +}); -async function handleAuthCallback(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; +api.get('/auth/github/callback', async (c) => { if (!GITHUB_CLIENT_ID || !GITHUB_CLIENT_SECRET) { - json(ctx.res, 503, { error: 'GitHub OAuth is not configured' }); - return; + return c.json({ error: 'GitHub OAuth is not configured' }, 503); } - const code = ctx.url.searchParams.get('code'); - const state = ctx.url.searchParams.get('state'); + const code = c.req.query('code'); + const state = c.req.query('state'); if (!code || !state) { - json(ctx.res, 400, { error: 'Missing OAuth callback parameters' }); - return; + return c.json({ error: 'Missing OAuth callback parameters' }, 400); } const returnTo = consumeOAuthState(state); if (!returnTo) { - json(ctx.res, 400, { error: 'Invalid or expired OAuth state' }); - return; + return c.json({ error: 'Invalid or expired OAuth state' }, 400); } - const redirectUri = `${oauthBaseUrl(ctx.req)}/api/auth/github/callback`; + const redirectUri = `${oauthBaseUrl(c)}/api/auth/github/callback`; const tokenRes = await fetch('https://github.com/login/oauth/access_token', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, @@ -186,8 +176,10 @@ async function handleAuthCallback(ctx: RouteContext): Promise { const tokenData = (await tokenRes.json().catch(() => null)) as OAuthTokenResponse | null; if (!tokenRes.ok || !tokenData?.access_token) { - json(ctx.res, 502, { error: tokenData?.error_description ?? tokenData?.error ?? 'Failed to exchange OAuth code' }); - return; + return c.json( + { error: tokenData?.error_description ?? tokenData?.error ?? 'Failed to exchange OAuth code' }, + 502, + ); } const userRes = await fetch('https://api.github.com/user', { @@ -200,8 +192,7 @@ async function handleAuthCallback(ctx: RouteContext): Promise { signal: AbortSignal.timeout(GITHUB_FETCH_TIMEOUT_MS), }); if (!userRes.ok) { - json(ctx.res, 502, { error: 'Failed to fetch GitHub user profile' }); - return; + return c.json({ error: 'Failed to fetch GitHub user profile' }, 502); } const ghUser = (await userRes.json()) as { @@ -211,7 +202,7 @@ async function handleAuthCallback(ctx: RouteContext): Promise { name: string | null; }; - const session = createSession(ctx.res, { + const session = createSession(c, { githubUserId: ghUser.id, githubAccessToken: tokenData.access_token, githubLogin: ghUser.login, @@ -221,56 +212,117 @@ async function handleAuthCallback(ctx: RouteContext): Promise { }); console.log(`[auth] Created session for ${ghUser.login} (id=${session.id.slice(0, 8)}…), redirecting to ${returnTo}`); - redirect(ctx.res, returnTo); -} + return c.redirect(returnTo, 302); +}); -async function handleAuthSession(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const session = getSession(ctx.req); +api.get('/auth/session', (c) => { + const session = getSession(c); if (!session) { - const hasCookie = Boolean(ctx.req.headers.cookie?.includes('input_session_id')); + const cookieHeader = c.req.header('cookie') ?? ''; + const hasCookie = cookieHeader.includes('input_session_id'); console.log(`[auth] Session check: no valid session (cookie present=${hasCookie})`); - json(ctx.res, 200, { authenticated: false }); - return; + return c.json({ authenticated: false }, 200); } - refreshSession(session, ctx.res); - json(ctx.res, 200, { - authenticated: true, - user: { + refreshSession(session, c); + return c.json( + { + authenticated: true, + user: { + login: session.githubLogin, + avatar_url: session.githubAvatarUrl, + name: session.githubName, + }, + installationId: session.installationId, + }, + 200, + ); +}); + +api.post('/auth/logout', (c) => { + destroySession(c); + return c.json({ ok: true }, 200); +}); + +// GitHub user routes + +api.get('/github/user', (c) => { + const session = requireAuthSession(c); + return c.json( + { login: session.githubLogin, avatar_url: session.githubAvatarUrl, name: session.githubName, }, - installationId: session.installationId, + 200, + ); +}); + +// Gist routes (authenticated) + +api.get('/github/gists', async (c) => { + const session = requireAuthSession(c); + const qs = new URLSearchParams(); + const page = c.req.query('page') ?? '1'; + const perPage = c.req.query('per_page') ?? '30'; + qs.set('page', page); + qs.set('per_page', perPage); + return proxyGitHubJson(c, session, `/gists?${qs.toString()}`); +}); + +api.get('/github/gists/:id', async (c) => { + const session = requireAuthSession(c); + return proxyGitHubJson(c, session, `/gists/${encodeURIComponent(c.req.param('id'))}`); +}); + +api.post('/github/gists', async (c) => { + const session = requireAuthSession(c); + const body = await c.req.json(); + return proxyGitHubJson(c, session, '/gists', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body ?? {}), }); -} +}); -async function handleAuthLogout(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - // We only destroy the server-side session; the GitHub access token is not - // revoked. It remains valid until GitHub's own expiry or the user revokes - // it manually at https://github.com/settings/applications. - destroySession(ctx.req, ctx.res); - json(ctx.res, 200, { ok: true }); -} +api.patch('/github/gists/:id', async (c) => { + const session = requireAuthSession(c); + const body = await c.req.json(); + return proxyGitHubJson(c, session, `/gists/${encodeURIComponent(c.req.param('id'))}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body ?? {}), + }); +}); + +api.delete('/github/gists/:id', async (c) => { + const session = requireAuthSession(c); + const ghRes = await githubFetchWithUserToken(session, `/gists/${encodeURIComponent(c.req.param('id'))}`, { + method: 'DELETE', + }); + if (!ghRes.ok) { + const data = (await ghRes.json().catch(() => null)) as GitHubApiError | null; + if (ghRes.status === 401) throw new HTTPException(401, { message: 'Unauthorized' }); + return c.json({ error: data?.message ?? 'GitHub API error' }, ghRes.status as 400); + } + return c.json({ ok: true }, 200); +}); -async function handleInstallUrl(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; +// GitHub App routes + +api.get('/github-app/install-url', (c) => { const slug = requireEnv('GITHUB_APP_SLUG'); - const state = ctx.url.searchParams.get('state'); + const state = c.req.query('state'); const installUrl = new URL(`https://github.com/apps/${slug}/installations/new`); if (state) installUrl.searchParams.set('state', state); - json(ctx.res, 200, { url: installUrl.toString() }); -} + return c.json({ url: installUrl.toString() }, 200); +}); -async function handleCreateSession(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const session = requireAuthSession(ctx); - const body = await readJson(ctx.req); +api.post('/github-app/sessions', async (c) => { + const session = requireAuthSession(c); + const body = await c.req.json(); const installationId = body?.installationId; if (!installationId || typeof installationId !== 'string') { - json(ctx.res, 400, { error: 'installationId is required' }); - return; + return c.json({ error: 'installationId is required' }, 400); } const jwt = await createAppJwt(); @@ -287,94 +339,29 @@ async function handleCreateSession(ctx: RouteContext): Promise { if (!ghRes.ok) { clearRememberedInstallationForUser(session.githubUserId); session.installationId = null; - refreshSession(session, ctx.res); - json(ctx.res, 403, { error: 'Invalid installation' }); - return; + refreshSession(session, c); + return c.json({ error: 'Invalid installation' }, 403); } session.installationId = installationId; rememberInstallationForUser(session.githubUserId, installationId); - refreshSession(session, ctx.res); - json(ctx.res, 200, { installationId }); -} + refreshSession(session, c); + return c.json({ installationId }, 200); +}); -async function handleDisconnectInstallation(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const session = requireAuthSession(ctx); +api.post('/github-app/disconnect', (c) => { + const session = requireAuthSession(c); session.installationId = null; clearRememberedInstallationForUser(session.githubUserId); - refreshSession(session, ctx.res); - json(ctx.res, 200, { ok: true }); -} - -async function handleGitHubUser(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const session = requireAuthSession(ctx); - json(ctx.res, 200, { - login: session.githubLogin, - avatar_url: session.githubAvatarUrl, - name: session.githubName, - }); -} - -async function handleListGists(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const session = requireAuthSession(ctx); - const qs = new URLSearchParams(); - const page = ctx.url.searchParams.get('page') ?? '1'; - const perPage = ctx.url.searchParams.get('per_page') ?? '30'; - qs.set('page', page); - qs.set('per_page', perPage); - await proxyGitHubJson(ctx, session, `/gists?${qs.toString()}`); -} - -async function handleGetAuthedGist(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const session = requireAuthSession(ctx); - await proxyGitHubJson(ctx, session, `/gists/${encodeURIComponent(ctx.match[1])}`); -} - -async function handleCreateGist(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const session = requireAuthSession(ctx); - const body = await readJson(ctx.req); - await proxyGitHubJson(ctx, session, '/gists', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body ?? {}), - }); -} + refreshSession(session, c); + return c.json({ ok: true }, 200); +}); -async function handlePatchGist(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const session = requireAuthSession(ctx); - const body = await readJson(ctx.req); - await proxyGitHubJson(ctx, session, `/gists/${encodeURIComponent(ctx.match[1])}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body ?? {}), - }); -} +// Installation repos -async function handleDeleteGist(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const session = requireAuthSession(ctx); - const ghRes = await githubFetchWithUserToken(session, `/gists/${encodeURIComponent(ctx.match[1])}`, { - method: 'DELETE', - }); - if (!ghRes.ok) { - const data = (await ghRes.json().catch(() => null)) as GitHubApiError | null; - if (ghRes.status === 401) throw new ClientError('Unauthorized', 401); - json(ctx.res, ghRes.status, { error: data?.message ?? 'GitHub API error' }); - return; - } - json(ctx.res, 200, { ok: true }); -} - -async function handleListRepos(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const session = requireAuthSession(ctx); - const installationId = requireMatchedInstallation(ctx, session, 1); +api.get('/github-app/installations/:installationId/repositories', async (c) => { + const session = requireAuthSession(c); + const installationId = requireMatchedInstallation(c, session, 'installationId'); const allRepos: unknown[] = []; const MAX_PAGES = 50; @@ -390,18 +377,19 @@ async function handleListRepos(ctx: RouteContext): Promise { page++; } - json(ctx.res, 200, { total_count: allRepos.length, repositories: allRepos }); -} + return c.json({ total_count: allRepos.length, repositories: allRepos }, 200); +}); -async function handleGetContents(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const session = requireAuthSession(ctx); - const installationId = requireMatchedInstallation(ctx, session, 1); - const owner = ctx.match[2]; - const repo = ctx.match[3]; +// Repo contents (authenticated via installation) - const pathParam = ctx.url.searchParams.get('path') ?? ''; - const ref = ctx.url.searchParams.get('ref'); +api.get('/github-app/installations/:installationId/repos/:owner/:repo/contents', async (c) => { + const session = requireAuthSession(c); + const installationId = requireMatchedInstallation(c, session, 'installationId'); + const owner = c.req.param('owner'); + const repo = c.req.param('repo'); + + const pathParam = c.req.query('path') ?? ''; + const ref = c.req.query('ref'); const encodedPath = encodePathPreserveSlashes(pathParam); const ghPath = encodedPath ? `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}` @@ -411,25 +399,23 @@ async function handleGetContents(ctx: RouteContext): Promise { const data = (await ghRes.json().catch(() => null)) as unknown; if (!ghRes.ok) { const err = data as GitHubApiError | null; - if (ghRes.status === 401) throw new ClientError('Unauthorized', 401); - json(ctx.res, ghRes.status, { error: err?.message ?? 'GitHub API error' }); - return; + if (ghRes.status === 401) throw new HTTPException(401, { message: 'Unauthorized' }); + return c.json({ error: err?.message ?? 'GitHub API error' }, ghRes.status as 400); } - json(ctx.res, 200, data); -} + return c.json(data, 200); +}); -async function handlePutContents(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const session = requireAuthSession(ctx); - const installationId = requireMatchedInstallation(ctx, session, 1); - const owner = ctx.match[2]; - const repo = ctx.match[3]; +api.put('/github-app/installations/:installationId/repos/:owner/:repo/contents', async (c) => { + const session = requireAuthSession(c); + const installationId = requireMatchedInstallation(c, session, 'installationId'); + const owner = c.req.param('owner'); + const repo = c.req.param('repo'); - const body = await readJson(ctx.req); + const body = await c.req.json(); const pathParam = requireString(body, 'path'); const message = requireString(body, 'message'); const content = body?.content; - if (typeof content !== 'string') throw new ClientError('content is required'); + if (typeof content !== 'string') throw new HTTPException(400, { message: 'content is required' }); const sha = typeof body?.sha === 'string' ? body.sha : undefined; const branch = typeof body?.branch === 'string' ? body.branch : undefined; const ghPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodePathPreserveSlashes(pathParam)}`; @@ -438,17 +424,16 @@ async function handlePutContents(ctx: RouteContext): Promise { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, content, sha, branch }), }); - json(ctx.res, 200, await ghRes.json()); -} + return c.json(await ghRes.json(), 200); +}); -async function handleDeleteContents(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const session = requireAuthSession(ctx); - const installationId = requireMatchedInstallation(ctx, session, 1); - const owner = ctx.match[2]; - const repo = ctx.match[3]; +api.delete('/github-app/installations/:installationId/repos/:owner/:repo/contents', async (c) => { + const session = requireAuthSession(c); + const installationId = requireMatchedInstallation(c, session, 'installationId'); + const owner = c.req.param('owner'); + const repo = c.req.param('repo'); - const body = await readJson(ctx.req); + const body = await c.req.json(); const pathParam = requireString(body, 'path'); const message = requireString(body, 'message'); const sha = requireString(body, 'sha'); @@ -459,36 +444,56 @@ async function handleDeleteContents(ctx: RouteContext): Promise { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, sha, branch }), }); - json(ctx.res, 200, await ghRes.json()); -} + return c.json(await ghRes.json(), 200); +}); -async function handleGetRawContent(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const session = requireAuthSession(ctx); - const installationId = requireMatchedInstallation(ctx, session, 1); - const owner = ctx.match[2]; - const repo = ctx.match[3]; - const pathParam = ctx.url.searchParams.get('path'); - if (!pathParam) throw new ClientError('path is required'); +// Raw content (authenticated) + +api.get('/github-app/installations/:installationId/repos/:owner/:repo/raw', async (c) => { + const session = requireAuthSession(c); + const installationId = requireMatchedInstallation(c, session, 'installationId'); + const owner = c.req.param('owner'); + const repo = c.req.param('repo'); + const pathParam = c.req.query('path'); + if (!pathParam) throw new HTTPException(400, { message: 'path is required' }); const ghPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodePathPreserveSlashes(pathParam)}`; const ghRes = await githubFetchWithInstallationToken(installationId, ghPath, { headers: { Accept: 'application/vnd.github.raw' }, }); - ctx.res.statusCode = 200; - ctx.res.setHeader('Content-Type', ghRes.headers.get('content-type') ?? 'application/octet-stream'); - ctx.res.setHeader('Cache-Control', 'private, max-age=300'); const body = Buffer.from(await ghRes.arrayBuffer()); - ctx.res.end(body); -} + return new Response(body, { + status: 200, + headers: { + 'Content-Type': ghRes.headers.get('content-type') ?? 'application/octet-stream', + 'Cache-Control': 'private, max-age=300', + }, + }); +}); + +// Tree (authenticated) + +api.get('/github-app/installations/:installationId/repos/:owner/:repo/tree', async (c) => { + const session = requireAuthSession(c); + const installationId = requireMatchedInstallation(c, session, 'installationId'); + const owner = c.req.param('owner'); + const repo = c.req.param('repo'); + const ref = c.req.query('ref') || 'HEAD'; + + const ghPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`; + const ghRes = await githubFetchWithInstallationToken(installationId, ghPath); + const data = (await ghRes.json()) as { tree: GitTreeEntry[]; truncated: boolean }; + return c.json({ files: mdFilesFromTree(data.tree), truncated: data.truncated }, 200); +}); + +// Public repo routes -async function handleGetPublicRepoContents(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const owner = decodeURIComponent(ctx.match[1]); - const repo = decodeURIComponent(ctx.match[2]); - const pathParam = ctx.url.searchParams.get('path') ?? ''; - const ref = ctx.url.searchParams.get('ref'); +api.get('/public/repos/:owner/:repo/contents', async (c) => { + const owner = c.req.param('owner'); + const repo = c.req.param('repo'); + const pathParam = c.req.query('path') ?? ''; + const ref = c.req.query('ref'); const encodedPath = encodePathPreserveSlashes(pathParam); const ghPath = encodedPath ? `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}` @@ -498,18 +503,16 @@ async function handleGetPublicRepoContents(ctx: RouteContext): Promise { const data = (await ghRes.json().catch(() => null)) as GitHubApiError | unknown; if (!ghRes.ok) { const err = data as GitHubApiError | null; - json(ctx.res, ghRes.status, { error: err?.message ?? 'GitHub API error' }); - return; + return c.json({ error: err?.message ?? 'GitHub API error' }, ghRes.status as 400); } - json(ctx.res, 200, data); -} + return c.json(data, 200); +}); -async function handleGetPublicRepoRaw(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const owner = decodeURIComponent(ctx.match[1]); - const repo = decodeURIComponent(ctx.match[2]); - const pathParam = ctx.url.searchParams.get('path'); - if (!pathParam) throw new ClientError('path is required'); +api.get('/public/repos/:owner/:repo/raw', async (c) => { + const owner = c.req.param('owner'); + const repo = c.req.param('repo'); + const pathParam = c.req.query('path'); + if (!pathParam) throw new HTTPException(400, { message: 'path is required' }); const ghPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodePathPreserveSlashes(pathParam)}`; const ghRes = await fetchPublicGitHub(ghPath, { @@ -526,49 +529,23 @@ async function handleGetPublicRepoRaw(ctx: RouteContext): Promise { message = text; } } - json(ctx.res, ghRes.status, { error: message }); - return; + return c.json({ error: message }, ghRes.status as 400); } - ctx.res.statusCode = 200; - ctx.res.setHeader('Content-Type', ghRes.headers.get('content-type') ?? 'application/octet-stream'); - ctx.res.setHeader('Cache-Control', 'public, max-age=300'); const body = Buffer.from(await ghRes.arrayBuffer()); - ctx.res.end(body); -} - -type GitTreeEntry = { path: string; type: string; sha: string }; - -function mdFilesFromTree(tree: GitTreeEntry[]): { name: string; path: string; sha: string }[] { - const files: { name: string; path: string; sha: string }[] = []; - for (const entry of tree) { - if (entry.type !== 'blob' || !entry.path.toLowerCase().endsWith('.md')) continue; - const slash = entry.path.lastIndexOf('/'); - files.push({ name: slash === -1 ? entry.path : entry.path.slice(slash + 1), path: entry.path, sha: entry.sha }); - } - files.sort((a, b) => a.path.localeCompare(b.path)); - return files; -} - -async function handleGetTree(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const session = requireAuthSession(ctx); - const installationId = requireMatchedInstallation(ctx, session, 1); - const owner = ctx.match[2]; - const repo = ctx.match[3]; - const ref = ctx.url.searchParams.get('ref') || 'HEAD'; - - const ghPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`; - const ghRes = await githubFetchWithInstallationToken(installationId, ghPath); - const data = (await ghRes.json()) as { tree: GitTreeEntry[]; truncated: boolean }; - json(ctx.res, 200, { files: mdFilesFromTree(data.tree), truncated: data.truncated }); -} + return new Response(body, { + status: 200, + headers: { + 'Content-Type': ghRes.headers.get('content-type') ?? 'application/octet-stream', + 'Cache-Control': 'public, max-age=300', + }, + }); +}); -async function handleGetPublicTree(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const owner = decodeURIComponent(ctx.match[1]); - const repo = decodeURIComponent(ctx.match[2]); - const ref = ctx.url.searchParams.get('ref') || 'HEAD'; +api.get('/public/repos/:owner/:repo/tree', async (c) => { + const owner = c.req.param('owner'); + const repo = c.req.param('repo'); + const ref = c.req.query('ref') || 'HEAD'; const ghPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`; const ghRes = await fetchPublicGitHub(ghPath); @@ -579,22 +556,21 @@ async function handleGetPublicTree(ctx: RouteContext): Promise { } | null; if (!ghRes.ok) { const err = data as GitHubApiError | null; - json(ctx.res, ghRes.status, { error: err?.message ?? 'GitHub API error' }); - return; + return c.json({ error: err?.message ?? 'GitHub API error' }, ghRes.status as 400); } - json(ctx.res, 200, { files: mdFilesFromTree(data?.tree ?? []), truncated: data?.truncated ?? false }); -} + return c.json({ files: mdFilesFromTree(data?.tree ?? []), truncated: data?.truncated ?? false }, 200); +}); -async function handleGetPublicGist(ctx: RouteContext): Promise { - if (!checkRateLimit(ctx.req, ctx.res)) return; - const gistId = ctx.match[1]; +// Public gist route + +api.get('/gists/:id', async (c) => { + const gistId = c.req.param('id'); const cached = getGistCacheEntry(gistId); const now = Date.now(); if (cached && isFresh(cached, now)) { - ctx.res.setHeader('X-Cache', 'hit'); - json(ctx.res, 200, cached.data); - return; + c.header('X-Cache', 'hit'); + return c.json(cached.data, 200); } const ghHeaders: Record = { @@ -613,94 +589,34 @@ async function handleGetPublicGist(ctx: RouteContext): Promise { if (ghRes.status === 304 && cached) { markRevalidated(cached, now); - ctx.res.setHeader('X-Cache', 'revalidated'); - json(ctx.res, 200, cached.data); - return; + c.header('X-Cache', 'revalidated'); + return c.json(cached.data, 200); } if (!ghRes.ok) { if (cached) { - ctx.res.setHeader('X-Cache', 'stale'); - json(ctx.res, 200, cached.data); - return; + c.header('X-Cache', 'stale'); + return c.json(cached.data, 200); } - json(ctx.res, ghRes.status === 404 ? 404 : 502, { - error: ghRes.status === 404 ? 'Gist not found' : 'GitHub API error', - }); - return; + return c.json( + { error: ghRes.status === 404 ? 'Gist not found' : 'GitHub API error' }, + ghRes.status === 404 ? 404 : 502, + ); } const data: unknown = await ghRes.json(); const etag = ghRes.headers.get('etag'); setGistCacheEntry(gistId, data, etag, now); - ctx.res.setHeader('X-Cache', 'miss'); - json(ctx.res, 200, data); + c.header('X-Cache', 'miss'); + return c.json(data, 200); } catch (err) { if (cached) { - ctx.res.setHeader('X-Cache', 'stale'); - json(ctx.res, 200, cached.data); - return; + c.header('X-Cache', 'stale'); + return c.json(cached.data, 200); } console.error('Gist fetch failed:', err); - throw new ClientError('Failed to load gist', 502); - } -} - -const CONTENTS_PATTERN = /^\/api\/github-app\/installations\/([^/]+)\/repos\/([^/]+)\/([^/]+)\/contents$/; -const RAW_CONTENT_PATTERN = /^\/api\/github-app\/installations\/([^/]+)\/repos\/([^/]+)\/([^/]+)\/raw$/; -const TREE_PATTERN = /^\/api\/github-app\/installations\/([^/]+)\/repos\/([^/]+)\/([^/]+)\/tree$/; -const PUBLIC_REPO_CONTENTS_PATTERN = /^\/api\/public\/repos\/([^/]+)\/([^/]+)\/contents$/; -const PUBLIC_REPO_RAW_PATTERN = /^\/api\/public\/repos\/([^/]+)\/([^/]+)\/raw$/; -const PUBLIC_REPO_TREE_PATTERN = /^\/api\/public\/repos\/([^/]+)\/([^/]+)\/tree$/; - -const routes: RouteDef[] = [ - { method: 'GET', pattern: /^\/api\/auth\/github\/start$/, handler: handleAuthStart }, - { method: 'GET', pattern: /^\/api\/auth\/github\/callback$/, handler: handleAuthCallback }, - { method: 'GET', pattern: /^\/api\/auth\/session$/, handler: handleAuthSession }, - { method: 'POST', pattern: /^\/api\/auth\/logout$/, handler: handleAuthLogout }, - { method: 'GET', pattern: /^\/api\/github\/user$/, handler: handleGitHubUser }, - { method: 'GET', pattern: /^\/api\/github\/gists$/, handler: handleListGists }, - { method: 'POST', pattern: /^\/api\/github\/gists$/, handler: handleCreateGist }, - { method: 'GET', pattern: /^\/api\/github\/gists\/([a-f0-9]+)$/i, handler: handleGetAuthedGist }, - { method: 'PATCH', pattern: /^\/api\/github\/gists\/([a-f0-9]+)$/i, handler: handlePatchGist }, - { method: 'DELETE', pattern: /^\/api\/github\/gists\/([a-f0-9]+)$/i, handler: handleDeleteGist }, - { method: 'GET', pattern: /^\/api\/github-app\/install-url$/, handler: handleInstallUrl }, - { method: 'POST', pattern: /^\/api\/github-app\/sessions$/, handler: handleCreateSession }, - { method: 'POST', pattern: /^\/api\/github-app\/disconnect$/, handler: handleDisconnectInstallation }, - { method: 'GET', pattern: /^\/api\/github-app\/installations\/([^/]+)\/repositories$/, handler: handleListRepos }, - { method: 'GET', pattern: CONTENTS_PATTERN, handler: handleGetContents }, - { method: 'PUT', pattern: CONTENTS_PATTERN, handler: handlePutContents }, - { method: 'DELETE', pattern: CONTENTS_PATTERN, handler: handleDeleteContents }, - { method: 'GET', pattern: RAW_CONTENT_PATTERN, handler: handleGetRawContent }, - { method: 'GET', pattern: TREE_PATTERN, handler: handleGetTree }, - { method: 'GET', pattern: PUBLIC_REPO_CONTENTS_PATTERN, handler: handleGetPublicRepoContents }, - { method: 'GET', pattern: PUBLIC_REPO_RAW_PATTERN, handler: handleGetPublicRepoRaw }, - { method: 'GET', pattern: PUBLIC_REPO_TREE_PATTERN, handler: handleGetPublicTree }, - { method: 'GET', pattern: /^\/api\/gists\/([a-f0-9]+)$/i, handler: handleGetPublicGist }, -]; - -export async function handleApiRequest( - req: http.IncomingMessage, - res: http.ServerResponse, - url: URL, - pathname: string, -): Promise { - for (const route of routes) { - const match = pathname.match(route.pattern); - if (!match) continue; - - if (req.method !== route.method) { - const hasMethodMatch = routes.some((r) => r.method === req.method && pathname.match(r.pattern)); - if (!hasMethodMatch) { - json(res, 405, { error: 'Method not allowed' }); - return true; - } - continue; - } - - await route.handler({ req, res, url, pathname, match }); - return true; + throw new HTTPException(502, { message: 'Failed to load gist' }); } +}); - return false; -} +export { api }; diff --git a/server/security_headers.ts b/server/security_headers.ts index cdc78e2..ddbfe7b 100644 --- a/server/security_headers.ts +++ b/server/security_headers.ts @@ -1,9 +1,14 @@ -import type http from 'node:http'; -import { CONTENT_SECURITY_POLICY } from './config'; +import { secureHeaders } from 'hono/secure-headers'; -export function applySecurityHeaders(res: http.ServerResponse): void { - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('X-Frame-Options', 'DENY'); - res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload'); - res.setHeader('Content-Security-Policy', CONTENT_SECURITY_POLICY); -} +export const securityHeaders = secureHeaders({ + xFrameOptions: 'DENY', + strictTransportSecurity: 'max-age=63072000; includeSubDomains; preload', + contentSecurityPolicy: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'sha256-wBdtWdXsHnAU2DdByySW4LlXFAScrBvmBgkXtydwJdg='"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "https://avatars.githubusercontent.com"], + connectSrc: ["'self'", "https://api.github.com", "https://gist.githubusercontent.com"], + fontSrc: ["'self'"], + }, +}); diff --git a/server/session.ts b/server/session.ts index ba439a6..9112079 100644 --- a/server/session.ts +++ b/server/session.ts @@ -1,8 +1,9 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; -import type http from 'node:http'; import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; +import type { Context } from 'hono'; +import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; import { DATABASE_PATH, SESSION_MAX_LIFETIME_SECONDS, SESSION_TTL_SECONDS } from './config'; import type { Session } from './types'; @@ -123,43 +124,6 @@ const selectInstallationStmt = db.prepare(` const deleteInstallationStmt = db.prepare('DELETE FROM user_installations WHERE github_user_id = ?'); -function parseCookies(req: http.IncomingMessage): Record { - const raw = req.headers.cookie; - if (!raw) return {}; - const result: Record = {}; - for (const pair of raw.split(';')) { - const idx = pair.indexOf('='); - if (idx === -1) continue; - const key = pair.slice(0, idx).trim(); - const value = pair.slice(idx + 1).trim(); - if (!key) continue; - result[key] = decodeURIComponent(value); - } - return result; -} - -function appendSetCookie(res: http.ServerResponse, cookie: string): void { - const existing = res.getHeader('Set-Cookie'); - if (!existing) { - res.setHeader('Set-Cookie', cookie); - return; - } - if (Array.isArray(existing)) { - res.setHeader('Set-Cookie', [...existing, cookie]); - return; - } - res.setHeader('Set-Cookie', [String(existing), cookie]); -} - -function cookieOptions(maxAgeSeconds: number): string { - const secure = process.env.NODE_ENV === 'production' ? 'Secure; ' : ''; - return `${secure}HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAgeSeconds}`; -} - -function makeCookie(value: string, maxAgeSeconds: number): string { - return `${SESSION_COOKIE_NAME}=${encodeURIComponent(value)}; ${cookieOptions(maxAgeSeconds)}`; -} - function createSessionId(): string { return crypto.randomBytes(32).toString('base64url'); } @@ -194,7 +158,7 @@ function rowToSession(row: Record): Session { }; } -function upsertSession(res: http.ServerResponse, id: string, input: SessionInput, createdAtMs: number): Session { +function upsertSession(c: Context, id: string, input: SessionInput, createdAtMs: number): Session { const session = buildSession(id, input, createdAtMs); sessionUpsertStmt.run( session.id, @@ -208,24 +172,33 @@ function upsertSession(res: http.ServerResponse, id: string, input: SessionInput session.expiresAtMs, ); const cookieMaxAge = Math.max(0, Math.ceil((session.expiresAtMs - Date.now()) / 1000)); - appendSetCookie(res, makeCookie(id, cookieMaxAge)); + setCookie(c, SESSION_COOKIE_NAME, id, { + httpOnly: true, + sameSite: 'Lax', + path: '/', + maxAge: cookieMaxAge, + secure: process.env.NODE_ENV === 'production', + }); return session; } -export function createSession(res: http.ServerResponse, input: SessionInput): Session { - return upsertSession(res, createSessionId(), input, Date.now()); +export function createSession(c: Context, input: SessionInput): Session { + return upsertSession(c, createSessionId(), input, Date.now()); } -export function destroySession(req: http.IncomingMessage, res: http.ServerResponse): void { - const cookies = parseCookies(req); - const id = cookies[SESSION_COOKIE_NAME]; +export function destroySession(c: Context): void { + const id = getCookie(c, SESSION_COOKIE_NAME); if (id) deleteSessionByIdStmt.run(id); - appendSetCookie(res, makeCookie('', 0)); + deleteCookie(c, SESSION_COOKIE_NAME, { + httpOnly: true, + sameSite: 'Lax', + path: '/', + secure: process.env.NODE_ENV === 'production', + }); } -export function getSession(req: http.IncomingMessage): Session | null { - const cookies = parseCookies(req); - const id = cookies[SESSION_COOKIE_NAME]; +export function getSession(c: Context): Session | null { + const id = getCookie(c, SESSION_COOKIE_NAME); if (!id) return null; const row = sessionByIdStmt.get(id) as Record | undefined; @@ -239,9 +212,9 @@ export function getSession(req: http.IncomingMessage): Session | null { return session; } -export function refreshSession(session: Session, res: http.ServerResponse): Session { +export function refreshSession(session: Session, c: Context): Session { return upsertSession( - res, + c, session.id, { githubUserId: session.githubUserId, diff --git a/server/static_files.ts b/server/static_files.ts index 83e1811..e44530d 100644 --- a/server/static_files.ts +++ b/server/static_files.ts @@ -1,40 +1,15 @@ -import { readFile, stat } from 'node:fs/promises'; -import type http from 'node:http'; -import path from 'node:path'; - -const DIST_DIR = path.resolve(new URL('../dist', import.meta.url).pathname); - -const MIME_TYPES: Record = { - '.html': 'text/html; charset=utf-8', - '.js': 'application/javascript', - '.css': 'text/css', - '.json': 'application/json', - '.png': 'image/png', - '.svg': 'image/svg+xml', - '.ico': 'image/x-icon', - '.woff': 'font/woff', - '.woff2': 'font/woff2', - '.txt': 'text/plain', -}; - -export async function serveStatic(res: http.ServerResponse, pathname: string): Promise { - const safePath = path.normalize(decodeURIComponent(pathname)); - const filePath = path.join(DIST_DIR, safePath); - if (!filePath.startsWith(DIST_DIR)) return false; - - try { - const s = await stat(filePath); - if (!s.isFile()) return false; - - const ext = path.extname(filePath); - const content = await readFile(filePath); - res.writeHead(200, { - 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream', - ...(ext !== '.html' ? { 'Cache-Control': 'public, max-age=31536000, immutable' } : {}), - }); - res.end(content); - return true; - } catch { - return false; - } -} +import { serveStatic } from '@hono/node-server/serve-static'; + +export const serveStaticMiddleware = serveStatic({ + root: 'dist', + onFound: (_path, c) => { + if (!_path.endsWith('.html')) { + c.header('Cache-Control', 'public, max-age=31536000, immutable'); + } + }, +}); + +export const spaFallback = serveStatic({ + root: 'dist', + path: 'index.html', +});