Vectrum
Sign In
167 lines · 7.0 KB · TypeScript
import { ClientResponseError } from "pocketbase";
import { client, db, filter, findById, findFirst, isUniqueViolation, orNull, 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;
  /** An organization (lib/orgs.ts): it owns repositories, but nobody signs in as it. */
  isOrg: boolean;
  /** When an avatar was uploaded (used to bust caches), or null to draw the initials. */
  avatarVersion: number | null;
  /** Whether the person confirmed their email address with a link sent to it (lib/emails.ts). */
  verified: boolean;
};

/** 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),
    isOrg: r.kind === "org",
    avatarVersion: r.avatar ? orNull(r.avatar_updated_at) : null,
    verified: !!r.verified,
  };
}

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 person = await getPersonByLogin(login);
  try {
    // Unknown logins are still checked, so usernames can't be probed by timing.
    const auth = await client().collection("users").authWithPassword(person?.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, and links to reset it stop working. */
export async function setPassword(userId: string, password: string) {
  await (await db()).collection("users").update(userId, { password, passwordConfirm: password });
  await revokeEmailTokens(userId, "reset");
}

/** Makes the unused links emailed to a user (lib/emails.ts) for `purpose` stop working. */
export async function revokeEmailTokens(userId: string, purpose: "verify" | "reset") {
  const tokens = (await db()).collection("email_tokens");
  const rows = await tokens.getFullList({ filter: filter("user = {:userId} && purpose = {:purpose}", { userId, purpose }), fields: "id" });
  await Promise.all(rows.map((r) => tokens.delete(r.id)));
}

/** A person by username or email address, as they sign in. */
export async function getPersonByLogin(login: string) {
  login = login.trim().toLowerCase();
  if (!login) return null;
  const record = await findFirst("users", filter("kind != 'org' && (username:lower = {:login} || email:lower = {:login})", { login }));
  return record ? toUser(record) : null;
}

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

/** A person (not an organization) by username. */
export async function getPersonByUsername(username: string) {
  const user = await getUserByUsername(username);
  return user && !user.isOrg ? user : null;
}

/**
 * Returns whether the email changed; PocketBase then rejects the user's existing session tokens. A
 * new address needs confirming again, and links sent to the old one stop working.
 */
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 {
    const emailChanged = before?.email !== email;
    // Not just a change of case.
    const newAddress = before?.email.toLowerCase() !== email.toLowerCase();
    await (await db())
      .collection("users")
      .update(userId, { name: data.name.trim().slice(0, 100), email, bio: data.bio.trim().slice(0, 300), ...(newAddress && { verified: false }) });
    if (newAddress) await Promise.all([revokeEmailTokens(userId, "verify"), revokeEmailTokens(userId, "reset")]);
    return { emailChanged, newAddress };
  } catch (e) {
    if (isUniqueViolation(e)) throw new UserError("That email address is already in use.");
    throw e;
  }
}

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