Files
kkkmeet/server.js
T
2026-06-12 21:54:02 +02:00

419 lines
14 KiB
JavaScript

import http from "node:http";
import { readFile } from "node:fs/promises";
import { extname, join, normalize } from "node:path";
import { randomUUID } from "node:crypto";
const port = Number(process.env.PORT || 3000);
const host = process.env.HOST || "127.0.0.1";
const captchaPassphrase = process.env.CAPTCHA || "kkkmeet";
const publicDir = join(process.cwd(), "public");
const rooms = new Map();
const watchStates = new Map();
const presentationStates = new Map();
const defaultRoomId = "main";
const captchaGlyphs = {
a: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
b: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
c: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
d: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
e: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
f: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
g: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"],
h: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
i: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"],
j: ["00111", "00010", "00010", "00010", "10010", "10010", "01100"],
k: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
l: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
m: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"],
n: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
o: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
p: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"],
r: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
s: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
t: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
u: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
v: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"],
w: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"],
x: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"],
y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"],
z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"],
0: ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
1: ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
2: ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
3: ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
4: ["10010", "10010", "10010", "11111", "00010", "00010", "00010"],
5: ["11111", "10000", "10000", "11110", "00001", "00001", "11110"],
6: ["01111", "10000", "10000", "11110", "10001", "10001", "01110"],
7: ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
8: ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
9: ["01110", "10001", "10001", "01111", "00001", "00001", "11110"],
"-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
_: ["00000", "00000", "00000", "00000", "00000", "00000", "11111"]
};
const mimeTypes = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml"
};
function sendJson(res, status, body) {
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store"
});
res.end(JSON.stringify(body));
}
function getRoom(roomId) {
if (!rooms.has(roomId)) {
rooms.set(roomId, new Map());
}
return rooms.get(roomId);
}
function normalizeName(name) {
const normalized = String(name || "").trim().replace(/\s+/g, " ");
return normalized.slice(0, 48) || "Guest";
}
function normalizeAnnouncement(text) {
return String(text || "").trim().replace(/\s+/g, " ").slice(0, 240);
}
function clampNumber(value, fallback, min, max) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, parsed));
}
function normalizeVoiceSettings(settings = {}) {
const source = settings && typeof settings === "object" ? settings : {};
return {
amplitude: clampNumber(source.amplitude, 100, 0, 200),
pitch: clampNumber(source.pitch, 32, 0, 99),
speed: clampNumber(source.speed, 135, 80, 240),
wordgap: clampNumber(source.wordgap, 4, 0, 20)
};
}
function normalizeClientId(value) {
const normalized = String(value || "").trim();
return /^[a-z0-9_-]{16,80}$/i.test(normalized) ? normalized : "";
}
function captchaAccepted(value) {
return String(value || "").toLowerCase() === captchaPassphrase.toLowerCase();
}
function captchaSvg() {
const cell = 4;
const gap = 4;
const padding = 10;
const chars = [...captchaPassphrase.toLowerCase()];
const width = Math.max(160, padding * 2 + chars.length * (5 * cell + gap) - gap);
const height = 42;
const rects = [];
chars.forEach((char, index) => {
const glyph = captchaGlyphs[char] || captchaGlyphs._;
const xOffset = padding + index * (5 * cell + gap);
glyph.forEach((row, y) => {
[...row].forEach((pixel, x) => {
if (pixel === "1") {
rects.push(`<rect x="${xOffset + x * cell}" y="${7 + y * cell}" width="${cell - 1}" height="${cell - 1}" rx="1"/>`);
}
});
});
});
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect width="100%" height="100%" fill="#eef2f6"/>
<path d="M0 34 C35 2 78 44 ${width} 12" stroke="rgba(24,32,42,0.22)" stroke-width="1.5" fill="none"/>
<g fill="#18202a" transform="skewX(-4)">${rects.join("")}</g>
</svg>`;
}
function normalizePlaylist(playlist) {
if (!Array.isArray(playlist)) return [];
const seen = new Set();
return playlist
.map((item) => ({
videoId: String(item?.videoId || "").trim(),
title: String(item?.title || "").trim(),
position: Math.max(0, Number(item?.position) || 0),
duration: Math.max(0, Number(item?.duration) || 0)
}))
.filter((item) => {
if (!/^[a-zA-Z0-9_-]{11}$/.test(item.videoId) || seen.has(item.videoId)) {
return false;
}
seen.add(item.videoId);
item.title ||= `YouTube video ${item.videoId}`;
return true;
})
.slice(0, 50);
}
function broadcast(roomId, senderId, event) {
const room = rooms.get(roomId);
if (!room) return;
for (const [peerId, peer] of room) {
if (peerId === senderId) continue;
peer.res.write(`data: ${JSON.stringify(event)}\n\n`);
}
}
function removePeer(roomId, peerId) {
const room = rooms.get(roomId);
if (!room) return;
const peer = room.get(peerId);
const name = peer?.name || "A participant";
room.delete(peerId);
broadcast(roomId, peerId, { type: "peer-left", peerId, name });
if (presentationStates.get(roomId)?.peerId === peerId) {
presentationStates.delete(roomId);
broadcast(roomId, peerId, { type: "presentation-state", from: peerId, name, sharing: false });
}
if (room.size === 0) {
rooms.delete(roomId);
watchStates.delete(roomId);
presentationStates.delete(roomId);
}
}
async function parseJson(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString("utf8");
return raw ? JSON.parse(raw) : {};
}
async function serveStatic(req, res) {
const url = new URL(req.url, `http://${req.headers.host}`);
const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname;
const safePath = normalize(decodeURIComponent(requestedPath)).replace(/^(\.\.[/\\])+/, "");
const filePath = join(publicDir, safePath);
if (!filePath.startsWith(publicDir)) {
res.writeHead(403);
res.end("Forbidden");
return;
}
try {
const data = await readFile(filePath);
res.writeHead(200, {
"content-type": mimeTypes[extname(filePath)] || "application/octet-stream",
"cache-control": "no-store"
});
res.end(req.method === "HEAD" ? undefined : data);
} catch {
res.writeHead(404);
res.end("Not found");
}
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
try {
if (req.method === "GET" && url.pathname.startsWith("/events/")) {
if (!captchaAccepted(url.searchParams.get("captcha"))) {
sendJson(res, 403, { error: "Captcha passphrase is required." });
return;
}
const roomId = url.pathname.split("/").at(-1) || defaultRoomId;
const displayName = normalizeName(url.searchParams.get("name"));
const clientId = normalizeClientId(url.searchParams.get("clientId"));
const peerId = randomUUID();
const room = getRoom(roomId);
const existingPeers = [...room].map(([id, peer]) => ({
id,
name: peer.name,
clientId: peer.clientId
}));
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store, no-transform",
connection: "keep-alive",
"x-accel-buffering": "no"
});
room.set(peerId, { res, name: displayName, clientId });
res.write(
`data: ${JSON.stringify({
type: "welcome",
peerId,
name: displayName,
clientId,
peers: existingPeers,
watchState: watchStates.get(roomId) || null,
presentationState: presentationStates.get(roomId) || null
})}\n\n`
);
broadcast(roomId, peerId, { type: "peer-joined", peerId, name: displayName, clientId });
const heartbeat = setInterval(() => {
res.write(": heartbeat\n\n");
}, 15000);
req.on("close", () => {
clearInterval(heartbeat);
removePeer(roomId, peerId);
});
return;
}
if ((req.method === "GET" || req.method === "HEAD") && url.pathname === "/captcha.svg") {
res.writeHead(200, {
"content-type": "image/svg+xml; charset=utf-8",
"cache-control": "no-store"
});
res.end(req.method === "HEAD" ? undefined : captchaSvg());
return;
}
if (req.method === "GET" && url.pathname === "/captcha/verify") {
sendJson(res, 200, { ok: captchaAccepted(url.searchParams.get("answer")) });
return;
}
if (req.method === "POST" && url.pathname.startsWith("/room-event/")) {
const roomId = url.pathname.split("/").at(-1) || defaultRoomId;
const payload = await parseJson(req);
if (!payload.from || !["presentation-state", "voice-announcement"].includes(payload.type)) {
sendJson(res, 400, { error: "Room events require from and a supported type." });
return;
}
if (payload.type === "voice-announcement") {
const text = normalizeAnnouncement(payload.text);
if (!text) {
sendJson(res, 400, { error: "Voice announcements require text." });
return;
}
const event = {
type: "voice-announcement",
from: payload.from,
name: normalizeName(payload.name),
text,
voice: normalizeVoiceSettings(payload.voice),
updatedAt: Date.now()
};
broadcast(roomId, payload.from, event);
sendJson(res, 202, { ok: true });
return;
}
const event = {
type: "presentation-state",
from: payload.from,
name: normalizeName(payload.name),
sharing: Boolean(payload.sharing),
updatedAt: Date.now()
};
if (event.sharing) {
const previous = presentationStates.get(roomId);
if (previous?.peerId && previous.peerId !== payload.from) {
broadcast(roomId, payload.from, {
type: "presentation-state",
from: previous.peerId,
peerId: previous.peerId,
name: previous.name,
sharing: false,
reason: "takeover",
replacedBy: payload.from,
replacedByName: event.name,
updatedAt: Date.now()
});
}
presentationStates.set(roomId, { ...event, peerId: payload.from });
} else if (presentationStates.get(roomId)?.peerId === payload.from) {
presentationStates.delete(roomId);
}
broadcast(roomId, payload.from, event);
sendJson(res, 202, { ok: true });
return;
}
if (req.method === "POST" && url.pathname.startsWith("/signal/")) {
const roomId = url.pathname.split("/").at(-1);
const payload = await parseJson(req);
if (!payload.from || !payload.to || !payload.type) {
sendJson(res, 400, { error: "Signal messages require from, to, and type." });
return;
}
const room = rooms.get(roomId);
const target = room?.get(payload.to);
if (target) {
target.res.write(`data: ${JSON.stringify(payload)}\n\n`);
}
sendJson(res, 202, { ok: true });
return;
}
if (req.method === "POST" && url.pathname.startsWith("/watch/")) {
const roomId = url.pathname.split("/").at(-1) || defaultRoomId;
const payload = await parseJson(req);
if (!payload.from || payload.type !== "watch-state") {
sendJson(res, 400, { error: "Watch messages require from and type=watch-state." });
return;
}
const playlist = normalizePlaylist(payload.playlist);
const videoId = String(payload.videoId || "").trim();
const activeVideoId = playlist.some((item) => item.videoId === videoId) ? videoId : "";
const action = payload.action === "play" && activeVideoId ? "play" : "pause";
const watchState = {
type: "watch-state",
from: payload.from,
name: normalizeName(payload.name),
videoId: activeVideoId,
playlist,
position: Number(payload.position) || 0,
playing: action === "play",
action,
updatedAt: Number(payload.updatedAt) || Date.now()
};
watchStates.set(roomId, watchState);
broadcast(roomId, payload.from, watchState);
sendJson(res, 202, { ok: true });
return;
}
if (req.method === "GET" || req.method === "HEAD") {
await serveStatic(req, res);
return;
}
res.writeHead(405);
res.end("Method not allowed");
} catch (error) {
sendJson(res, 500, { error: error.message });
}
});
server.listen(port, host, () => {
console.log(`kkkmeet is running at http://${host}:${port}`);
});