tiny update
This commit is contained in:
+591
-83
@@ -1,3 +1,482 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
setup(ctx) {
|
||||
ctx.db.exec(`
|
||||
@@ -14,6 +493,9 @@ export default {
|
||||
ctx.db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS msg_by_client ON messages(client_id, timestamp)`
|
||||
);
|
||||
ctx.db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS msg_by_timestamp ON messages(timestamp, id)`
|
||||
);
|
||||
} catch (_) {}
|
||||
|
||||
const cols = ctx.db
|
||||
@@ -35,60 +517,71 @@ export default {
|
||||
ctx.db.exec(`ALTER TABLE messages ADD COLUMN attachment_data BLOB`);
|
||||
} catch (_) {}
|
||||
}
|
||||
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 (_) {}
|
||||
},
|
||||
|
||||
onEvent(ctx, clientId, event, payload) {
|
||||
if (event === "chat_message") {
|
||||
const text = sanitizeChatText(payload?.text);
|
||||
if (!text) return;
|
||||
const sender = sanitizeChatText(payload?.from || "Unknown", 128) || "Unknown";
|
||||
const ts = Date.now();
|
||||
const info = ctx.db
|
||||
.prepare(
|
||||
`INSERT INTO messages(client_id, sender, direction, text, timestamp) VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
clientId,
|
||||
payload.from || "Unknown",
|
||||
"from_target",
|
||||
payload.text || "",
|
||||
ts
|
||||
);
|
||||
ctx.broadcast("new_message", {
|
||||
id: Number(info.lastInsertRowid),
|
||||
const stored = storeChatMessage(ctx, {
|
||||
clientId,
|
||||
sender: payload.from || "Unknown",
|
||||
sender,
|
||||
direction: "from_target",
|
||||
text: payload.text || "",
|
||||
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,
|
||||
timestamp: ts,
|
||||
});
|
||||
}
|
||||
if (event === "chat_attachment") {
|
||||
const ts = Date.now();
|
||||
const name = payload.name || "file";
|
||||
const mime = payload.mime || "application/octet-stream";
|
||||
let buf = null;
|
||||
try {
|
||||
buf = Buffer.from(payload.dataB64 || "", "base64");
|
||||
} catch (_) {
|
||||
buf = null;
|
||||
const attachment = validateChatAttachment(payload);
|
||||
if (!attachment.ok) {
|
||||
ctx.log?.warn?.(`Rejected chat attachment from ${clientId}: ${attachment.error}`);
|
||||
return;
|
||||
}
|
||||
if (!buf || buf.length === 0) return;
|
||||
const info = ctx.db
|
||||
.prepare(
|
||||
`INSERT INTO messages(client_id, sender, direction, text, timestamp, attachment_name, attachment_mime, attachment_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
clientId,
|
||||
payload.from || "Unknown",
|
||||
"from_target",
|
||||
"",
|
||||
ts,
|
||||
name,
|
||||
mime,
|
||||
buf
|
||||
);
|
||||
ctx.broadcast("new_message", {
|
||||
id: Number(info.lastInsertRowid),
|
||||
const ts = Date.now();
|
||||
const { name, mime, buf } = attachment;
|
||||
const sender = sanitizeChatText(payload?.from || "Unknown", 128) || "Unknown";
|
||||
const stored = storeChatMessage(ctx, {
|
||||
clientId,
|
||||
sender: payload.from || "Unknown",
|
||||
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,
|
||||
direction: "from_target",
|
||||
text: "",
|
||||
timestamp: ts,
|
||||
@@ -131,78 +624,93 @@ export default {
|
||||
},
|
||||
|
||||
get_attachment(ctx, params) {
|
||||
const clientId = String(params?.clientId || "").trim();
|
||||
if (!clientId) return { ok: false, error: "clientId required" };
|
||||
const row = ctx.db
|
||||
.prepare(
|
||||
`SELECT attachment_name, attachment_mime, attachment_data FROM messages WHERE id = ?`
|
||||
`SELECT attachment_name, attachment_mime, attachment_data FROM messages WHERE id = ? AND client_id = ?`
|
||||
)
|
||||
.get(params.id);
|
||||
.get(params.id, clientId);
|
||||
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);
|
||||
const checked = validateChatAttachmentBytes(row.attachment_mime, buf);
|
||||
if (!checked.ok) {
|
||||
return { ok: false, error: "unsafe attachment blocked" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
name: row.attachment_name,
|
||||
mime: row.attachment_mime,
|
||||
name: sanitizeAttachmentName(row.attachment_name),
|
||||
mime: checked.mime,
|
||||
dataB64: buf.toString("base64"),
|
||||
};
|
||||
},
|
||||
|
||||
store_message(ctx, params) {
|
||||
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" };
|
||||
const ts = Date.now();
|
||||
const info = ctx.db
|
||||
.prepare(
|
||||
`INSERT INTO messages(client_id, sender, direction, text, timestamp) VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(params.clientId, params.sender, "to_target", params.text, ts);
|
||||
ctx.broadcast("new_message", {
|
||||
id: Number(info.lastInsertRowid),
|
||||
clientId: params.clientId,
|
||||
sender: params.sender,
|
||||
const stored = storeChatMessage(ctx, {
|
||||
clientId,
|
||||
sender,
|
||||
direction: "to_target",
|
||||
text: params.text,
|
||||
text,
|
||||
timestamp: ts,
|
||||
});
|
||||
return { ok: true, id: Number(info.lastInsertRowid), timestamp: ts };
|
||||
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 };
|
||||
},
|
||||
|
||||
store_attachment(ctx, params) {
|
||||
const ts = Date.now();
|
||||
const name = params.name || "file";
|
||||
const mime = params.mime || "application/octet-stream";
|
||||
let buf = null;
|
||||
try {
|
||||
buf = Buffer.from(params.dataB64 || "", "base64");
|
||||
} catch (_) {}
|
||||
if (!buf || buf.length === 0) {
|
||||
return { ok: false, error: "empty data" };
|
||||
const attachment = validateChatAttachment(params);
|
||||
if (!attachment.ok) {
|
||||
return { ok: false, error: attachment.error };
|
||||
}
|
||||
const info = ctx.db
|
||||
.prepare(
|
||||
`INSERT INTO messages(client_id, sender, direction, text, timestamp, attachment_name, attachment_mime, attachment_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
params.clientId,
|
||||
params.sender,
|
||||
"to_target",
|
||||
"",
|
||||
ts,
|
||||
name,
|
||||
mime,
|
||||
buf
|
||||
);
|
||||
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;
|
||||
ctx.broadcast("new_message", {
|
||||
id: Number(info.lastInsertRowid),
|
||||
clientId: params.clientId,
|
||||
sender: params.sender,
|
||||
id: stored.id,
|
||||
clientId,
|
||||
sender,
|
||||
direction: "to_target",
|
||||
text: "",
|
||||
timestamp: ts,
|
||||
attachment: { name, mime, size: buf.length },
|
||||
});
|
||||
return { ok: true, id: Number(info.lastInsertRowid), timestamp: ts };
|
||||
return {
|
||||
ok: true,
|
||||
id: stored.id,
|
||||
timestamp: ts,
|
||||
name,
|
||||
mime,
|
||||
};
|
||||
},
|
||||
|
||||
clear_history(ctx, params) {
|
||||
|
||||
Reference in New Issue
Block a user