/** * Reads and writes the `users` table. Its `User`s carry the password hash and * email, so a route strips them with `toPublicUser` or `toSelfUser` before * answering. `routes/users.ts` is its main caller; `lib/auth.ts` checks every * token against {@link getSessionState}. * * @module */ import { APIException } from "../lib/api-exception.ts"; import type { Invitee, PublicUser, RegisterUserRequest, Role, UpdateUserRequest, UserSuggestion, } from "../../shared/user.ts"; import { isUserRow, toPublicUser, type User, userApiToRow, userRowToApi, } from "../model/user.ts"; import { db, transaction } from "../db/connection.ts"; import { closeUserSockets } from "./ws-service.ts"; import { hashPassword } from "../lib/password.ts"; import { linkAttachments } from "./attachment-service.ts"; import { LIKE, likePattern } from "../lib/like.ts"; import { parseRows } from "../model/rows.ts"; import { isEmailEnabled, sendEmail } from "./email-service.ts"; import { renderWelcomeEmail } from "../mail/welcome.ts"; import { RENAME_LIMITS, USERNAME_RE, VALIDATION, } from "../../shared/validation.ts"; /** Selects full `UserRow`s from `users u`, with the inviter's name joined. */ const USER_SELECT = `SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email, i.username as invited_by_username FROM users u LEFT JOIN users i ON i.id = u.invited_by`; /** * Throws a 409 when `username` is, in any case, the name or a former name of * an account other than `exceptUserId`. A former name stays its user's for * good, so that old links and old @mentions can't be taken over. */ function assertUsernameAvailable( username: string, exceptUserId?: string, ): void { const row = db.prepare( `SELECT id FROM users WHERE lower(username) = lower(?) AND id != ? UNION ALL SELECT user_id FROM user_aliases WHERE lower(username) = lower(?) AND user_id != ? LIMIT 1;`, ).get(username, exceptUserId ?? "", username, exceptUserId ?? ""); if (row) { throw new APIException(409, "Username already exists"); } } /** * Creates a user with the `user` role, invited by `inviterId`, for * `POST /api/users`. The caller has validated `request` and claimed the * invite. * * @throws {APIException} 409 when the username or the email is taken, in any * case, or the username is someone's former name. */ export async function createUser( request: RegisterUserRequest, inviterId: string | null, ): Promise { const userId = crypto.randomUUID(); const createdAt = new Date(); assertUsernameAvailable(request.username); assertEmailAvailable(request.email); const passwordHash = await hashPassword(request.password); try { db.prepare( `INSERT INTO users (id, username, password_hash, role, created_at, invited_by, email) VALUES (?, ?, ?, ?, ?, ?, ?);`, ).run( userId, request.username, passwordHash, "user", createdAt.toISOString(), inviterId, request.email, ); } catch (err) { // A parallel sign-up can take the name or the address while the hash is // computed; the unique indexes are the real guard. throw uniqueViolationToConflict(err); } return { id: userId, username: request.username, passwordHash, role: "user", createdAt, email: request.email, }; } /** * Emails `user` the welcome message, if email is configured. The register * route calls it once the account exists, and doesn't wait: a slow or failing * SMTP server mustn't hold up the sign-up, so a failure is only logged. */ export function sendWelcomeEmail(user: User): void { if (!isEmailEnabled()) return; sendEmail({ to: user.email, ...renderWelcomeEmail(user.username) }) .catch((err) => console.error("[register] welcome email failed:", err)); } /** * Returns user `userId`, with their hash and email. * * @throws {APIException} 404 when there is no such user. */ export function getUserById(userId: string): User { const userRow = db.prepare( `${USER_SELECT} WHERE u.id = ?`, ).get(userId); if (!userRow || !isUserRow(userRow)) { throw new APIException(404, "User not found"); } return userRowToApi(userRow); } /** * Returns the user's live role and their last password change, before which * their tokens don't count, or undefined when the user is gone. `lib/auth.ts` * checks every session and media token against it. */ export function getSessionState( userId: string, ): { role: Role; tokensValidAfter: string | null } | undefined { const row = db.prepare( `SELECT role, tokens_valid_after FROM users WHERE id = ?;`, ).get(userId) as | { role: Role; tokens_valid_after: string | null } | undefined; return row && { role: row.role, tokensValidAfter: row.tokens_valid_after, }; } /** * Returns the user named `username` now, in any case, with their hash and * email. Signing in takes it: a former name doesn't sign in. * * @throws {APIException} 404 when there is no such user. */ export function getUserByUsername(username: string): User { const userRow = db.prepare( `${USER_SELECT} WHERE lower(u.username) = lower(?)`, ).get(username); if (!userRow || !isUserRow(userRow)) { throw new APIException(404, "User not found"); } return userRowToApi(userRow); } /** * Returns the user who is or was named `username`, in any case: a page or an * API path naming a user by a former name reaches them, and the caller sends * the client on to the current one. * * @throws {APIException} 404 when nobody is or was. */ export function resolveUsername(username: string): User { const alias = db.prepare( `SELECT user_id FROM user_aliases WHERE lower(username) = lower(?);`, ).get(username) as { user_id: string } | undefined; return alias ? getUserById(alias.user_id) : getUserByUsername(username); } /** * Returns the user an API path's key names: `@name` by their name, current or * former ({@link resolveUsername}), anything else by their id. Every route * under `/api/users/:key` resolves its key with it. * * @throws {APIException} 404 when there is no such user. */ export function resolveUserKey(key: string): User { return key.startsWith("@") ? resolveUsername(key.slice(1)) : getUserById(key); } /** * Renames user `userId` to `username`, for `PATCH /api/me` and, by a * moderator, `PATCH /api/users/:key/username`, and returns them renamed. The * old name becomes an alias of theirs for good, so `/@old/…` still reaches * them and nobody else may take it; a change of case alone leaves none, since * names are looked up in any case. Taking back a former name of one's own is * allowed. Their dumps' and playlists' slugs belong to their id, so their * paths follow the new name. Unless `exempt` (a moderator), a user may change * names once in `RENAME_LIMITS.USERNAME`'s window. * * @throws {APIException} 400 when `username` isn't a valid name, 404 when * there is no such user, 409 when the name is or was someone else's, 429 when * the user changed names too recently. */ export function renameUser( userId: string, username: string, exempt: boolean, ): User { if (!USERNAME_RE.test(username)) { throw new APIException( 400, `Username must be ${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters and contain only letters, numbers, or underscores`, ); } const user = getUserById(userId); if (username === user.username) return user; const now = new Date(); transaction(() => { assertUsernameAvailable(username, userId); if (username.toLowerCase() !== user.username.toLowerCase()) { const { count, windowMs } = RENAME_LIMITS.USERNAME; const recent = (db.prepare( `SELECT COUNT(*) AS n FROM user_aliases WHERE user_id = ? AND created_at > ?;`, ).get(userId, new Date(now.getTime() - windowMs).toISOString()) as { n: number; }).n; if (!exempt && recent >= count) { throw new APIException( 429, `A username can only be changed once every ${ windowMs / 86_400_000 } days`, ); } db.prepare( `DELETE FROM user_aliases WHERE user_id = ? AND lower(username) = lower(?);`, ).run(userId, username); db.prepare( `INSERT INTO user_aliases (username, user_id, created_at) VALUES (?, ?, ?);`, ).run(user.username, userId, now.toISOString()); } db.prepare(`UPDATE users SET username = ?, updated_at = ? WHERE id = ?;`) .run(username, now.toISOString(), userId); }); return getUserById(userId); } /** * Throws a 409 when an account other than `exceptUserId` has `email`, in any * case. Password resets are looked up by email, so an address must belong to * one account only. */ function assertEmailAvailable(email: string, exceptUserId?: string): void { const row = db.prepare( `SELECT id FROM users WHERE lower(email) = lower(?) AND id != ?;`, ).get(email, exceptUserId ?? ""); if (row) { throw new APIException(409, "Email already in use"); } } /** * Returns `err` as a 409 when it is SQLite's unique violation on a username or * an email, and as it is otherwise. */ function uniqueViolationToConflict(err: unknown): unknown { if (!(err instanceof Error) || !err.message.includes("UNIQUE")) return err; return err.message.includes("email") ? new APIException(409, "Email already in use") : new APIException(409, "Username already exists"); } /** * Returns the user whose email is `email`, in any case, or null, for * `requestPasswordReset`. */ export function getUserByEmail(email: string): User | null { const userRow = db.prepare( `${USER_SELECT} WHERE lower(u.email) = lower(?)`, ).get(email); if (!userRow || !isUserRow(userRow)) return null; return userRowToApi(userRow); } /** * Returns up to `limit` users whose name starts with `query`, for the editor's * @mention suggestions (`GET /api/users?q=`). An empty query returns none. */ export function searchUsers( query: string, limit: number, ): UserSuggestion[] { if (!query) return []; const rows = db.prepare( `SELECT id, username, avatar_mime FROM users WHERE username ${LIKE} ORDER BY username LIMIT ?;`, ).all(likePattern(query, "prefix"), limit) as { id: string; username: string; avatar_mime: string | null; }[]; return rows.map((r) => ({ id: r.id, username: r.username, avatarMime: r.avatar_mime, })); } /** * Returns up to `limit` users whose name or description contains `query`, for * the `/search` page (`GET /api/search`). They are already public profiles. */ export function searchUsersGlobal(query: string, limit: number): PublicUser[] { if (!query.trim()) return []; const pattern = likePattern(query); const rows = db.prepare( `${USER_SELECT} WHERE (u.username ${LIKE} OR u.description ${LIKE}) ORDER BY u.username LIMIT ?;`, ).all(pattern, pattern, limit); return parseRows(rows, isUserRow, userRowToApi).map(toPublicUser); } /** * Applies `request` to user `userId` and returns the updated user. It trusts * `request`: the routes pick the fields a caller may change. The username * changes through {@link renameUser} alone, which keeps the old one as an * alias, so a `username` here is ignored. A new password * also ends every session opened before it and closes the user's sockets, so * the caller must issue fresh tokens if the user is to stay signed in. A * `description` of null keeps the current one. * * @throws {APIException} 404 when there is no such user, 409 when the new * email belongs to another account. */ export async function updateUser( userId: string, request: UpdateUserRequest, ): Promise { const user = getUserById(userId); const { password, description, email, username: _, ...requestFields } = request; if (email !== undefined && email !== user.email) { assertEmailAvailable(email, userId); } const now = new Date(); const updatedUser: User = { ...user, passwordHash: password ? await hashPassword(password) : user.passwordHash, ...requestFields, description: description ?? user.description, email: email ?? user.email, updatedAt: now, }; const updatedUserRow = userApiToRow(updatedUser); let userResult; try { userResult = db.prepare( `UPDATE users SET password_hash = ?, role = ?, description = ?, email = ?, updated_at = ? WHERE id = ?`, ).run( updatedUserRow.password_hash, updatedUserRow.role, updatedUserRow.description, updatedUserRow.email, now.toISOString(), updatedUserRow.id, ); } catch (err) { throw uniqueViolationToConflict(err); } if (userResult.changes === 0) { throw new APIException(404, "User not found"); } if (password) { db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run( now.toISOString(), userId, ); closeUserSockets(userId, "Password changed"); } if (updatedUser.description) { linkAttachments(updatedUser.description, "user", userId); } return updatedUser; } /** * Records that user `userId` now has an avatar of type `mime`, once * `PUT /api/me/avatar` has written the file. It bumps `updated_at`, which * the client uses to reload the image. * * @throws {APIException} 404 when there is no such user. */ export function updateUserAvatar(userId: string, mime: string): void { const result = db.prepare( `UPDATE users SET avatar_mime = ?, updated_at = ? WHERE id = ?`, ).run(mime, new Date().toISOString(), userId); if (result.changes === 0) { throw new APIException(404, "User not found"); } } /** * Returns everyone `userId` invited, and everyone they invited in turn, eleven * levels deep at most, in sign-up order, for the profile page's invite tree * (`GET /api/users/:key/invitees`). */ export function getInviteTree(userId: string): Invitee[] { const rows = db.prepare(` WITH RECURSIVE tree AS ( SELECT id, username, avatar_mime, invited_by, created_at, 0 AS depth FROM users WHERE invited_by = ? UNION ALL SELECT u.id, u.username, u.avatar_mime, u.invited_by, u.created_at, t.depth + 1 FROM users u INNER JOIN tree t ON u.invited_by = t.id WHERE t.depth < 10 ) SELECT * FROM tree ORDER BY created_at; `).all(userId) as { id: string; username: string; avatar_mime: string | null; invited_by: string; created_at: string; }[]; return rows.map((r) => ({ id: r.id, username: r.username, avatarMime: r.avatar_mime ?? undefined, invitedById: r.invited_by, createdAt: new Date(r.created_at), })); }