2026-08-24 09:35:03 -05:00
|
|
|
export const MAX_CHAT_ATTACHMENT_SIZE = 64 * 1024 * 1024;
|
|
|
|
|
|
|
|
|
|
const MAX_CHAT_ATTACHMENT_BASE64_SIZE = Math.ceil(MAX_CHAT_ATTACHMENT_SIZE / 3) * 4;
|
|
|
|
|
const SAFE_RASTER_MIMES = new Set([
|
|
|
|
|
"image/png",
|
|
|
|
|
"image/jpeg",
|
|
|
|
|
"image/webp",
|
|
|
|
|
]);
|
|
|
|
|
const MAX_CHAT_IMAGE_DIMENSION = 8192;
|
|
|
|
|
const MAX_CHAT_IMAGE_PIXELS = 16 * 1024 * 1024;
|
|
|
|
|
const MAX_CHAT_TEXT_LENGTH = 1024 * 1024;
|
|
|
|
|
const MAX_CHAT_MESSAGES_PER_CLIENT = 100_000;
|
|
|
|
|
const MAX_CHAT_MESSAGE_STORAGE_PER_CLIENT = 1024 * 1024 * 1024;
|
|
|
|
|
const MAX_CHAT_ATTACHMENT_STORAGE_PER_CLIENT = 10 * 1024 * 1024 * 1024;
|
|
|
|
|
const DEFAULT_MAX_CHAT_MESSAGES_GLOBAL = 1_000_000;
|
|
|
|
|
const DEFAULT_MAX_CHAT_MESSAGE_STORAGE_GLOBAL = 16 * 1024 * 1024 * 1024;
|
|
|
|
|
const DEFAULT_MAX_CHAT_ATTACHMENT_STORAGE_GLOBAL = 16 * 1024 * 1024 * 1024;
|
|
|
|
|
export const MAX_CHAT_ROWS_PRUNED_PER_SCOPE = 2_000;
|
|
|
|
|
export const MAX_CHAT_INCOMING_RATE_KEYS = 10_000;
|
|
|
|
|
const CHAT_MESSAGE_ROW_OVERHEAD_BYTES = 64;
|
|
|
|
|
const CHAT_MESSAGE_PAYLOAD_BYTES_SQL = `(
|
|
|
|
|
${CHAT_MESSAGE_ROW_OVERHEAD_BYTES}
|
|
|
|
|
+ COALESCE(length(CAST(client_id AS BLOB)), 0)
|
|
|
|
|
+ COALESCE(length(CAST(sender AS BLOB)), 0)
|
|
|
|
|
+ COALESCE(length(CAST(direction AS BLOB)), 0)
|
|
|
|
|
+ COALESCE(length(CAST(text AS BLOB)), 0)
|
|
|
|
|
+ COALESCE(length(CAST(attachment_name AS BLOB)), 0)
|
|
|
|
|
+ COALESCE(length(CAST(attachment_mime AS BLOB)), 0)
|
|
|
|
|
)`;
|
|
|
|
|
const incomingEventRates = new Map();
|
|
|
|
|
const chatMessageStores = new WeakMap();
|
|
|
|
|
|
|
|
|
|
function sanitizeChatText(value, maxLength = MAX_CHAT_TEXT_LENGTH) {
|
|
|
|
|
return typeof value === "string" ? value.slice(0, maxLength) : "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function consumeIncomingEventRate(clientId, kind) {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const isAttachment = kind === "attachment";
|
|
|
|
|
const windowMs = isAttachment ? 10 * 60_000 : 60_000;
|
|
|
|
|
const maxEvents = isAttachment ? 20 : 120;
|
|
|
|
|
const key = `${clientId}:${kind}`;
|
|
|
|
|
let state = incomingEventRates.get(key);
|
|
|
|
|
if (!state || now - state.windowStart >= windowMs) {
|
|
|
|
|
state = { windowStart: now, count: 0 };
|
|
|
|
|
}
|
|
|
|
|
state.count += 1;
|
|
|
|
|
incomingEventRates.set(key, state);
|
|
|
|
|
if (incomingEventRates.size > MAX_CHAT_INCOMING_RATE_KEYS) {
|
|
|
|
|
for (const [rateKey, entry] of incomingEventRates) {
|
|
|
|
|
if (now - entry.windowStart > 10 * 60_000) incomingEventRates.delete(rateKey);
|
|
|
|
|
}
|
|
|
|
|
while (incomingEventRates.size > MAX_CHAT_INCOMING_RATE_KEYS) {
|
|
|
|
|
const oldestKey = incomingEventRates.keys().next().value;
|
|
|
|
|
if (oldestKey === undefined) break;
|
|
|
|
|
incomingEventRates.delete(oldestKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return state.count <= maxEvents;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getChatRuntimeStats() {
|
|
|
|
|
return { incomingRateKeys: incomingEventRates.size };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function positiveChatLimit(name, fallback, maximum) {
|
|
|
|
|
const parsed = Number(process.env[name]);
|
|
|
|
|
if (!Number.isSafeInteger(parsed) || parsed <= 0) return fallback;
|
|
|
|
|
return Math.min(parsed, maximum);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getChatStorageLimits() {
|
|
|
|
|
return {
|
|
|
|
|
perClientMessages: MAX_CHAT_MESSAGES_PER_CLIENT,
|
|
|
|
|
perClientMessageBytes: MAX_CHAT_MESSAGE_STORAGE_PER_CLIENT,
|
|
|
|
|
perClientAttachmentBytes: MAX_CHAT_ATTACHMENT_STORAGE_PER_CLIENT,
|
|
|
|
|
globalMessages: positiveChatLimit(
|
|
|
|
|
"OVERLORD_CHAT_PLUGIN_GLOBAL_MAX_MESSAGES",
|
|
|
|
|
DEFAULT_MAX_CHAT_MESSAGES_GLOBAL,
|
|
|
|
|
1_000_000,
|
|
|
|
|
),
|
|
|
|
|
globalMessageBytes: positiveChatLimit(
|
|
|
|
|
"OVERLORD_CHAT_PLUGIN_GLOBAL_MAX_MESSAGE_BYTES",
|
|
|
|
|
DEFAULT_MAX_CHAT_MESSAGE_STORAGE_GLOBAL,
|
|
|
|
|
16 * 1024 * 1024 * 1024,
|
|
|
|
|
),
|
|
|
|
|
globalAttachmentBytes: positiveChatLimit(
|
|
|
|
|
"OVERLORD_CHAT_PLUGIN_GLOBAL_MAX_ATTACHMENT_BYTES",
|
|
|
|
|
DEFAULT_MAX_CHAT_ATTACHMENT_STORAGE_GLOBAL,
|
|
|
|
|
16 * 1024 * 1024 * 1024,
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function optionalChatStringBytes(value) {
|
|
|
|
|
return value ? Buffer.byteLength(value, "utf8") : 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function chatMessagePayloadBytes(record) {
|
|
|
|
|
return CHAT_MESSAGE_ROW_OVERHEAD_BYTES
|
|
|
|
|
+ Buffer.byteLength(record.clientId, "utf8")
|
|
|
|
|
+ Buffer.byteLength(record.sender, "utf8")
|
|
|
|
|
+ Buffer.byteLength(record.direction, "utf8")
|
|
|
|
|
+ Buffer.byteLength(record.text, "utf8")
|
|
|
|
|
+ optionalChatStringBytes(record.attachmentName)
|
|
|
|
|
+ optionalChatStringBytes(record.attachmentMime);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getChatStorageTotals(database, clientId) {
|
|
|
|
|
const row = clientId === null
|
|
|
|
|
? database.prepare(
|
|
|
|
|
`SELECT COUNT(*) AS messages,
|
|
|
|
|
COALESCE(SUM(${CHAT_MESSAGE_PAYLOAD_BYTES_SQL}), 0) AS message_bytes,
|
|
|
|
|
COALESCE(SUM(length(CAST(attachment_data AS BLOB))), 0) AS attachment_bytes
|
|
|
|
|
FROM messages`,
|
|
|
|
|
).get()
|
|
|
|
|
: database.prepare(
|
|
|
|
|
`SELECT COUNT(*) AS messages,
|
|
|
|
|
COALESCE(SUM(${CHAT_MESSAGE_PAYLOAD_BYTES_SQL}), 0) AS message_bytes,
|
|
|
|
|
COALESCE(SUM(length(CAST(attachment_data AS BLOB))), 0) AS attachment_bytes
|
|
|
|
|
FROM messages WHERE client_id = ?`,
|
|
|
|
|
).get(clientId);
|
|
|
|
|
return {
|
|
|
|
|
messages: Number(row?.messages || 0),
|
|
|
|
|
messageBytes: Number(row?.message_bytes || 0),
|
|
|
|
|
attachmentBytes: Number(row?.attachment_bytes || 0),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function chatStorageScopeFits(database, clientId, incoming, limits) {
|
|
|
|
|
const totals = getChatStorageTotals(database, clientId);
|
|
|
|
|
return totals.messages + 1 <= limits.messages
|
|
|
|
|
&& totals.messageBytes + incoming.messageBytes <= limits.messageBytes
|
|
|
|
|
&& totals.attachmentBytes + incoming.attachmentBytes <= limits.attachmentBytes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deleteChatRows(database, ids) {
|
|
|
|
|
if (ids.length === 0) return;
|
|
|
|
|
const placeholders = ids.map(() => "?").join(",");
|
|
|
|
|
database.prepare(`DELETE FROM messages WHERE id IN (${placeholders})`).run(...ids);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pruneChatStorageScope(database, clientId, incoming, limits) {
|
|
|
|
|
const totals = getChatStorageTotals(database, clientId);
|
|
|
|
|
let messages = totals.messages + 1;
|
|
|
|
|
let messageBytes = totals.messageBytes + incoming.messageBytes;
|
|
|
|
|
let attachmentBytes = totals.attachmentBytes + incoming.attachmentBytes;
|
|
|
|
|
if (
|
|
|
|
|
messages <= limits.messages
|
|
|
|
|
&& messageBytes <= limits.messageBytes
|
|
|
|
|
&& attachmentBytes <= limits.attachmentBytes
|
|
|
|
|
) return true;
|
|
|
|
|
|
|
|
|
|
const rows = clientId === null
|
|
|
|
|
? database.prepare(
|
|
|
|
|
`SELECT id, ${CHAT_MESSAGE_PAYLOAD_BYTES_SQL} AS message_bytes,
|
|
|
|
|
COALESCE(length(CAST(attachment_data AS BLOB)), 0) AS attachment_bytes
|
|
|
|
|
FROM messages ORDER BY timestamp ASC, id ASC LIMIT ?`,
|
|
|
|
|
).all(MAX_CHAT_ROWS_PRUNED_PER_SCOPE)
|
|
|
|
|
: database.prepare(
|
|
|
|
|
`SELECT id, ${CHAT_MESSAGE_PAYLOAD_BYTES_SQL} AS message_bytes,
|
|
|
|
|
COALESCE(length(CAST(attachment_data AS BLOB)), 0) AS attachment_bytes
|
|
|
|
|
FROM messages WHERE client_id = ?
|
|
|
|
|
ORDER BY timestamp ASC, id ASC LIMIT ?`,
|
|
|
|
|
).all(clientId, MAX_CHAT_ROWS_PRUNED_PER_SCOPE);
|
|
|
|
|
|
|
|
|
|
const pruneIds = [];
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
pruneIds.push(row.id);
|
|
|
|
|
messages -= 1;
|
|
|
|
|
messageBytes -= Number(row.message_bytes || 0);
|
|
|
|
|
attachmentBytes -= Number(row.attachment_bytes || 0);
|
|
|
|
|
if (
|
|
|
|
|
messages <= limits.messages
|
|
|
|
|
&& messageBytes <= limits.messageBytes
|
|
|
|
|
&& attachmentBytes <= limits.attachmentBytes
|
|
|
|
|
) break;
|
|
|
|
|
}
|
|
|
|
|
deleteChatRows(database, pruneIds);
|
|
|
|
|
return messages <= limits.messages
|
|
|
|
|
&& messageBytes <= limits.messageBytes
|
|
|
|
|
&& attachmentBytes <= limits.attachmentBytes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createChatMessageStore(database, quotaProvider = getChatStorageLimits) {
|
|
|
|
|
const transaction = database.transaction((record) => {
|
|
|
|
|
const limits = quotaProvider();
|
|
|
|
|
const incoming = {
|
|
|
|
|
messageBytes: chatMessagePayloadBytes(record),
|
|
|
|
|
attachmentBytes: record.attachmentData?.length || 0,
|
|
|
|
|
};
|
|
|
|
|
if (
|
|
|
|
|
incoming.messageBytes > limits.perClientMessageBytes
|
|
|
|
|
|| incoming.messageBytes > limits.globalMessageBytes
|
|
|
|
|
|| incoming.attachmentBytes > limits.perClientAttachmentBytes
|
|
|
|
|
|| incoming.attachmentBytes > limits.globalAttachmentBytes
|
|
|
|
|
) return { ok: false, error: "chat storage quota exceeded" };
|
|
|
|
|
|
|
|
|
|
let perClientSatisfied = pruneChatStorageScope(
|
|
|
|
|
database,
|
|
|
|
|
record.clientId,
|
|
|
|
|
incoming,
|
|
|
|
|
{
|
|
|
|
|
messages: limits.perClientMessages,
|
|
|
|
|
messageBytes: limits.perClientMessageBytes,
|
|
|
|
|
attachmentBytes: limits.perClientAttachmentBytes,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
const globalSatisfied = pruneChatStorageScope(
|
|
|
|
|
database,
|
|
|
|
|
null,
|
|
|
|
|
incoming,
|
|
|
|
|
{
|
|
|
|
|
messages: limits.globalMessages,
|
|
|
|
|
messageBytes: limits.globalMessageBytes,
|
|
|
|
|
attachmentBytes: limits.globalAttachmentBytes,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
if (!perClientSatisfied && globalSatisfied) {
|
|
|
|
|
perClientSatisfied = chatStorageScopeFits(
|
|
|
|
|
database,
|
|
|
|
|
record.clientId,
|
|
|
|
|
incoming,
|
|
|
|
|
{
|
|
|
|
|
messages: limits.perClientMessages,
|
|
|
|
|
messageBytes: limits.perClientMessageBytes,
|
|
|
|
|
attachmentBytes: limits.perClientAttachmentBytes,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (!perClientSatisfied || !globalSatisfied) {
|
|
|
|
|
return { ok: false, error: "chat storage quota exceeded" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const info = database.prepare(
|
|
|
|
|
`INSERT INTO messages
|
|
|
|
|
(client_id, sender, direction, text, timestamp,
|
|
|
|
|
attachment_name, attachment_mime, attachment_data)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
|
|
|
).run(
|
|
|
|
|
record.clientId,
|
|
|
|
|
record.sender,
|
|
|
|
|
record.direction,
|
|
|
|
|
record.text,
|
|
|
|
|
record.timestamp,
|
|
|
|
|
record.attachmentName || null,
|
|
|
|
|
record.attachmentMime || null,
|
|
|
|
|
record.attachmentData || null,
|
|
|
|
|
);
|
|
|
|
|
return { ok: true, id: Number(info.lastInsertRowid), timestamp: record.timestamp };
|
|
|
|
|
});
|
|
|
|
|
return transaction;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function storeChatMessage(ctx, record) {
|
|
|
|
|
let store = chatMessageStores.get(ctx.db);
|
|
|
|
|
if (!store) {
|
|
|
|
|
store = createChatMessageStore(ctx.db);
|
|
|
|
|
chatMessageStores.set(ctx.db, store);
|
|
|
|
|
}
|
|
|
|
|
return store(record);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bytesEqualAt(buf, offset, expected) {
|
|
|
|
|
if (offset < 0 || offset + expected.length > buf.length) return false;
|
|
|
|
|
for (let i = 0; i < expected.length; i++) {
|
|
|
|
|
if (buf[offset + i] !== expected[i]) return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function imageDimensionsAllowed(width, height) {
|
|
|
|
|
return Number.isInteger(width) &&
|
|
|
|
|
Number.isInteger(height) &&
|
|
|
|
|
width > 0 &&
|
|
|
|
|
height > 0 &&
|
|
|
|
|
width <= MAX_CHAT_IMAGE_DIMENSION &&
|
|
|
|
|
height <= MAX_CHAT_IMAGE_DIMENSION &&
|
|
|
|
|
width * height <= MAX_CHAT_IMAGE_PIXELS;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readPngDimensions(buf) {
|
|
|
|
|
if (!bytesEqualAt(buf, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
let offset = 8;
|
|
|
|
|
let dimensions = null;
|
|
|
|
|
let sawImageData = false;
|
|
|
|
|
while (offset + 12 <= buf.length) {
|
|
|
|
|
const length = buf.readUInt32BE(offset);
|
|
|
|
|
const chunkEnd = offset + 12 + length;
|
|
|
|
|
if (chunkEnd > buf.length) return null;
|
|
|
|
|
|
|
|
|
|
const type = buf.subarray(offset + 4, offset + 8).toString("ascii");
|
|
|
|
|
if (type === "acTL" || type === "fcTL" || type === "fdAT") return null;
|
|
|
|
|
if (offset === 8) {
|
|
|
|
|
if (type !== "IHDR" || length !== 13) return null;
|
|
|
|
|
dimensions = { width: buf.readUInt32BE(offset + 8), height: buf.readUInt32BE(offset + 12) };
|
|
|
|
|
} else if (type === "IDAT") {
|
|
|
|
|
sawImageData = true;
|
|
|
|
|
} else if (type === "IEND") {
|
|
|
|
|
if (length !== 0 || chunkEnd !== buf.length || !sawImageData) return null;
|
|
|
|
|
return dimensions;
|
|
|
|
|
}
|
|
|
|
|
offset = chunkEnd;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readJpegDimensions(buf) {
|
|
|
|
|
if (!bytesEqualAt(buf, 0, [0xff, 0xd8, 0xff]) || !bytesEqualAt(buf, buf.length - 2, [0xff, 0xd9])) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
let offset = 2;
|
|
|
|
|
while (offset + 4 <= buf.length - 2) {
|
|
|
|
|
if (buf[offset] !== 0xff) return null;
|
|
|
|
|
while (offset < buf.length && buf[offset] === 0xff) offset += 1;
|
|
|
|
|
if (offset >= buf.length) return null;
|
|
|
|
|
const marker = buf[offset++];
|
|
|
|
|
if (marker === 0xd9) return null;
|
|
|
|
|
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (offset + 2 > buf.length) return null;
|
|
|
|
|
const segmentLength = buf.readUInt16BE(offset);
|
|
|
|
|
if (segmentLength < 2 || offset + segmentLength > buf.length) return null;
|
|
|
|
|
const isStartOfFrame =
|
|
|
|
|
marker >= 0xc0 && marker <= 0xcf &&
|
|
|
|
|
marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
|
|
|
|
if (isStartOfFrame) {
|
|
|
|
|
if (segmentLength < 8) return null;
|
|
|
|
|
return {
|
|
|
|
|
width: buf.readUInt16BE(offset + 5),
|
|
|
|
|
height: buf.readUInt16BE(offset + 3),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
// A valid JPEG provides its frame dimensions before scan data starts.
|
|
|
|
|
if (marker === 0xda) return null;
|
|
|
|
|
offset += segmentLength;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readWebpDimensions(buf) {
|
|
|
|
|
if (
|
|
|
|
|
buf.length < 20 ||
|
|
|
|
|
buf.subarray(0, 4).toString("ascii") !== "RIFF" ||
|
|
|
|
|
buf.readUInt32LE(4) + 8 !== buf.length ||
|
|
|
|
|
buf.subarray(8, 12).toString("ascii") !== "WEBP"
|
|
|
|
|
) return null;
|
|
|
|
|
|
|
|
|
|
let offset = 12;
|
|
|
|
|
let dimensions = null;
|
|
|
|
|
while (offset + 8 <= buf.length) {
|
|
|
|
|
const chunkType = buf.subarray(offset, offset + 4).toString("ascii");
|
|
|
|
|
const chunkLength = buf.readUInt32LE(offset + 4);
|
|
|
|
|
const dataOffset = offset + 8;
|
|
|
|
|
const chunkEnd = dataOffset + chunkLength;
|
|
|
|
|
if (chunkEnd > buf.length) return null;
|
|
|
|
|
if (chunkType === "ANIM" || chunkType === "ANMF") return null;
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
chunkType === "VP8 " &&
|
|
|
|
|
chunkLength >= 10 &&
|
|
|
|
|
bytesEqualAt(buf, dataOffset + 3, [0x9d, 0x01, 0x2a])
|
|
|
|
|
) {
|
|
|
|
|
dimensions ??= {
|
|
|
|
|
width: buf.readUInt16LE(dataOffset + 6) & 0x3fff,
|
|
|
|
|
height: buf.readUInt16LE(dataOffset + 8) & 0x3fff,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (chunkType === "VP8L" && chunkLength >= 5 && buf[dataOffset] === 0x2f) {
|
|
|
|
|
const packed = buf.readUInt32LE(dataOffset + 1);
|
|
|
|
|
dimensions ??= {
|
|
|
|
|
width: (packed & 0x3fff) + 1,
|
|
|
|
|
height: ((packed >>> 14) & 0x3fff) + 1,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (chunkType === "VP8X" && chunkLength >= 10) {
|
|
|
|
|
if ((buf[dataOffset] & 0x02) !== 0) return null;
|
|
|
|
|
dimensions ??= {
|
|
|
|
|
width: 1 + buf[dataOffset + 4] + (buf[dataOffset + 5] << 8) + (buf[dataOffset + 6] << 16),
|
|
|
|
|
height: 1 + buf[dataOffset + 7] + (buf[dataOffset + 8] << 8) + (buf[dataOffset + 9] << 16),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
offset = chunkEnd + (chunkLength % 2);
|
|
|
|
|
}
|
|
|
|
|
return offset === buf.length ? dimensions : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function detectChatRasterMime(buf) {
|
|
|
|
|
if (!buf || typeof buf.length !== "number") return null;
|
|
|
|
|
|
|
|
|
|
const pngDimensions = readPngDimensions(buf);
|
|
|
|
|
if (pngDimensions && imageDimensionsAllowed(pngDimensions.width, pngDimensions.height)) {
|
|
|
|
|
return "image/png";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const jpegDimensions = readJpegDimensions(buf);
|
|
|
|
|
if (jpegDimensions && imageDimensionsAllowed(jpegDimensions.width, jpegDimensions.height)) {
|
|
|
|
|
return "image/jpeg";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const webpDimensions = readWebpDimensions(buf);
|
|
|
|
|
if (webpDimensions && imageDimensionsAllowed(webpDimensions.width, webpDimensions.height)) {
|
|
|
|
|
return "image/webp";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sanitizeAttachmentName(value) {
|
|
|
|
|
const cleaned = String(value || "file")
|
|
|
|
|
.replace(/[\\/\u0000-\u001f\u007f]/g, "_")
|
|
|
|
|
.trim()
|
|
|
|
|
.slice(0, 255);
|
|
|
|
|
return cleaned || "file";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateChatAttachmentBytes(mimeValue, buf) {
|
|
|
|
|
if (!buf || buf.length === 0) {
|
|
|
|
|
return { ok: false, error: "empty attachment" };
|
|
|
|
|
}
|
|
|
|
|
if (buf.length > MAX_CHAT_ATTACHMENT_SIZE) {
|
|
|
|
|
return { ok: false, error: "attachment exceeds 64 MB limit" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const declaredMime = String(mimeValue || "").trim().toLowerCase();
|
|
|
|
|
const detectedMime = detectChatRasterMime(buf);
|
|
|
|
|
if (declaredMime && SAFE_RASTER_MIMES.has(declaredMime) && declaredMime !== detectedMime) {
|
|
|
|
|
return { ok: false, error: "attachment MIME does not match its contents" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Unknown/active formats remain downloadable but are forced to an inert MIME.
|
|
|
|
|
return { ok: true, mime: detectedMime || "application/octet-stream", buf };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function validateChatAttachment(input) {
|
|
|
|
|
if (!input || typeof input !== "object") {
|
|
|
|
|
return { ok: false, error: "invalid attachment payload" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dataB64 = input.dataB64;
|
|
|
|
|
if (typeof dataB64 !== "string" || dataB64.length === 0) {
|
|
|
|
|
return { ok: false, error: "empty attachment" };
|
|
|
|
|
}
|
|
|
|
|
if (dataB64.length > MAX_CHAT_ATTACHMENT_BASE64_SIZE) {
|
|
|
|
|
return { ok: false, error: "attachment exceeds 64 MB limit" };
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
dataB64.length % 4 !== 0 ||
|
|
|
|
|
!/^[A-Za-z0-9+/]*={0,2}$/.test(dataB64)
|
|
|
|
|
) {
|
|
|
|
|
return { ok: false, error: "invalid base64 attachment" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let buf;
|
|
|
|
|
try {
|
|
|
|
|
buf = Buffer.from(dataB64, "base64");
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return { ok: false, error: "invalid base64 attachment" };
|
|
|
|
|
}
|
|
|
|
|
// Buffer's base64 decoder is intentionally permissive. Re-encoding makes
|
|
|
|
|
// the accepted representation canonical and rejects ignored junk bytes.
|
|
|
|
|
if (buf.toString("base64") !== dataB64) {
|
|
|
|
|
return { ok: false, error: "invalid base64 attachment" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const checked = validateChatAttachmentBytes(input.mime, buf);
|
|
|
|
|
if (!checked.ok) return checked;
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
name: sanitizeAttachmentName(input.name),
|
|
|
|
|
mime: checked.mime,
|
|
|
|
|
buf: checked.buf,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 10:11:22 -05:00
|
|
|
export default {
|
|
|
|
|
setup(ctx) {
|
|
|
|
|
ctx.db.exec(`
|
|
|
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
client_id TEXT NOT NULL,
|
|
|
|
|
sender TEXT NOT NULL,
|
|
|
|
|
direction TEXT NOT NULL,
|
|
|
|
|
text TEXT NOT NULL,
|
|
|
|
|
timestamp INTEGER NOT NULL
|
|
|
|
|
);
|
|
|
|
|
`);
|
|
|
|
|
try {
|
|
|
|
|
ctx.db.exec(
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS msg_by_client ON messages(client_id, timestamp)`
|
|
|
|
|
);
|
2026-08-24 09:35:03 -05:00
|
|
|
ctx.db.exec(
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS msg_by_timestamp ON messages(timestamp, id)`
|
|
|
|
|
);
|
2026-05-13 10:11:22 -05:00
|
|
|
} catch (_) {}
|
2026-05-18 23:18:57 -05:00
|
|
|
|
|
|
|
|
const cols = ctx.db
|
|
|
|
|
.prepare(`PRAGMA table_info(messages)`)
|
|
|
|
|
.all()
|
|
|
|
|
.map((r) => r.name);
|
|
|
|
|
if (!cols.includes("attachment_name")) {
|
|
|
|
|
try {
|
|
|
|
|
ctx.db.exec(`ALTER TABLE messages ADD COLUMN attachment_name TEXT`);
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
if (!cols.includes("attachment_mime")) {
|
|
|
|
|
try {
|
|
|
|
|
ctx.db.exec(`ALTER TABLE messages ADD COLUMN attachment_mime TEXT`);
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
if (!cols.includes("attachment_data")) {
|
|
|
|
|
try {
|
|
|
|
|
ctx.db.exec(`ALTER TABLE messages ADD COLUMN attachment_data BLOB`);
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
2026-08-24 09:35:03 -05:00
|
|
|
try {
|
|
|
|
|
ctx.db.exec(
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS msg_attachments_by_client
|
|
|
|
|
ON messages(client_id, timestamp, id) WHERE attachment_data IS NOT NULL`
|
|
|
|
|
);
|
|
|
|
|
ctx.db.exec(
|
|
|
|
|
`CREATE INDEX IF NOT EXISTS msg_attachments_by_timestamp
|
|
|
|
|
ON messages(timestamp, id) WHERE attachment_data IS NOT NULL`
|
|
|
|
|
);
|
|
|
|
|
} catch (_) {}
|
2026-05-13 10:11:22 -05:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
onEvent(ctx, clientId, event, payload) {
|
|
|
|
|
if (event === "chat_message") {
|
2026-08-24 09:35:03 -05:00
|
|
|
const text = sanitizeChatText(payload?.text);
|
|
|
|
|
if (!text) return;
|
|
|
|
|
const sender = sanitizeChatText(payload?.from || "Unknown", 128) || "Unknown";
|
2026-05-13 10:11:22 -05:00
|
|
|
const ts = Date.now();
|
2026-08-24 09:35:03 -05:00
|
|
|
const stored = storeChatMessage(ctx, {
|
2026-05-13 10:11:22 -05:00
|
|
|
clientId,
|
2026-08-24 09:35:03 -05:00
|
|
|
sender,
|
2026-05-13 10:11:22 -05:00
|
|
|
direction: "from_target",
|
2026-08-24 09:35:03 -05:00
|
|
|
text,
|
|
|
|
|
timestamp: ts,
|
|
|
|
|
});
|
|
|
|
|
if (!stored.ok) {
|
|
|
|
|
ctx.log?.warn?.(`Rejected chat message from ${clientId}: ${stored.error}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
ctx.broadcast("new_message", {
|
|
|
|
|
id: stored.id,
|
|
|
|
|
clientId,
|
|
|
|
|
sender,
|
|
|
|
|
direction: "from_target",
|
|
|
|
|
text,
|
2026-05-13 10:11:22 -05:00
|
|
|
timestamp: ts,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-18 23:18:57 -05:00
|
|
|
if (event === "chat_attachment") {
|
2026-08-24 09:35:03 -05:00
|
|
|
const attachment = validateChatAttachment(payload);
|
|
|
|
|
if (!attachment.ok) {
|
|
|
|
|
ctx.log?.warn?.(`Rejected chat attachment from ${clientId}: ${attachment.error}`);
|
|
|
|
|
return;
|
2026-05-18 23:18:57 -05:00
|
|
|
}
|
2026-08-24 09:35:03 -05:00
|
|
|
const ts = Date.now();
|
|
|
|
|
const { name, mime, buf } = attachment;
|
|
|
|
|
const sender = sanitizeChatText(payload?.from || "Unknown", 128) || "Unknown";
|
|
|
|
|
const stored = storeChatMessage(ctx, {
|
2026-05-18 23:18:57 -05:00
|
|
|
clientId,
|
2026-08-24 09:35:03 -05:00
|
|
|
sender,
|
|
|
|
|
direction: "from_target",
|
|
|
|
|
text: "",
|
|
|
|
|
timestamp: ts,
|
|
|
|
|
attachmentName: name,
|
|
|
|
|
attachmentMime: mime,
|
|
|
|
|
attachmentData: buf,
|
|
|
|
|
});
|
|
|
|
|
if (!stored.ok) {
|
|
|
|
|
ctx.log?.warn?.(`Rejected chat attachment from ${clientId}: ${stored.error}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
ctx.broadcast("new_message", {
|
|
|
|
|
id: stored.id,
|
|
|
|
|
clientId,
|
|
|
|
|
sender,
|
2026-05-18 23:18:57 -05:00
|
|
|
direction: "from_target",
|
|
|
|
|
text: "",
|
|
|
|
|
timestamp: ts,
|
|
|
|
|
attachment: { name, mime, size: buf.length },
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-13 10:11:22 -05:00
|
|
|
if (event === "chat_opened") {
|
|
|
|
|
ctx.broadcast("chat_status", { clientId, status: "opened" });
|
|
|
|
|
}
|
|
|
|
|
if (event === "chat_closed") {
|
|
|
|
|
ctx.broadcast("chat_status", { clientId, status: "closed" });
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
rpc: {
|
|
|
|
|
get_history(ctx, params) {
|
|
|
|
|
return ctx.db
|
|
|
|
|
.prepare(
|
2026-05-18 23:18:57 -05:00
|
|
|
`SELECT id, client_id, sender, direction, text, timestamp,
|
|
|
|
|
attachment_name, attachment_mime,
|
|
|
|
|
CASE WHEN attachment_data IS NULL THEN 0 ELSE length(attachment_data) END AS attachment_size
|
|
|
|
|
FROM messages WHERE client_id = ? ORDER BY timestamp ASC LIMIT 500`
|
2026-05-13 10:11:22 -05:00
|
|
|
)
|
2026-05-18 23:18:57 -05:00
|
|
|
.all(params.clientId)
|
|
|
|
|
.map((r) => ({
|
|
|
|
|
id: r.id,
|
|
|
|
|
clientId: r.client_id,
|
|
|
|
|
sender: r.sender,
|
|
|
|
|
direction: r.direction,
|
|
|
|
|
text: r.text,
|
|
|
|
|
timestamp: r.timestamp,
|
|
|
|
|
attachment: r.attachment_name
|
|
|
|
|
? {
|
|
|
|
|
name: r.attachment_name,
|
|
|
|
|
mime: r.attachment_mime,
|
|
|
|
|
size: r.attachment_size,
|
|
|
|
|
}
|
|
|
|
|
: null,
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
get_attachment(ctx, params) {
|
2026-08-24 09:35:03 -05:00
|
|
|
const clientId = String(params?.clientId || "").trim();
|
|
|
|
|
if (!clientId) return { ok: false, error: "clientId required" };
|
2026-05-18 23:18:57 -05:00
|
|
|
const row = ctx.db
|
|
|
|
|
.prepare(
|
2026-08-24 09:35:03 -05:00
|
|
|
`SELECT attachment_name, attachment_mime, attachment_data FROM messages WHERE id = ? AND client_id = ?`
|
2026-05-18 23:18:57 -05:00
|
|
|
)
|
2026-08-24 09:35:03 -05:00
|
|
|
.get(params.id, clientId);
|
2026-05-18 23:18:57 -05:00
|
|
|
if (!row || !row.attachment_data) {
|
|
|
|
|
return { ok: false, error: "not found" };
|
|
|
|
|
}
|
|
|
|
|
const buf = Buffer.isBuffer(row.attachment_data)
|
|
|
|
|
? row.attachment_data
|
|
|
|
|
: Buffer.from(row.attachment_data);
|
2026-08-24 09:35:03 -05:00
|
|
|
const checked = validateChatAttachmentBytes(row.attachment_mime, buf);
|
|
|
|
|
if (!checked.ok) {
|
|
|
|
|
return { ok: false, error: "unsafe attachment blocked" };
|
|
|
|
|
}
|
2026-05-18 23:18:57 -05:00
|
|
|
return {
|
|
|
|
|
ok: true,
|
2026-08-24 09:35:03 -05:00
|
|
|
name: sanitizeAttachmentName(row.attachment_name),
|
|
|
|
|
mime: checked.mime,
|
2026-05-18 23:18:57 -05:00
|
|
|
dataB64: buf.toString("base64"),
|
|
|
|
|
};
|
2026-05-13 10:11:22 -05:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
store_message(ctx, params) {
|
2026-08-24 09:35:03 -05:00
|
|
|
const clientId = String(params?.clientId || "").trim();
|
|
|
|
|
const text = sanitizeChatText(params?.text);
|
|
|
|
|
const sender = sanitizeChatText(params?.sender || "Operator", 128) || "Operator";
|
|
|
|
|
if (!clientId || !text) return { ok: false, error: "invalid message" };
|
2026-05-13 10:11:22 -05:00
|
|
|
const ts = Date.now();
|
2026-08-24 09:35:03 -05:00
|
|
|
const stored = storeChatMessage(ctx, {
|
|
|
|
|
clientId,
|
|
|
|
|
sender,
|
2026-05-13 10:11:22 -05:00
|
|
|
direction: "to_target",
|
2026-08-24 09:35:03 -05:00
|
|
|
text,
|
2026-05-13 10:11:22 -05:00
|
|
|
timestamp: ts,
|
|
|
|
|
});
|
2026-08-24 09:35:03 -05:00
|
|
|
if (!stored.ok) return stored;
|
|
|
|
|
ctx.broadcast("new_message", {
|
|
|
|
|
id: stored.id,
|
|
|
|
|
clientId,
|
|
|
|
|
sender,
|
|
|
|
|
direction: "to_target",
|
|
|
|
|
text,
|
|
|
|
|
timestamp: ts,
|
|
|
|
|
});
|
|
|
|
|
return { ok: true, id: stored.id, timestamp: ts };
|
2026-05-18 23:18:57 -05:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
store_attachment(ctx, params) {
|
2026-08-24 09:35:03 -05:00
|
|
|
const attachment = validateChatAttachment(params);
|
|
|
|
|
if (!attachment.ok) {
|
|
|
|
|
return { ok: false, error: attachment.error };
|
2026-05-18 23:18:57 -05:00
|
|
|
}
|
2026-08-24 09:35:03 -05:00
|
|
|
const ts = Date.now();
|
|
|
|
|
const { name, mime, buf } = attachment;
|
|
|
|
|
const clientId = String(params?.clientId || "").trim();
|
|
|
|
|
if (!clientId) return { ok: false, error: "clientId required" };
|
|
|
|
|
const sender = sanitizeChatText(params?.sender || "Operator", 128) || "Operator";
|
|
|
|
|
const stored = storeChatMessage(ctx, {
|
|
|
|
|
clientId,
|
|
|
|
|
sender,
|
|
|
|
|
direction: "to_target",
|
|
|
|
|
text: "",
|
|
|
|
|
timestamp: ts,
|
|
|
|
|
attachmentName: name,
|
|
|
|
|
attachmentMime: mime,
|
|
|
|
|
attachmentData: buf,
|
|
|
|
|
});
|
|
|
|
|
if (!stored.ok) return stored;
|
2026-05-18 23:18:57 -05:00
|
|
|
ctx.broadcast("new_message", {
|
2026-08-24 09:35:03 -05:00
|
|
|
id: stored.id,
|
|
|
|
|
clientId,
|
|
|
|
|
sender,
|
2026-05-18 23:18:57 -05:00
|
|
|
direction: "to_target",
|
|
|
|
|
text: "",
|
|
|
|
|
timestamp: ts,
|
|
|
|
|
attachment: { name, mime, size: buf.length },
|
|
|
|
|
});
|
2026-08-24 09:35:03 -05:00
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
id: stored.id,
|
|
|
|
|
timestamp: ts,
|
|
|
|
|
name,
|
|
|
|
|
mime,
|
|
|
|
|
};
|
2026-05-13 10:11:22 -05:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
clear_history(ctx, params) {
|
|
|
|
|
ctx.db
|
|
|
|
|
.prepare(`DELETE FROM messages WHERE client_id = ?`)
|
|
|
|
|
.run(params.clientId);
|
|
|
|
|
ctx.broadcast("history_cleared", { clientId: params.clientId });
|
|
|
|
|
return { ok: true };
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|