/** * 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 { APIErrorCode } from "../../shared/api.ts"; 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 } from "../db/connection.ts"; import { closeUserSockets, disconnectUser } from "./ws-service.ts"; import { hashPassword } from "../lib/jwt.ts"; import { linkAttachments } from "./attachment-service.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`; /** * Creates a user with the `user` role, invited by `inviterId`, for * `POST /api/users/register`. The caller has validated `request` and claimed * the invite. * * @throws {APIException} 400 when the username is taken, 409 when the email * is. */ export async function createUser( request: RegisterUserRequest, inviterId: string | null, ): Promise { const userId = crypto.randomUUID(); const createdAt = new Date(); const existingUser = db.prepare( "SELECT id FROM users WHERE username = ?;", ).get(request.username); if (existingUser) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Username already exists", ); } 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 while the hash is computed; the // UNIQUE constraint is the real guard. Emails have no such constraint. if (err instanceof Error && err.message.includes("UNIQUE")) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Username already exists", ); } throw err; } return { id: userId, username: request.username, passwordHash, role: "user", createdAt, email: request.email, }; } /** * 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(APIErrorCode.NOT_FOUND, 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`, with their hash and email. The name * matches case-sensitively. * * @throws {APIException} 404 when there is no such user. */ export function getUserByUsername(username: string): User { const userRow = db.prepare( `${USER_SELECT} WHERE u.username = ?`, ).get(username); if (!userRow || !isUserRow(userRow)) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); } return userRowToApi(userRow); } /** * 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( APIErrorCode.VALIDATION_ERROR, 409, "Email already in use", ); } } /** * Returns the user whose email is exactly `email`, or null, for * `requestPasswordReset`. Unlike the uniqueness check, it is case-sensitive. */ export function getUserByEmail(email: string): User | null { const userRow = db.prepare( `${USER_SELECT} WHERE u.email = ?`, ).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/search`). 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(`${query}%`, 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 = `%${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 rows.filter(isUserRow).map(userRowToApi).map(toPublicUser); } /** * Returns every user. Nothing calls it. * * @throws {APIException} 404 when a row doesn't parse as a user. */ export function listUsers(): User[] { const userRows = db.prepare( `${USER_SELECT}`, ).all(); if (!userRows || !userRows.every(isUserRow)) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "No user found"); } return userRows.map(userRowToApi); } /** * Applies `request` to user `userId` and returns the updated user. It trusts * `request`: the routes pick the fields a caller may change. 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, ...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); const userResult = db.prepare( `UPDATE users SET username = ?, password_hash = ?, role = ?, description = ?, email = ?, updated_at = ? WHERE id = ?`, ).run( updatedUserRow.username, updatedUserRow.password_hash, updatedUserRow.role, updatedUserRow.description, updatedUserRow.email, now.toISOString(), updatedUserRow.id, ); if (userResult.changes === 0) { throw new APIException(APIErrorCode.NOT_FOUND, 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, userId); } return updatedUser; } /** * Records that user `userId` now has an avatar of type `mime`, once * `POST /api/avatars/me` 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(APIErrorCode.NOT_FOUND, 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/:username/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), })); } /** * Deletes user `userId` and disconnects their sockets. Nothing calls it. * * @throws {APIException} 404 when there is no such user. */ export function deleteUser(userId: string): void { disconnectUser(userId); const result = db.prepare( `DELETE FROM users WHERE id = ?;`, ).run(userId); if (result.changes === 0) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); } }