Files
gerbeur/api/services/user-service.ts
khannurien d62e4a9883
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
Build and deploy docs / build-and-push (push) Successful in 26s
v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:07:40 +00:00

359 lines
9.5 KiB
TypeScript

/**
* Users: accounts, profiles, lookups and search, and the invite tree.
*
* @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";
/** The query selecting full `UserRow`s from `users u`, with the inviter's name. */
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`.
*
* @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) {
// The check above ran before the hash yielded, so a parallel registration
// can still take the name first; the UNIQUE constraint is the real guard.
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`.
*
* @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 what a session token is checked against: the user's live role, and
* the moment older tokens stopped counting (their last password change).
* Undefined when the user is gone.
*/
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`.
*
* @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 another account than `exceptUserId` has `email`. Password
* resets are looked up by email, so an address can belong to one account only
* — otherwise which account a reset link resets is up to row order.
*/
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 `email`, or null. */
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 @mention
* suggestions.
*/
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 profile contains `query`, for the
* public search page: profiles only, never hashes or emails.
*/
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. */
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`. A new password also ends every session
* opened before it and closes the user's sockets.
*
* @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");
}
// A new password ends every session opened with the old one.
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`.
*
* @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, down
* to ten levels, in registration order.
*/
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.
*
* @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");
}
}