tiny update

This commit is contained in:
KingKDot
2026-08-24 09:35:03 -05:00
parent fcc6cc53be
commit 0921e39333
131 changed files with 9948 additions and 1076 deletions
+45
View File
@@ -254,6 +254,46 @@
cursor: zoom-in;
}
.chat-image-preview {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
background: rgba(0, 0, 0, 0.86);
}
.chat-image-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 16px 60px rgba(0, 0, 0, 0.55);
}
.chat-image-preview-close {
position: fixed;
top: 16px;
right: 20px;
width: 40px;
height: 40px;
border: 1px solid #455168;
border-radius: 999px;
background: #161a22;
color: #e8ecf1;
font-size: 28px;
line-height: 1;
cursor: pointer;
}
.chat-image-preview-close:hover,
.chat-image-preview-close:focus-visible {
background: #262c3a;
outline: 2px solid #60a5fa;
}
.chat-msg-file {
display: inline-flex;
align-items: center;
@@ -273,6 +313,11 @@
border-color: #3a4a60;
}
.chat-msg-file-blocked {
color: #fca5a5;
cursor: default;
}
.chat-msg-file i {
color: #8a96aa;
}
+1 -1
View File
@@ -59,7 +59,7 @@
<!-- Input -->
<section class="chat-input-area">
<input type="file" id="file-input" hidden />
<button id="btn-attach" class="chat-btn chat-btn-muted chat-btn-icon" disabled title="Attach file (max 5 MB)"><i class="fa-solid fa-paperclip"></i></button>
<button id="btn-attach" class="chat-btn chat-btn-muted chat-btn-icon" disabled title="Attach file (max 64 MB)"><i class="fa-solid fa-paperclip"></i></button>
<input type="text" id="msg-input" class="chat-field chat-msg-field" placeholder="Type a message..." disabled />
<button id="btn-send" class="chat-btn chat-btn-blue" disabled>Send</button>
</section>
+116 -40
View File
@@ -1,6 +1,12 @@
(() => {
const PLUGIN_ID = "chat";
const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024;
const MAX_ATTACHMENT_SIZE = 64 * 1024 * 1024;
const MAX_ATTACHMENT_BASE64_SIZE = Math.ceil(MAX_ATTACHMENT_SIZE / 3) * 4;
const SAFE_IMAGE_MIMES = new Set([
"image/png",
"image/jpeg",
"image/webp",
]);
const params = new URLSearchParams(window.location.search);
@@ -69,58 +75,119 @@
});
}
async function loadAttachmentBlob(id) {
async function loadAttachmentBlob(id, expectedMime) {
if (attachmentUrlCache.has(id)) return attachmentUrlCache.get(id);
const { ok, result } = await rpc("get_attachment", { id });
if (!ok || !result?.dataB64) return null;
const bin = atob(result.dataB64);
const clientId = getClientId();
if (!clientId) return null;
const { ok, result } = await rpc("get_attachment", { id, clientId });
if (
!ok ||
!result?.ok ||
(SAFE_IMAGE_MIMES.has(expectedMime) && result.mime !== expectedMime) ||
typeof result.dataB64 !== "string" ||
result.dataB64.length === 0 ||
result.dataB64.length > MAX_ATTACHMENT_BASE64_SIZE
) return null;
let bin;
try {
bin = atob(result.dataB64);
} catch {
return null;
}
if (bin.length === 0 || bin.length > MAX_ATTACHMENT_SIZE) return null;
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const blob = new Blob([bytes], {
type: result.mime || "application/octet-stream",
type: SAFE_IMAGE_MIMES.has(expectedMime) ? expectedMime : "application/octet-stream",
});
const url = URL.createObjectURL(blob);
if (attachmentUrlCache.size >= 10) {
const oldestId = attachmentUrlCache.keys().next().value;
const oldestUrl = attachmentUrlCache.get(oldestId);
if (oldestUrl) URL.revokeObjectURL(oldestUrl);
attachmentUrlCache.delete(oldestId);
}
attachmentUrlCache.set(id, url);
return url;
}
function showImagePreview(url, name) {
const overlay = document.createElement("div");
overlay.className = "chat-image-preview";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-label", `Image preview: ${name}`);
const close = document.createElement("button");
close.type = "button";
close.className = "chat-image-preview-close";
close.setAttribute("aria-label", "Close image preview");
close.textContent = "×";
const image = document.createElement("img");
image.src = url;
image.alt = name;
const remove = () => {
document.removeEventListener("keydown", onKeyDown);
overlay.remove();
};
const onKeyDown = (event) => {
if (event.key === "Escape") remove();
};
close.addEventListener("click", remove);
overlay.addEventListener("click", (event) => {
if (event.target === overlay) remove();
});
document.addEventListener("keydown", onKeyDown);
overlay.append(image, close);
document.body.appendChild(overlay);
close.focus();
}
function renderAttachment(att, messageId) {
const wrap = document.createElement("div");
wrap.className = "chat-msg-attachment";
const isImage = (att.mime || "").startsWith("image/");
const mime = String(att?.mime || "").trim().toLowerCase();
const name = String(att?.name || "image").slice(0, 255);
const sizeValue = Number(att?.size);
const size = Number.isFinite(sizeValue) && sizeValue >= 0
? Math.min(sizeValue, MAX_ATTACHMENT_SIZE)
: 0;
const isImage = SAFE_IMAGE_MIMES.has(mime) && size > 0 && size <= MAX_ATTACHMENT_SIZE;
if (isImage) {
const img = document.createElement("img");
img.className = "chat-msg-image";
img.alt = att.name;
img.title = `${att.name} (${humanSize(att.size)})`;
loadAttachmentBlob(messageId).then((url) => {
if (url) img.src = url;
const view = document.createElement("button");
view.type = "button";
view.className = "chat-msg-file";
view.textContent = `View image: ${name} (${humanSize(size)})`;
view.addEventListener("click", async () => {
view.disabled = true;
const url = await loadAttachmentBlob(messageId, mime);
if (url) showImagePreview(url, name);
view.disabled = false;
});
img.addEventListener("click", async () => {
const url = await loadAttachmentBlob(messageId);
if (url) window.open(url, "_blank");
});
wrap.appendChild(img);
wrap.appendChild(view);
} else {
const link = document.createElement("a");
link.className = "chat-msg-file";
link.href = "#";
link.innerHTML = `<i class="fa-solid fa-file"></i> <span class="chat-msg-file-name"></span> <span class="chat-msg-file-size"></span>`;
link.querySelector(".chat-msg-file-name").textContent = att.name;
link.querySelector(".chat-msg-file-size").textContent = `(${humanSize(att.size)})`;
link.addEventListener("click", async (e) => {
e.preventDefault();
const url = await loadAttachmentBlob(messageId);
if (!url) return;
const a = document.createElement("a");
a.href = url;
a.download = att.name;
document.body.appendChild(a);
a.click();
a.remove();
const download = document.createElement("button");
download.type = "button";
download.className = "chat-msg-file";
download.textContent = `Download: ${name} (${humanSize(size)})`;
download.addEventListener("click", async () => {
download.disabled = true;
const url = await loadAttachmentBlob(messageId, mime);
if (url) {
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}
download.disabled = false;
});
wrap.appendChild(link);
wrap.appendChild(download);
}
return wrap;
}
@@ -192,7 +259,11 @@
function connectSSE() {
if (sseStream) sseStream.close();
sseStream = new EventSource(`/api/plugins/${PLUGIN_ID}/stream`);
const clientId = getClientId();
if (!clientId) return;
sseStream = new EventSource(
`/api/plugins/${PLUGIN_ID}/stream?clientId=${encodeURIComponent(clientId)}`,
);
sseStream.addEventListener("new_message", (e) => {
const data = JSON.parse(e.data);
@@ -294,18 +365,23 @@
btnAttach.disabled = true;
try {
const dataB64 = await readFileAsBase64(file);
const mime = file.type || "application/octet-stream";
await rpc("store_attachment", {
// Some platforms leave File.type blank for otherwise valid images. The
// server detects the real raster format and returns its canonical MIME.
const mime = file.type || "";
const stored = await rpc("store_attachment", {
clientId: cid,
sender,
name: file.name,
mime,
dataB64,
});
if (!stored.ok || !stored.result?.ok) {
throw new Error(stored.result?.error || stored.error || "Attachment rejected");
}
await sendPluginEvent(cid, "chat_attachment", {
from: sender,
name: file.name,
mime,
name: stored.result.name,
mime: stored.result.mime,
dataB64,
});
} catch (err) {
+1 -1
View File
@@ -43,7 +43,7 @@
#define IDC_SEND 103
#define IDC_ATTACH 104
#define MAX_ATTACHMENT_BYTES (5 * 1024 * 1024)
#define MAX_ATTACHMENT_BYTES (64 * 1024 * 1024)
static const char *WND_CLASS_NAME = "OverlordChatWnd";
+591 -83
View File
@@ -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) {