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; /** An organization (lib/orgs.ts): it owns repositories, but nobody signs in as it. */ isOrg: 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" }; } 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("kind != 'org' && (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; } /** 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. */ 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("kind != 'org' && (username ~ {:q} || name ~ {:q})", { q: query }), sort: "username", skipTotal: true }); return items.map(toUser); }