Lumi/plugins/lumi_overlay/backend/protocol.js

109 lines
4.1 KiB
JavaScript

"use strict";
const MAX_MESSAGE_BYTES = 64 * 1024;
const MAX_SELECTED_SOURCES = 250;
const MAX_SELECTED_EVENTS = 100;
function parseClientMessage(data) {
const body = Buffer.from(data);
if (body.length > MAX_MESSAGE_BYTES) throw coded("MESSAGE_TOO_LARGE", "Overlay control message is too large.");
let value;
try { value = JSON.parse(body.toString("utf8")); } catch { throw coded("INVALID_JSON", "Overlay control message must be valid JSON."); }
if (!value || typeof value !== "object" || Array.isArray(value)) throw coded("INVALID_MESSAGE", "Overlay control message must be an object.");
const type = String(value.type || "").trim();
if (!["subscribe", "ping", "pong"].includes(type)) throw coded("INVALID_MESSAGE", `Unsupported overlay control message '${type || "empty"}'.`);
if (type !== "subscribe") return { type };
return {
type,
sources: uniqueStrings(value.sources, MAX_SELECTED_SOURCES, 300),
events: uniqueStrings(value.events, MAX_SELECTED_EVENTS, 120)
};
}
function uniqueStrings(value, limit, maxLength) {
if (!Array.isArray(value)) return [];
return [...new Set(value.map((entry) => String(entry || "").trim().slice(0, maxLength)).filter(Boolean))].slice(0, limit);
}
function cleanText(value, max = 500) {
return String(value === undefined || value === null ? "" : value).slice(0, max);
}
function sanitizeChat(message) {
return {
id: cleanText(message.id, 256),
platform: cleanText(message.platform, 24),
text: cleanText(message.text, 2000),
timestamp: Number(message.timestamp) || Date.now(),
channel: {
id: cleanText(message.channel?.id, 256),
name: cleanText(message.channel?.name, 256),
key: cleanText(message.channel?.key, 256)
},
author: {
id: cleanText(message.author?.id, 160),
name: cleanText(message.author?.name, 160),
username: cleanText(message.author?.username, 160),
avatar: safeHttps(message.author?.avatar),
color: /^#[0-9a-f]{6}$/i.test(message.author?.color || "") ? message.author.color : null,
badges: (Array.isArray(message.author?.badges) ? message.author.badges : []).slice(0, 12).map((badge) => ({
label: cleanText(badge?.label, 40),
image: safeHttps(badge?.image)
}))
},
emotes: (Array.isArray(message.emotes) ? message.emotes : []).slice(0, 100).map((emote) => ({
start: Math.max(0, Number(emote?.start) || 0),
end: Math.max(0, Number(emote?.end) || 0),
label: cleanText(emote?.label, 100),
image: safeHttps(emote?.image)
})).filter((emote) => emote.image),
media: (Array.isArray(message.media) ? message.media : []).slice(0, 4).map((media) => ({
url: safeHttps(media?.url),
type: media?.type === "video" ? "video" : "image",
alt: cleanText(media?.alt, 160),
preview: safeHttps(media?.preview)
})).filter((media) => media.url)
};
}
function sanitizeEvent(event) {
return {
id: cleanText(event.id, 256),
type: cleanText(event.type, 120),
source: cleanText(event.source, 80),
occurred_at: Number(event.occurredAt) || Date.now(),
payload: sanitizeObject(event.payload, 0)
};
}
function sanitizeObject(value, depth) {
if (depth > 3 || value === null || value === undefined) return null;
if (Array.isArray(value)) return value.slice(0, 20).map((item) => sanitizeObject(item, depth + 1));
if (typeof value === "object") {
const output = {};
for (const [key, item] of Object.entries(value).slice(0, 50)) {
if (/token|secret|authorization|credential|oauth/i.test(key)) continue;
output[cleanText(key, 80)] = sanitizeObject(item, depth + 1);
}
return output;
}
if (typeof value === "string") return cleanText(value, 1000);
return typeof value === "number" || typeof value === "boolean" ? value : null;
}
function safeHttps(value) {
try {
const parsed = new URL(String(value || ""));
return parsed.protocol === "https:" ? parsed.toString() : null;
} catch { return null; }
}
function coded(code, message) { return Object.assign(new Error(message), { code }); }
module.exports = {
MAX_MESSAGE_BYTES,
parseClientMessage,
sanitizeChat,
sanitizeEvent
};