Files
gerbeur/api/services/chat-service.ts
khannurien 14e3ab125b v3: fix the privacy and security findings of the comment pass — private dumps' broadcasts, mentions and attachments reached people who can't see them, and a few smaller holes. A dump's every write now goes through ws-service's broadcastDumpChange(before, after), which decides the audience: a private dump's creation, edits and deletion reach its owner's sockets alone (its deletion id went to everyone before), going private is dump_deleted for everyone else and dump_updated for the owner (whose other tabs dropped it), going public dump_created for everyone else. Mentions in a private dump, a comment on one, or a private playlist's description notify no one until it goes public (mentions are deduplicated per context, so going public notifies them once), and a moderator's edit of a comment, chat message or playlist mentions in the author's name. A link preview of a private playlist is refused, as one of a private dump already was: the preview is stored in the linking dump, which others may see. Attachments record the type of the resource that links them (migration 0018 backfills it); one linked by a private dump or playlist, or a comment on a private dump, is served only with its owner's media token, which Markdown now adds to attachment URLs, and one whose resource is gone answers 404 and is purged at startup. The Bandcamp provider claimed bandcamp.com.evil.example (the pattern wasn't anchored) and now reuses bandcamp-tralbum's anchored one; the YouTube provider took any site's ?list= for a YouTube playlist and missed m., music., youtube-nocookie and /live/ links, and now shares canonical-url's rules. Sign-in does the same scrypt work for an unknown username as for a wrong password, so timing no longer tells which exist. Usernames and emails are unique regardless of case (migration 0017, which refuses to run over existing clashes and names them): "Bob" and "bob" could both sign up and a mention picked one, emails had no constraint, and a reset request in another case silently sent nothing. The Open Graph tags escape the site name and build their URLs on PUBLIC_URL and FRONTEND_URL instead of the request's origin, which a proxy may rewrite.
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>
2026-09-29 15:19:07 +00:00

296 lines
10 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.
/**
* Stores the site-wide chat's messages and each user's read marker.
*
* The WebSocket route (`routes/ws.ts`) writes messages and moves read markers,
* then broadcasts the result. `routes/chat.ts` reads the history, a page at a
* time.
*
* @module
*/
import { APIException } from "../lib/api-exception.ts";
import type { ChatMessage } from "../../shared/chat.ts";
import { chatMessageRowToApi, isChatMessageRow } from "../model/chat.ts";
import { db } from "../db/connection.ts";
import { linkAttachments } from "./attachment-service.ts";
import { VALIDATION } from "../../shared/validation.ts";
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
/**
* How much of the replied-to message a reply carries. The client shows one line
* of it, once stripped of its Markdown, so the cut leaves room for a long link
* or image URL before the text.
*/
const REPLY_SNIPPET_LEN = 300;
/** The columns of a `ChatMessageRow`, to select over {@link FROM_JOINS}. */
const SELECT_COLS =
`m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id,
u.username as author_username, u.avatar_mime as author_avatar_mime,
ru.username as reply_to_author, substr(rm.body, 1, ${REPLY_SNIPPET_LEN}) as reply_to_body`;
/**
* Joins a message to its author, and to the message it replies to and that
* message's author. Those two are left joins: a reply outlives its target.
*/
const FROM_JOINS = `FROM chat_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN chat_messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id`;
/** Returns message `id` as the broadcast sends it, or throws a 404. */
function fetchMessage(id: string): ChatMessage {
const row = db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`,
).get(id);
if (!row || !isChatMessageRow(row)) {
throw new APIException(404, "Message not found");
}
return chatMessageRowToApi(row);
}
/**
* Stores a message from `userId` on a `chat_send`, and claims the pasted images
* it links to. A `replyToId` naming no message is dropped, and the message is
* posted as no reply. The caller broadcasts the result.
*
* @throws {APIException} 400 when the body is blank or longer than
* `VALIDATION.CHAT_MESSAGE_MAX`.
*/
export function createChatMessage(
userId: string,
body: string,
replyToId?: string,
): ChatMessage {
const trimmed = body.trim();
if (!trimmed) {
throw new APIException(400, "Message cannot be empty");
}
if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) {
throw new APIException(
400,
`Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`,
);
}
let replyTo: string | null = null;
if (replyToId) {
const exists = db.prepare(`SELECT 1 FROM chat_messages WHERE id = ?;`).get(
replyToId,
);
if (exists) replyTo = replyToId;
}
const id = crypto.randomUUID();
const createdAt = new Date().toISOString();
db.prepare(
`INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id) VALUES (?, ?, ?, ?, ?);`,
).run(id, userId, trimmed, createdAt, replyTo);
// Unclaimed attachments are deleted as orphans at startup.
linkAttachments(trimmed, "chat", id);
return fetchMessage(id);
}
/** Returns the id of message `id`'s author, or throws a 404. */
function getMessageOwner(id: string): string {
const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get(
id,
) as { user_id: string } | undefined;
if (!row) {
throw new APIException(404, "Message not found");
}
return row.user_id;
}
/**
* Replaces a message's body on a `chat_edit`, and claims any image the new body
* links to. `canModerate` lets a moderator edit anyone's message. The caller
* broadcasts the result.
*
* @throws {APIException} 404 when there is no such message, 403 when the user
* is neither its author nor a moderator, 400 when the body is blank or too
* long.
*/
export function updateChatMessage(
id: string,
body: string,
requestingUserId: string,
canModerate: boolean,
): ChatMessage {
const ownerId = getMessageOwner(id);
if (ownerId !== requestingUserId && !canModerate) {
throw new APIException(403, "Not authorized to edit this message");
}
const trimmed = body.trim();
if (!trimmed) {
throw new APIException(400, "Message cannot be empty");
}
if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) {
throw new APIException(
400,
`Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`,
);
}
db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`)
.run(trimmed, new Date().toISOString(), id);
linkAttachments(trimmed, "chat", id);
return fetchMessage(id);
}
/**
* Deletes a message on a `chat_delete`. `canModerate` lets a moderator delete
* anyone's message. Replies to it keep their `replyToId`, without its preview.
*
* @throws {APIException} 404 when there is no such message, 403 when the user
* is neither its author nor a moderator.
*/
export function deleteChatMessage(
id: string,
requestingUserId: string,
canModerate: boolean,
): void {
const ownerId = getMessageOwner(id);
if (ownerId !== requestingUserId && !canModerate) {
throw new APIException(403, "Not authorized to delete this message");
}
db.prepare(`DELETE FROM chat_messages WHERE id = ?;`).run(id);
}
/**
* Passed by `routes/chat.ts` to page through the history, from the message the
* client already has at one end. Messages are ordered by `createdAt`, then by
* id, so a page boundary between two messages sent in the same millisecond
* skips neither.
*/
export interface ChatCursor {
/** An ISO timestamp, compared as a string with `created_at`. */
createdAt: string;
/**
* Without it, the cursor takes in every message at `createdAt`: they are
* all older for {@link getRecentChatMessages} and all newer for
* {@link getChatMessagesAfter}.
*/
id?: string;
}
/** Converts query rows to messages, or throws a 500 if one doesn't parse. */
function toMessages(rows: unknown[]): ChatMessage[] {
if (!rows.every(isChatMessageRow)) {
throw new APIException(500, "Malformed chat message data");
}
return rows.map(chatMessageRowToApi);
}
function capLimit(limit: number): number {
return Math.min(Math.max(Math.floor(limit), 1), MAX_LIMIT);
}
/**
* Returns the latest `limit` messages, or with `before` the `limit` messages
* just older than it, oldest first either way. `limit` is clamped to 1–100.
*
* @throws {APIException} 500 when a row doesn't parse as a message.
*/
export function getRecentChatMessages(
limit = DEFAULT_LIMIT,
before?: ChatCursor,
): ChatMessage[] {
const capped = capLimit(limit);
const rows = before
? db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS}
WHERE m.created_at < ? OR (m.created_at = ? AND m.id < ?)
ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`,
).all(
before.createdAt,
before.createdAt,
// Nothing sorts below "", so no message at that timestamp is taken.
before.id ?? "",
capped,
)
: db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS}
ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`,
).all(capped);
return toMessages(rows).reverse();
}
/**
* Returns the first `limit` messages after `after`, oldest first. The chatbox
* uses it to fetch what it missed while the socket was down. `limit` is
* clamped to 1–100.
*
* @throws {APIException} 500 when a row doesn't parse as a message.
*/
export function getChatMessagesAfter(
after: ChatCursor,
limit = DEFAULT_LIMIT,
): ChatMessage[] {
const rows = db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS}
WHERE m.created_at > ? OR (m.created_at = ? AND m.id > ?)
ORDER BY m.created_at ASC, m.id ASC LIMIT ?;`,
).all(after.createdAt, after.createdAt, after.id ?? "", capLimit(limit));
return toMessages(rows);
}
/**
* Returned to the WebSocket route by {@link getChatReadState} and
* {@link markChatRead}. The route sends it in `welcome` and in
* `chat_read_update`, and the client shows `unread` on the chat's badge.
*/
export interface ChatReadState {
/** The `created_at` of the newest message the user has read. */
readAt: string;
/** Counts only the messages of other users after `readAt`. */
unread: number;
}
/**
* Returns how far `userId` has read the chat. A user who never opened it has
* read up to their registration, so the history from before it isn't unread.
*
* @throws {APIException} 404 when there is no such user.
*/
export function getChatReadState(userId: string): ChatReadState {
const row = db.prepare(
`SELECT COALESCE(u.chat_read_at, u.created_at) AS read_at,
(SELECT COUNT(*) FROM chat_messages m
WHERE m.user_id != u.id
AND m.created_at > COALESCE(u.chat_read_at, u.created_at)) AS unread
FROM users u WHERE u.id = ?;`,
).get(userId) as { read_at: string; unread: number } | undefined;
if (!row) {
throw new APIException(404, "User not found");
}
return { readAt: row.read_at, unread: row.unread };
}
/**
* Moves `userId`'s read marker to `at`, a message's `created_at`, on a
* `chat_read`. It never moves back, so a lagging tab can't undo a newer tab's
* read. It never passes the newest message, so a timestamp from the future
* can't mark messages read before they are sent.
*
* @throws {APIException} 400 when `at` isn't a date, 404 when there is no
* such user.
*/
export function markChatRead(userId: string, at: string): ChatReadState {
const parsed = new Date(at);
if (Number.isNaN(parsed.getTime())) {
throw new APIException(400, "Invalid read marker");
}
const newest = db.prepare(
`SELECT MAX(created_at) AS newest FROM chat_messages;`,
).get() as { newest: string | null };
// With no message to cap it at, a marker from the future would stay stuck.
if (!newest.newest) return getChatReadState(userId);
const iso = parsed.toISOString();
const target = iso > newest.newest ? newest.newest : iso;
db.prepare(
`UPDATE users SET chat_read_at = ?
WHERE id = ? AND (chat_read_at IS NULL OR chat_read_at < ?);`,
).run(target, userId, target);
return getChatReadState(userId);
}