import { Router } from 'express'; import bcrypt from 'bcryptjs'; import { randomBytes } from 'node:crypto'; import { findUserByUsername, findUserById, createUser, createSession, findSession, deleteSession, } from './db.js'; const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; const BCRYPT_ROUNDS = 8; const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,64}$/; function newToken() { return randomBytes(24).toString('hex'); } function sanitizeUser(u) { return { id: u.id, username: u.username, role: u.role }; } export async function validateToken(token) { if (!token || typeof token !== 'string') return null; const s = await findSession(token); if (!s) return null; return { id: s.user_id, username: s.username, role: s.role }; } export function requireAuth(req, res, next) { const h = req.get('authorization') || ''; const m = /^Bearer\s+(.+)$/i.exec(h); if (!m) return res.status(401).json({ error: 'Not authenticated' }); validateToken(m[1]) .then((user) => { if (!user) return res.status(401).json({ error: 'Invalid or expired session' }); req.user = user; req.token = m[1]; next(); }) .catch((err) => res.status(500).json({ error: err.message })); } export function requireTeacher(req, res, next) { requireAuth(req, res, () => { if (req.user.role !== 'teacher') return res.status(403).json({ error: 'Teacher only' }); next(); }); } const router = Router(); router.post('/register', async (req, res) => { const { username, password } = req.body || {}; if (!username || !password) return res.status(400).json({ error: 'username and password required' }); if (!USERNAME_RE.test(username)) return res.status(400).json({ error: 'username must be 3-64 chars, letters/digits/._-' }); if (password.length < 4) return res.status(400).json({ error: 'password must be at least 4 characters' }); try { const existing = await findUserByUsername(username); if (existing) return res.status(409).json({ error: 'username already taken' }); const hash = await bcrypt.hash(password, BCRYPT_ROUNDS); const user = await createUser({ username, passwordHash: hash, role: 'student' }); const token = newToken(); const expiresAt = new Date(Date.now() + SESSION_TTL_MS); await createSession({ token, userId: user.id, expiresAt }); res.json({ token, user: sanitizeUser(user) }); } catch (err) { res.status(500).json({ error: err.message }); } }); router.post('/login', async (req, res) => { const { username, password } = req.body || {}; if (!username || !password) return res.status(400).json({ error: 'username and password required' }); try { const user = await findUserByUsername(username); if (!user) return res.status(401).json({ error: 'Invalid username or password' }); const ok = await bcrypt.compare(password, user.password_hash); if (!ok) return res.status(401).json({ error: 'Invalid username or password' }); const token = newToken(); const expiresAt = new Date(Date.now() + SESSION_TTL_MS); await createSession({ token, userId: user.id, expiresAt }); res.json({ token, user: sanitizeUser(user) }); } catch (err) { res.status(500).json({ error: err.message }); } }); router.post('/logout', async (req, res) => { const h = req.get('authorization') || ''; const m = /^Bearer\s+(.+)$/i.exec(h); if (m) { try { await deleteSession(m[1]); } catch { /* ignore */ } } res.json({ ok: true }); }); router.get('/me', requireAuth, async (req, res) => { const fresh = await findUserById(req.user.id); if (!fresh) return res.status(401).json({ error: 'User no longer exists' }); res.json({ user: sanitizeUser(fresh) }); }); export default router;