Files
gerbeur/api/services/user-service.ts
khannurien 0a16d3c96c v4: users can't rename themselves, /settings lays out again, and its selectors fit a phone — PATCH /api/me refuses a username with a 400 and changes nothing else in that request; a name changes only through PATCH /api/users/:key/username, which needs user:manage, from /@name/manage. The old name still stays an alias for good. renameUser lost its 30-day limit and its exempt flag, and RENAME_LIMITS holds the slug limit alone. /settings shows the username as plain text, and the rename hint left the catalogs. The settings grid sized its control column to its widest content, the rename hint, which stretched every control under it; that column now takes the remaining width and each control keeps its own size. A segmented control wraps instead of overflowing; in a settings row on a phone it takes the full width, and one of more than 4 options (the styles) lays out in two even rows. .btn-border turned red on hover, so Settings, Manage, Retry and Back looked like Log out; it is now neutral (Smooth's accent is red too), and only Log out takes the new btn-border--danger, muted until hovered red. A <button> and a <Link> .btn-border are now the same height. API.md and the module docs say who renames; the backend tests rename through the moderator route and check a self-rename is refused, and the malformed-name check moved to the manage tab's test. 254 backend and 667 frontend tests pass, and tsc, deno check and doc:lint are clean; Smooth, Brutalist, GeoCities and Kanagawa were checked in screenshots at desktop and phone widths.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:50:43 +00:00

437 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);
}
/**
* 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),
}));
}