Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email. Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
377 lines
10 KiB
TypeScript
377 lines
10 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 {
|
|
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/password.ts";
|
|
import { linkAttachments } from "./attachment-service.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`;
|
|
|
|
/**
|
|
* Creates a user with the `user` role, invited by `inviterId`, for
|
|
* `POST /api/users/register`. The caller has validated `request` and claimed
|
|
* the invite.
|
|
*
|
|
* @throws {APIException} 409 when the username or the email is taken, in any
|
|
* case.
|
|
*/
|
|
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 lower(username) = lower(?);",
|
|
).get(request.username);
|
|
|
|
if (existingUser) {
|
|
throw new APIException(409, "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) {
|
|
// 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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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`, with their hash and email. The name
|
|
* matches case-sensitively.
|
|
*
|
|
* @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(404, "User not found");
|
|
}
|
|
|
|
return userRowToApi(userRow);
|
|
}
|
|
|
|
/**
|
|
* 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/search`). 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(`${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 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 = `%${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. Nothing calls it.
|
|
*
|
|
* @throws {APIException} 404 when a row doesn't parse as a user.
|
|
*/
|
|
export function listUsers(): User[] {
|
|
const userRows = db.prepare(
|
|
`${USER_SELECT}`,
|
|
).all();
|
|
|
|
if (!userRows || !userRows.every(isUserRow)) {
|
|
throw new APIException(404, "No user found");
|
|
}
|
|
|
|
return userRows.map(userRowToApi);
|
|
}
|
|
|
|
/**
|
|
* Applies `request` to user `userId` and returns the updated user. It trusts
|
|
* `request`: the routes pick the fields a caller may change. 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 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,
|
|
);
|
|
} 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
|
|
* `POST /api/avatars/me` 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/:username/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),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Deletes user `userId` and disconnects their sockets. Nothing calls it.
|
|
*
|
|
* @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(404, "User not found");
|
|
}
|
|
}
|