Vectrum
Sign In
122 lines · 4.6 KB · TypeScript
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);
}