323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
/**
|
||
* 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 { APIErrorCode } from "../../shared/api.ts";
|
||
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(APIErrorCode.NOT_FOUND, 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(
|
||
APIErrorCode.VALIDATION_ERROR,
|
||
400,
|
||
"Message cannot be empty",
|
||
);
|
||
}
|
||
if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) {
|
||
throw new APIException(
|
||
APIErrorCode.VALIDATION_ERROR,
|
||
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, 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(APIErrorCode.NOT_FOUND, 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, 401 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(
|
||
APIErrorCode.UNAUTHORIZED,
|
||
401,
|
||
"Not authorized to edit this message",
|
||
);
|
||
}
|
||
const trimmed = body.trim();
|
||
if (!trimmed) {
|
||
throw new APIException(
|
||
APIErrorCode.VALIDATION_ERROR,
|
||
400,
|
||
"Message cannot be empty",
|
||
);
|
||
}
|
||
if (trimmed.length > VALIDATION.CHAT_MESSAGE_MAX) {
|
||
throw new APIException(
|
||
APIErrorCode.VALIDATION_ERROR,
|
||
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, 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, 401 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(
|
||
APIErrorCode.UNAUTHORIZED,
|
||
401,
|
||
"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(
|
||
APIErrorCode.SERVER_ERROR,
|
||
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(APIErrorCode.NOT_FOUND, 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(
|
||
APIErrorCode.VALIDATION_ERROR,
|
||
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);
|
||
}
|