Files
gerbeur/api/services/user-service.ts
khannurien 0d4e344d77 v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:31:14 +00:00

377 lines
10 KiB
TypeScript

/**
* 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<User> {
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<User> {
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");
}
}