import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; import { promisify } from "node:util"; import { db } from "./db"; import { isValidOwner } from "./git"; const scryptAsync = promisify(scrypt) as (pw: string, salt: Buffer, len: number) => Promise; export type User = { id: number; username: string; name: string; email: string; bio: string; createdAt: number; }; type UserRow = { id: number; username: string; name: string; email: string; bio: string; created_at: number; password_hash: string; }; export function toUser(row: UserRow): User { return { id: row.id, username: row.username, name: row.name, email: row.email, bio: row.bio, createdAt: row.created_at }; } export async function hashPassword(password: string) { const salt = randomBytes(16); const hash = await scryptAsync(password, salt, 64); return `scrypt$${salt.toString("base64")}$${hash.toString("base64")}`; } async function checkPassword(password: string, stored: string) { const [, salt, hash] = stored.split("$"); const expected = Buffer.from(hash, "base64"); const actual = await scryptAsync(password, Buffer.from(salt, "base64"), expected.length); return timingSafeEqual(actual, expected); } export function validatePassword(password: string) { if (password.length < 8) return "Password must be at least 8 characters."; if (password.length > 200) return "Password is too long."; return null; } export class UserError extends Error {} export async function createUser(input: { username: string; email: string; password: string; name?: string }) { const username = input.username.trim(); const email = input.email.trim(); if (!isValidOwner(username)) { throw new UserError("Username may only contain letters, numbers and hyphens, and can't be a reserved word."); } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new UserError("Email address is invalid."); const pwError = validatePassword(input.password); if (pwError) throw new UserError(pwError); if (getUserByUsername(username)) throw new UserError(`Username ${username} is not available.`); if (db.prepare("SELECT 1 FROM users WHERE email = ?").get(email)) { throw new UserError("That email address is already registered."); } const hash = await hashPassword(input.password); const result = db .prepare("INSERT INTO users (username, name, email, password_hash, created_at) VALUES (?, ?, ?, ?, ?)") .run(username, input.name?.trim() ?? "", email, hash, Date.now()); return getUserById(Number(result.lastInsertRowid))!; } /** Looks up a user by username or email and checks the password. */ export async function authenticateUser(login: string, password: string) { const row = db .prepare("SELECT * FROM users WHERE username = ? OR email = ?") .get(login.trim(), login.trim()) as UserRow | undefined; if (!row) { // Spend the same time as a real check so usernames can't be probed by timing. await hashPassword(password); return null; } return (await checkPassword(password, row.password_hash)) ? toUser(row) : null; } export async function verifyUserPassword(userId: number, password: string) { const row = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId) as UserRow | undefined; return !!row && (await checkPassword(password, row.password_hash)); } export async function setPassword(userId: number, password: string) { db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), userId); } export function getUserById(id: number) { const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as UserRow | undefined; return row ? toUser(row) : null; } export function getUserByUsername(username: string) { const row = db.prepare("SELECT * FROM users WHERE username = ?").get(username) as UserRow | undefined; return row ? toUser(row) : null; } export function updateProfile(userId: number, data: { name: string; email: string; bio: string }) { const email = data.email.trim(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new UserError("Email address is invalid."); const taken = db.prepare("SELECT 1 FROM users WHERE email = ? AND id != ?").get(email, userId); if (taken) throw new UserError("That email address is already in use."); db.prepare("UPDATE users SET name = ?, email = ?, bio = ? WHERE id = ?").run( data.name.trim().slice(0, 100), email, data.bio.trim().slice(0, 300), userId, ); } export function searchUsers(query: string, limit = 8) { const rows = db .prepare("SELECT * FROM users WHERE username LIKE ? OR name LIKE ? ORDER BY username LIMIT ?") .all(`%${query}%`, `%${query}%`, limit) as UserRow[]; return rows.map(toUser); }