Files
gerbeur/api/services/chat-service.ts
khannurien 2ad8ae73d5 v3: the API stops trusting what it's handed — a review of api/ turned up a server anyone could crash, uploads that ran script on the app's origin, and a server that would fetch its own network on request, and this closes them along with the smaller holes around them. A WebSocket message that wasn't an object (a bare null will do) threw inside the listener, which Deno treats as uncaught and exits on; messages are now shape-checked and the handler can't throw out. Uploaded files kept the MIME type the uploader's browser claimed, so an .html or .svg dump opened from /api/files ran with the session token in reach; only images, audio, video, PDF and plain text are served inline now, the rest download, and every file carries nosniff and a sandboxing CSP (PDFs aside, which Chrome won't render sandboxed). The image proxy passed image/svg+xml straight through the same way and now takes raster formats only. Outbound fetches for previews, the proxy and Bandcamp checked the hostname against a partial list — no 169.254.169.254, no 0.0.0.0, an IPv6 check that never matched a bracketed hostname, nothing for names that resolve inward, and redirects followed blindly; every hop now resolves and is refused if any address is private or reserved, redirects are walked by hand, the curl fallback is pinned to the checked IP, and /api/preview needs a login. PATCH /users/me accepted a new password without the current one and now takes only the description and email; a wrong password at login answered 500 because the handler caught its own 401, and now says 401. Changing or resetting a password now ends every older session through users.tokens_valid_after (migration 0015) and an iat on every token, with change-password handing back fresh tokens the modal keeps, and all token checks go through one authenticate() that also refuses deleted users. Invites and reset links are claimed atomically before the async hash rather than marked after, so one invite no longer opens several accounts, and an email address belongs to one account. File and thumbnail URLs carry a media-only token rather than the session token, so a URL in a log exposes files and not the account; older sessions fetch one on load. Private things stop leaking into broadcasts: private playlist updates go to their owner, votes and likes on someone else's private dump 404 and their counts go to the owner alone, and a private dump can't be slipped into a playlist. Smaller: selfProvider only claims this site's own host, so github.com/users/bob stops rendering our bob; the chat read marker can't be parked in the future on an empty chat; dump mutations use the resolved id so a slug works; multipart dumps get the title and comment limits; logins and reset requests are rate-limited through a shared token bucket; the password compare is constant-time; ffmpeg reads only local files in known video containers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:07:33 +00:00

266 lines
8.6 KiB
TypeScript

import {
APIErrorCode,
APIException,
type ChatMessage,
} from "../model/interfaces.ts";
import { chatMessageRowToApi, db, isChatMessageRow } from "../model/db.ts";
import { linkAttachments } from "./attachment-service.ts";
export const MAX_CHAT_LENGTH = 2000;
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
// Length cap for the replied-to preview snippet, so a reply to a long message
// doesn't ship the whole 2000-char body over the wire for every reply. This is
// raw markdown: the client strips it to text before showing a line of it, so
// it's cut generously enough that an image URL or link doesn't eat it all.
const REPLY_SNIPPET_LEN = 300;
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`;
// Common joins: author (required) plus the optional replied-to message and its
// author (LEFT — null when there's no reply or the target was deleted).
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`;
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);
}
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 > MAX_CHAT_LENGTH) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Message exceeds ${MAX_CHAT_LENGTH} characters`,
);
}
// Only store the reply target if it actually exists — drop dangling ids so a
// message never references a phantom parent.
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);
// Claim any pasted/dropped image attachments so the startup cleanup doesn't
// purge them as orphans (mirrors dumps/comments/playlists).
linkAttachments(trimmed, id);
return fetchMessage(id);
}
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;
}
// Owners may edit their own messages; moderators may edit any.
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 > MAX_CHAT_LENGTH) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Message exceeds ${MAX_CHAT_LENGTH} characters`,
);
}
db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`)
.run(trimmed, new Date().toISOString(), id);
// An edit may introduce a freshly pasted image; claim its attachment too.
linkAttachments(trimmed, id);
return fetchMessage(id);
}
// Owners may delete their own messages; moderators may delete any.
export function deleteChatMessage(
id: string,
requestingUserId: string,
canModerate: boolean,
): void {
const ownerId = getMessageOwner(id); // 404s if the message doesn't exist
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);
}
/** A position in the chat stream. Messages are ordered by (createdAt, id), so
* two sharing a millisecond still have a strict order and a page boundary
* between them can't skip either. */
export interface ChatCursor {
createdAt: string;
/** Omitted: the cursor sits before (for `after`) or after (for `before`)
* every message sharing that timestamp. */
id?: string;
}
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 up to `limit` most recent messages in ascending (display) order.
* When `before` is given, returns the page of older messages immediately
* preceding it — used for "load older" history.
*/
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,
// No id: nothing at that timestamp sorts below the empty string.
before.id ?? "",
capped,
)
: db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS}
ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`,
).all(capped);
// Newest-first from SQL; reverse to ascending for display.
return toMessages(rows).reverse();
}
/**
* Returns up to `limit` messages following `after`, in ascending order — used
* to resync the chat after a reconnect. Without an id the cursor is inclusive
* of its timestamp.
*/
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);
}
export interface ChatReadState {
/** created_at of the newest message the user has seen (ISO). */
readAt: string;
/** Messages from other people since then. */
unread: number;
}
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 the user's read marker forward to `at` (a message's created_at) —
* never backward, so a lagging tab can't undo a newer tab's read, and never
* past the newest message, so a bogus timestamp can't pre-read the future.
*/
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 };
// No messages yet: there's nothing to have read, and no newest message to
// cap a bogus far-future marker at — which could never be moved back.
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);
}