12522f2478
Return 200 ok even if email send fails — token is saved in DB and magic link is logged to console. Prevents 500 on transient SMTP issues. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
235 lines
8.6 KiB
JavaScript
235 lines
8.6 KiB
JavaScript
'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/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,
|
|
})
|
|
}
|
|
|
|
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 }
|