'use strict' const http = require('node:http') const path = require('node:path') const fs = require('node:fs') const crypto = require('node:crypto') const { initDb } = require('./db.js') const { sendMagicLink } = require('./mailer.js') const PORT = parseInt(process.env.PORT || '3001', 10) const DB_PATH = process.env.DB_PATH || undefined const DIST_DIR = path.join(__dirname, '../client/dist') function send(res, status, body) { res.writeHead(status, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(body)) } function readBody(req) { return new Promise((resolve, reject) => { let data = '' req.on('error', reject) req.on('data', c => { data += c if (data.length > 1024 * 1024) { req.destroy(new Error('body too large')) } }) req.on('end', () => { try { resolve(JSON.parse(data)) } catch { resolve({}) } }) }) } function createServer(db) { if (!db) { db = initDb(DB_PATH) } function requireAuth(req, res) { const auth = req.headers['authorization'] || '' const token = auth.startsWith('Bearer ') ? auth.slice(7) : null if (!token) { send(res, 401, { error: 'unauthorized' }); return null } const session = db.getSession(token) if (!session) { send(res, 401, { error: 'unauthorized' }); return null } return session } function requireAdmin(req, res) { const pwd = process.env.ADMIN_PASSWORD const auth = req.headers['authorization'] || '' const token = auth.startsWith('Bearer ') ? auth.slice(7) : null if (!pwd || token !== pwd) { send(res, 401, { error: 'unauthorized' }); return false } return true } async function handleRequest(req, res) { const url = new URL(req.url, 'http://127.0.0.1') const pathname = url.pathname const method = req.method // ── Auth routes ────────────────────────────────────────────────────────── if (pathname === '/api/auth/guest' && method === 'POST') { const body = await readBody(req) const name = body.display_name if (!name || typeof name !== 'string' || name.trim().length < 2 || name.trim().length > 80) { return send(res, 400, { error: 'display_name required (2-80 chars)' }) } const profile = db.createGuestProfile(name.trim()) db.getOrCreateProgress(profile.id) const sessionToken = crypto.randomBytes(32).toString('hex') db.createSession(sessionToken, profile.id) return send(res, 200, { token: sessionToken, profile: { id: profile.id, email: null, display_name: profile.display_name, is_guest: true }, }) } if (pathname === '/api/auth/request' && method === 'POST') { const body = await readBody(req) const email = body.email if (!email || typeof email !== 'string' || !email.includes('@') || !email.includes('.')) { return send(res, 400, { error: 'email required' }) } let profile = db.findProfileByEmail(email) if (!profile) { profile = db.createProfile(email, null) } const token = crypto.randomBytes(32).toString('hex') const expiresAt = Date.now() + 24 * 60 * 60 * 1000 db.createMagicToken(token, profile.id, expiresAt) await sendMagicLink(email, token).catch(err => { // Log SMTP errors but don't fail the request — token is in DB, link logged console.error('[server] email send failed:', err.message ?? err) }) return send(res, 200, { ok: true }) } if (pathname === '/api/auth/verify' && method === 'GET') { const token = url.searchParams.get('token') if (!token) { return send(res, 400, { error: 'token required' }) } const profileId = db.useMagicToken(token) if (!profileId) { return send(res, 400, { error: 'invalid or expired token' }) } const sessionToken = crypto.randomBytes(32).toString('hex') db.createSession(sessionToken, profileId) res.writeHead(302, { Location: `/?session=${sessionToken}` }) return res.end() } if (pathname === '/api/auth/me' && method === 'GET') { const session = requireAuth(req, res) if (!session) return const profile = db.findProfileById(session.profile_id) if (!profile) { return send(res, 404, { error: 'not found' }) } return send(res, 200, { id: profile.id, email: profile.email, display_name: profile.display_name, is_guest: profile.is_guest === 1, }) } if (pathname === '/api/auth/session' && method === 'DELETE') { const session = requireAuth(req, res) if (!session) return db.deleteSession(session.token) return send(res, 200, { ok: true }) } // ── Progress routes ─────────────────────────────────────────────────────── if (pathname === '/api/progress' && method === 'GET') { const session = requireAuth(req, res) if (!session) return const progress = db.getOrCreateProgress(session.profile_id) return send(res, 200, progress) } if (pathname === '/api/progress/answer' && method === 'POST') { const session = requireAuth(req, res) if (!session) return const body = await readBody(req) const letter = typeof body.letter === 'string' ? body.letter.toUpperCase() : '' if (!/^[A-Z]$/.test(letter)) { return send(res, 400, { error: 'letter must be a single letter A-Z' }) } if (typeof body.correct !== 'boolean') { return send(res, 400, { error: 'correct required' }) } const progress = db.recordAnswer(session.profile_id, letter, body.correct) return send(res, 200, progress) } if (pathname === '/api/mnemonics' && method === 'GET') { const session = requireAuth(req, res) if (!session) return const mnemonics = db.getMnemonics(session.profile_id) return send(res, 200, mnemonics) } if (pathname === '/api/mnemonics' && method === 'POST') { const session = requireAuth(req, res) if (!session) return const body = await readBody(req) const letter = typeof body.letter === 'string' ? body.letter.toUpperCase() : '' if (!/^[A-Z]$/.test(letter)) { return send(res, 400, { error: 'letter must be a single letter A-Z' }) } if (!body.phrase || typeof body.phrase !== 'string' || body.phrase.length > 500) { return send(res, 400, { error: 'phrase required (max 500 chars)' }) } db.saveMnemonic(session.profile_id, letter, body.phrase) return send(res, 200, { ok: true }) } // ── Admin routes ────────────────────────────────────────────────────────── if (pathname === '/api/admin/users' && method === 'GET') { if (!requireAdmin(req, res)) return const users = db.getAdminUsers() return send(res, 200, users) } if (pathname === '/api/admin/stats' && method === 'GET') { if (!requireAdmin(req, res)) return const stats = db.getAdminStats() return send(res, 200, stats) } // ── Unknown /api/* ──────────────────────────────────────────────────────── if (pathname.startsWith('/api/')) { return send(res, 404, { error: 'not found' }) } // ── Static files (SPA) ─────────────────────────────────────────────────── // Prevent path traversal — ensure resolved path stays within DIST_DIR const relative = pathname.replace(/^\/+/, '') const filePath = path.join(DIST_DIR, relative) if (!filePath.startsWith(DIST_DIR + path.sep) && filePath !== DIST_DIR) { return send(res, 400, { error: 'bad request' }) } if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { const ext = path.extname(filePath) const mimeTypes = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', } const contentType = mimeTypes[ext] || 'application/octet-stream' res.writeHead(200, { 'Content-Type': contentType }) return fs.createReadStream(filePath).pipe(res) } // SPA fallback — serve index.html const indexPath = path.join(DIST_DIR, 'index.html') if (fs.existsSync(indexPath)) { res.writeHead(200, { 'Content-Type': 'text/html' }) return fs.createReadStream(indexPath).pipe(res) } // No dist yet return send(res, 404, { error: 'not found' }) } const server = http.createServer((req, res) => { handleRequest(req, res).catch(err => { console.error('[server] unhandled:', err) if (!res.headersSent) { res.writeHead(500); res.end() } }) }) return server } if (require.main === module) { const db = initDb(DB_PATH) const server = createServer(db) server.listen(PORT, () => console.log(`[server] listening on :${PORT}`)) } module.exports = { createServer }