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<Buffer>;
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);
}