tiny update
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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