Files
gerbeur/api/services/user-service.ts
khannurien 708d113013 v4: a /community page shows the site's week and month, from the same figures as the digests, and every recurring server task is a job of one scheduler — the digests' top dumps and community section (who joined, the new public playlists, the chat and the totals) existed only in emails. community-service.ts now gathers them for a period from listTopDumps, listNewUsers and listNewPublicPlaylists, each in its own service and returning the API's Dump, PublicUser and Playlist; the digest builds its sections from the same call, its email-only DumpRow and DUMP_COLS replaced by one Dump mapper that networkOf uses too, and links its community section to the page. The page, /community for the last week and /community/monthly for the month, signed in only and linked from the avatar menu, reads GET /api/community?period=, which serves figures kept in memory and recomputed every GERBEUR_COMMUNITY_REFRESH_MINUTES (15 by default). It shows the four figures, the top dumps and the new playlists as live cards, and the new members as UserCards, the follow tab's user row made shared. Its words are the emails' own: the messages both use are renamed from email.digest.* to digest.*, as digest.weekly.off was. api/lib/scheduler.ts's startJobs runs the digests, the community refresh, the WebSocket ping, the typing expiry and the rate-limit sweep, which main.ts starts, so none of them runs on import any more. PERIOD_MS moves to shared/settings.ts. community is reserved as a category slug, and migration 0022 renames a category that had it, with renameReservedCategories moved out of 0021 into reserved-slugs.ts. A group's dump or playlist cards now fill its column. 262 backend tests and 678 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in screenshots in Smooth light at 1280 and 390px, Smooth dark with mocked playlists, Brutalist dark at 390px and Kanagawa dark at 1280px.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:42:01 +00:00

449 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { 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<User> {
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/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`,
);
}
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 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);
}
/**
* 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<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);
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),
}));
}