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