import crypto from 'node:crypto'; import bcrypt from 'bcrypt'; import db from '../db/index.js'; import config from '../config.js'; const EMAIL_RX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const USERNAME_RX = /^[a-zA-Z0-9_]{3,24}$/; export function validateRegistration({ email, username, password }) { const errors = {}; if (!email || !EMAIL_RX.test(email)) errors.email = 'Invalid email address.'; if (!username || !USERNAME_RX.test(username)) { errors.username = 'Username must be 3-24 chars (letters, digits, underscore).'; } if (!password || password.length < 8) { errors.password = 'Password must be at least 8 characters.'; } return errors; } export async function createUser({ email, username, password }) { const lowerEmail = email.toLowerCase(); const existing = db .prepare('SELECT id FROM users WHERE email = ? OR username = ?') .get(lowerEmail, username); if (existing) { const conflict = db .prepare('SELECT email, username FROM users WHERE id = ?') .get(existing.id); if (conflict.email === lowerEmail) { throw Object.assign(new Error('Email already in use.'), { code: 'EMAIL_TAKEN' }); } throw Object.assign(new Error('Username already in use.'), { code: 'USERNAME_TAKEN' }); } const hash = await bcrypt.hash(password, config.auth.bcryptRounds); const token = crypto.randomBytes(32).toString('hex'); const expires = new Date( Date.now() + config.email.verificationTtlHours * 3600 * 1000, ).toISOString(); const info = db .prepare( `INSERT INTO users (email, username, password_hash, verification_token, verification_expires_at) VALUES (?, ?, ?, ?, ?)`, ) .run(lowerEmail, username, hash, token, expires); db.prepare( 'INSERT INTO profiles (user_id, display_name) VALUES (?, ?)', ).run(info.lastInsertRowid, username); return { id: info.lastInsertRowid, email: lowerEmail, username, verificationToken: token, }; } export async function verifyPassword(identifier, password) { const ident = identifier.toLowerCase(); const user = db .prepare( 'SELECT id, email, username, password_hash, email_verified FROM users WHERE email = ? OR LOWER(username) = ?', ) .get(ident, ident); if (!user) return null; const ok = await bcrypt.compare(password, user.password_hash); if (!ok) return null; return user; } export function createSession(userId) { const id = crypto.randomBytes(32).toString('hex'); const expires = new Date( Date.now() + config.auth.sessionTtlDays * 86400 * 1000, ).toISOString(); db.prepare( 'INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', ).run(id, userId, expires); return { id, expiresAt: expires }; } export function destroySession(sessionId) { if (!sessionId) return; db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId); } export function findSessionUser(sessionId) { if (!sessionId) return null; const row = db .prepare( `SELECT u.id, u.email, u.username, u.email_verified, s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ?`, ) .get(sessionId); if (!row) return null; if (new Date(row.expires_at).getTime() < Date.now()) { destroySession(sessionId); return null; } return { id: row.id, email: row.email, username: row.username, emailVerified: !!row.email_verified, }; } export function consumeVerificationToken(token) { const user = db .prepare( `SELECT id, verification_expires_at FROM users WHERE verification_token = ? AND email_verified = 0`, ) .get(token); if (!user) return null; if (new Date(user.verification_expires_at).getTime() < Date.now()) return null; db.prepare( `UPDATE users SET email_verified = 1, verification_token = NULL, verification_expires_at = NULL WHERE id = ?`, ).run(user.id); return user.id; }