Files
gerbeur/api/services/user-service.ts

571 lines
19 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 {
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, FEDERATION } 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<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,
};
}
/**
* 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
* another instance while an admin blocks it or the deployment turns sign-ins
* from other instances off (`FEDERATION.incoming`). `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, ui.issuer, 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;
issuer: string | null;
peer_status: string | null;
}
| undefined;
if (!row || row.peer_status === "blocked") return undefined;
if (row.issuer && !FEDERATION.incoming) 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<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) 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<User> {
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),
}));
}