Vectrum
Sign In
120 lines · 5.1 KB · TypeScript
import { ClientResponseError } from "pocketbase";
import { client, db, filter, findById, findFirst, isUniqueViolation, type RecordModel } from "./db";
import { isValidOwner } from "./git";

// Accounts are PocketBase auth records: PocketBase hashes and checks passwords and issues the
// session tokens (see lib/auth.ts).

export type User = {
  id: string;
  username: string;
  name: string;
  email: string;
  bio: string;
  createdAt: number;
};

/** PocketBase dates read "2026-01-02 03:04:05.678Z". */
export const parseDate = (value: string) => Date.parse(value.replace(" ", "T"));

export function toUser(r: RecordModel): User {
  return { id: r.id, username: r.username, name: r.name, email: r.email, bio: r.bio, createdAt: parseDate(r.created) };
}

export function validatePassword(password: string) {
  if (password.length < 8) return "Password must be at least 8 characters.";
  // PocketBase hashes with bcrypt, which reads at most 72 bytes.
  if (Buffer.byteLength(password) > 71) return "Password is too long.";
  return null;
}

export class UserError extends Error {}

const isEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);

const emailTaken = async (email: string, exceptId = "") =>
  !!(await findFirst("users", filter("email:lower = {:email} && id != {:id}", { email: email.toLowerCase(), id: exceptId }), { fields: "id" }));

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 (!isEmail(email)) throw new UserError("Email address is invalid.");
  const pwError = validatePassword(input.password);
  if (pwError) throw new UserError(pwError);
  if (await getUserByUsername(username)) throw new UserError(`Username ${username} is not available.`);
  if (await emailTaken(email)) throw new UserError("That email address is already registered.");
  try {
    const record = await (await db()).collection("users").create({
      username,
      name: input.name?.trim().slice(0, 100) ?? "",
      email,
      password: input.password,
      passwordConfirm: input.password,
    });
    return toUser(record);
  } catch (e) {
    if (isUniqueViolation(e)) throw new UserError("That username or email address was just taken.");
    throw e;
  }
}

/** Looks up a user by username or email and checks the password. Returns a session token with the user. */
export async function authenticateUser(login: string, password: string) {
  login = login.trim();
  if (!login || !password) return null;
  // PocketBase signs in by email; usernames are resolved here, case-insensitively.
  const record = await findFirst("users", filter("username:lower = {:login} || email:lower = {:login}", { login: login.toLowerCase() }));
  try {
    // Unknown logins are still checked, so usernames can't be probed by timing.
    const auth = await client().collection("users").authWithPassword(record?.email ?? login, password);
    return { user: toUser(auth.record), token: auth.token };
  } catch (e) {
    if (e instanceof ClientResponseError && e.status === 400) return null;
    throw e;
  }
}

export async function verifyUserPassword(userId: string, password: string) {
  const user = await getUserById(userId);
  return !!user && !!(await authenticateUser(user.email, password));
}

/** Changes the password. PocketBase then rejects the user's existing session tokens. */
export async function setPassword(userId: string, password: string) {
  await (await db()).collection("users").update(userId, { password, passwordConfirm: password });
}

export async function getUserById(id: string) {
  const record = await findById("users", id);
  return record ? toUser(record) : null;
}

export async function getUserByUsername(username: string) {
  const record = await findFirst("users", filter("username:lower = {:username}", { username: username.toLowerCase() }));
  return record ? toUser(record) : null;
}

/** Returns whether the email changed; PocketBase then rejects the user's existing session tokens. */
export async function updateProfile(userId: string, data: { name: string; email: string; bio: string }) {
  const email = data.email.trim();
  if (!isEmail(email)) throw new UserError("Email address is invalid.");
  if (await emailTaken(email, userId)) throw new UserError("That email address is already in use.");
  const before = await getUserById(userId);
  try {
    await (await db()).collection("users").update(userId, { name: data.name.trim().slice(0, 100), email, bio: data.bio.trim().slice(0, 300) });
  } catch (e) {
    if (isUniqueViolation(e)) throw new UserError("That email address is already in use.");
    throw e;
  }
  return { emailChanged: before?.email !== email };
}

export async function searchUsers(query: string, limit = 8) {
  const { items } = await (await db())
    .collection("users")
    .getList(1, limit, { filter: filter("username ~ {:q} || name ~ {:q}", { q: query }), sort: "username", skipTotal: true });
  return items.map(toUser);
}