/** * 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 { isRemoteUser, isUserRow, toPublicUser, type User, userApiToRow, userRowToApi, } from "../model/user.ts"; import { db, transaction } from "../db/connection.ts"; import { broadcastUserUpdated, closeUserSockets, updateClientAvatar, } from "./ws-service.ts"; import { replaceUploadedFile, validateImageUpload } from "../lib/upload.ts"; import { AVATARS_DIR } from "../config.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 { USERNAME_RE, VALIDATION } from "../../shared/validation.ts"; import { EVERYONE } from "../../shared/mention.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`, or `everyone`, which the chat's * `@everyone` takes. 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 { if (username.toLowerCase() === EVERYONE) { throw new APIException(409, "Username already exists"); } 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, }; } /** * Creates the account of a user from another instance, named `username` * (their handle here, `name@host`), with no password, for * `federation-service.ts`, which links it to their identity there in the same * transaction. `inviterId` is whoever invited them, or null when their * instance is trusted. * * @throws {APIException} 409 when the handle is or was someone else's. */ export function createRemoteUser( request: { username: string; email: string }, inviterId: string | null, ): User { const userId = crypto.randomUUID(); const createdAt = new Date(); assertUsernameAvailable(request.username); try { db.prepare( `INSERT INTO users (id, username, password_hash, role, created_at, invited_by, email) VALUES (?, ?, '', 'user', ?, ?, ?);`, ).run( userId, request.username, createdAt.toISOString(), inviterId, request.email, ); } catch (err) { throw uniqueViolationToConflict(err); } return getUserById(userId); } /** * 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, or comes from * an instance an admin blocked. `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 u.role, u.tokens_valid_after, p.status AS peer_status FROM users u LEFT JOIN user_identities ui ON ui.user_id = u.id LEFT JOIN federation_peers p ON p.issuer = ui.issuer WHERE u.id = ?;`, ).get(userId) as | { role: Role; tokens_valid_after: string | null; peer_status: string | null; } | undefined; if (!row || row.peer_status === "blocked") return undefined; return { 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/users/:key/username`, * which only `user:manage` may call: users can't rename themselves. 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 their own is allowed. Their dumps' and playlists' slugs * belong to their id, so their paths follow the new name. * * @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. */ export function renameUser(userId: string, username: string): 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`, ); } return setUsername(userId, username); } /** * Names user `userId` `username`, keeping the old name as their alias as * {@link renameUser} describes, and returns them. The caller has checked the * name: {@link renameUser} checks a local one, and `federation-service.ts` * the handle of a user renamed on their home instance. * * @throws {APIException} 404 when there is no such user, 409 when the name is * or was someone else's. */ export function setUsername(userId: string, username: string): User { const user = getUserById(userId); if (username === user.username) return user; const now = new Date().toISOString(); transaction(() => { assertUsernameAvailable(username, userId); if (username.toLowerCase() !== user.username.toLowerCase()) { 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); } db.prepare(`UPDATE users SET username = ?, updated_at = ? WHERE id = ?;`) .run(username, now, userId); }); return getUserById(userId); } /** * Throws a 400 when `user`'s account lives on another instance * ({@link isRemoteUser}), whose password, email, avatar and name are that * instance's to change. The routes that change them call it first. */ export function assertLocalUser(user: User): void { if (isRemoteUser(user)) { throw new APIException( 400, "This account is managed on the instance it comes from", ); } } /** * Throws a 409 when a local account other than `exceptUserId` has `email`, in * any case. Password resets are looked up by email, so an address must belong * to one local account only; an account from another instance may share it, * as the same person may have both. */ function assertEmailAvailable(email: string, exceptUserId?: string): void { const row = db.prepare( `SELECT id FROM users WHERE lower(email) = lower(?) AND id != ? AND password_hash != '';`, ).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 local user whose email is `email`, in any case, or null, for * `requestPasswordReset`. An account from another instance has no password * here to reset. */ export function getUserByEmail(email: string): User | null { const userRow = db.prepare( `${USER_SELECT} WHERE lower(u.email) = lower(?) AND u.password_hash != ''`, ).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); } /** * Returns the users who joined from `from` to `to`, in sign-up order, each * with the name of whoever invited them. The community service gathers them * for the `/community` page and the digests. */ export function listNewUsers(from: Date, to: Date): PublicUser[] { const rows = db.prepare( `${USER_SELECT} WHERE u.created_at >= ? AND u.created_at < ? ORDER BY u.created_at;`, ).all(from.toISOString(), to.toISOString()); 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. 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); 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) revokeSessions(userId, now, "Password changed"); if (updatedUser.description) { linkAttachments(updatedUser.description, "user", userId); } return updatedUser; } /** * Ends every session of user `userId` opened before `at`, closing their * sockets with `reason`: their tokens stop counting * (`users.tokens_valid_after`, which `getSessionState` reads). A password * change calls it, and so does the sign-out of a user from another instance, * when that instance says so or an admin blocks it. */ export function revokeSessions(userId: string, at: Date, reason: string): void { db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run( at.toISOString(), userId, ); closeUserSockets(userId, reason); } /** * Makes the image `data` user `userId`'s avatar and returns them, for an * upload (`PUT /api/me/avatar`) and for the avatar a user from another * instance has there (`federation-service.ts`). Presence alone would update * only the online list, so it also sends `user_updated`, which reaches every * avatar on screen, its bumped `updatedAt` busting their cache. * * @throws {APIException} 400 when `data` isn't an image an avatar may be, 404 * when there is no such user. */ export async function setUserAvatar( userId: string, data: Uint8Array, ): Promise { const mime = validateImageUpload(data); // Written before the record announces it, or clients would fetch it too // early. await replaceUploadedFile(`${AVATARS_DIR}/${userId}`, data); updateUserAvatar(userId, mime); updateClientAvatar(userId, mime); const user = getUserById(userId); broadcastUserUpdated(toPublicUser(user)); return user; } /** * Records that user `userId` now has an avatar of type `mime`, once * {@link setUserAvatar} 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. */ 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), })); }