571 lines
19 KiB
TypeScript
571 lines
19 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 { 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),
|
||
}));
|
||
}
|