Add join captcha gate

This commit is contained in:
codex
2026-06-11 20:50:14 +02:00
parent 8dc0299b75
commit ceb8f7edf5
5 changed files with 246 additions and 55 deletions
+54 -34
View File
@@ -2,6 +2,7 @@
;; Run from a shell:
;; emacs -Q --batch -l kkkmeet-server.el -f kkkmeet-server-run-batch
;; KKKMEET_CAPTCHA_PASSPHRASE=secret emacs -Q --batch -l kkkmeet-server.el -f kkkmeet-server-run-batch
;;
;; Or from Emacs:
;; M-x kkkmeet-server-start
@@ -88,6 +89,14 @@
normalized
"")))
(defun kkkmeet-server--captcha-passphrase ()
"Return the configured join captcha passphrase."
(or (getenv "KKKMEET_CAPTCHA_PASSPHRASE") "kkkmeet"))
(defun kkkmeet-server--captcha-accepted-p (value)
"Return non-nil when VALUE matches the configured captcha passphrase."
(equal (or value "") (kkkmeet-server--captcha-passphrase)))
(defun kkkmeet-server--nonnegative-number (value)
"Return VALUE as a nonnegative number, or zero."
(max 0 (if (numberp value) value 0)))
@@ -267,40 +276,48 @@ When KEEP-OPEN is nil, close PROCESS after writing."
(when-let ((query (cadr (split-string target "?" t))))
(cadr (assoc name (url-parse-query-string query)))))
(defun kkkmeet-server--handle-events (process room-id display-name client-id)
(defun kkkmeet-server--handle-config (process)
"Send public runtime configuration to PROCESS."
(kkkmeet-server--send-json
process 200 `((captcha . ((enabled . t)
(passphrase . ,(kkkmeet-server--captcha-passphrase)))))))
(defun kkkmeet-server--handle-events (process room-id display-name client-id captcha)
"Attach PROCESS to ROOM-ID as an SSE client."
(let* ((safe-room-id (if (string-empty-p room-id) kkkmeet-server--default-room-id room-id))
(room (kkkmeet-server--room safe-room-id))
(peer-id (kkkmeet-server--uuid))
(peers (kkkmeet-server--room-peers room))
(safe-name (kkkmeet-server--normalize-name display-name))
(safe-client-id (kkkmeet-server--normalize-client-id client-id)))
(process-put process :kkkmeet-room-id safe-room-id)
(process-put process :kkkmeet-peer-id peer-id)
(process-put process :kkkmeet-name safe-name)
(process-put process :kkkmeet-client-id safe-client-id)
(puthash peer-id process room)
(kkkmeet-server--send-response
process 200
'(("Content-Type" . "text/event-stream; charset=utf-8")
("Cache-Control" . "no-store, no-transform")
("X-Accel-Buffering" . "no"))
nil t)
(kkkmeet-server--send-sse
process `((type . "welcome")
(peerId . ,peer-id)
(name . ,safe-name)
(clientId . ,safe-client-id)
(peers . ,peers)
(watchState . ,(or (gethash safe-room-id kkkmeet-server--watch-states)
:null))
(presentationState . ,(or (gethash safe-room-id kkkmeet-server--presentation-states)
:null))))
(kkkmeet-server--broadcast
safe-room-id peer-id `((type . "peer-joined")
(peerId . ,peer-id)
(name . ,safe-name)
(clientId . ,safe-client-id)))))
(if (not (kkkmeet-server--captcha-accepted-p captcha))
(kkkmeet-server--send-json process 403 '((error . "Captcha passphrase is required.")))
(let* ((safe-room-id (if (string-empty-p room-id) kkkmeet-server--default-room-id room-id))
(room (kkkmeet-server--room safe-room-id))
(peer-id (kkkmeet-server--uuid))
(peers (kkkmeet-server--room-peers room))
(safe-name (kkkmeet-server--normalize-name display-name))
(safe-client-id (kkkmeet-server--normalize-client-id client-id)))
(process-put process :kkkmeet-room-id safe-room-id)
(process-put process :kkkmeet-peer-id peer-id)
(process-put process :kkkmeet-name safe-name)
(process-put process :kkkmeet-client-id safe-client-id)
(puthash peer-id process room)
(kkkmeet-server--send-response
process 200
'(("Content-Type" . "text/event-stream; charset=utf-8")
("Cache-Control" . "no-store, no-transform")
("X-Accel-Buffering" . "no"))
nil t)
(kkkmeet-server--send-sse
process `((type . "welcome")
(peerId . ,peer-id)
(name . ,safe-name)
(clientId . ,safe-client-id)
(peers . ,peers)
(watchState . ,(or (gethash safe-room-id kkkmeet-server--watch-states)
:null))
(presentationState . ,(or (gethash safe-room-id kkkmeet-server--presentation-states)
:null))))
(kkkmeet-server--broadcast
safe-room-id peer-id `((type . "peer-joined")
(peerId . ,peer-id)
(name . ,safe-name)
(clientId . ,safe-client-id))))))
(defun kkkmeet-server--handle-signal (process room-id body)
"Handle signaling BODY from PROCESS for ROOM-ID."
@@ -417,12 +434,15 @@ When KEEP-OPEN is nil, close PROCESS after writing."
(path (alist-get 'path request))
(body (alist-get 'body request)))
(cond
((and (equal method "GET") (equal path "/config"))
(kkkmeet-server--handle-config process))
((and (equal method "GET") (string-prefix-p "/events/" path))
(kkkmeet-server--handle-events
process
(url-unhex-string (string-remove-prefix "/events/" path))
(kkkmeet-server--query-param target "name")
(kkkmeet-server--query-param target "clientId")))
(kkkmeet-server--query-param target "clientId")
(kkkmeet-server--query-param target "captcha")))
((and (equal method "POST") (string-prefix-p "/signal/" path))
(kkkmeet-server--handle-signal
process (url-unhex-string (string-remove-prefix "/signal/" path)) body))
+109 -12
View File
@@ -9,6 +9,9 @@ const nameDialogTitle = document.querySelector("#name-dialog-title");
const nameDialogSubmit = document.querySelector("#name-dialog-submit");
const initialNameForm = document.querySelector("#initial-name-form");
const initialNameInput = document.querySelector("#initial-name-input");
const captchaControl = document.querySelector("#captcha-control");
const captchaCanvas = document.querySelector("#captcha-canvas");
const captchaInput = document.querySelector("#captcha-input");
const statusText = document.querySelector("#status");
const activityStatusText = document.querySelector("#activity-status");
const youtubeForm = document.querySelector("#youtube-form");
@@ -37,6 +40,7 @@ const announcementCancelButton = document.querySelector("#announcement-cancel-bu
const roomId = "main";
const nameStorageKey = "kkkmeet.displayName";
const clientIdStorageKey = "kkkmeet.clientId";
const captchaAnswerStorageKey = "kkkmeet.captchaAnswer";
const cameraStorageKey = "kkkmeet.cameraDeviceId";
const micStorageKey = "kkkmeet.micDeviceId";
const micVolumeStorageKey = "kkkmeet.selfMicVolume";
@@ -75,6 +79,7 @@ let watchApplySequence = 0;
let youtubePlaybackRequestId = 0;
let watchPlaylist = [];
let remoteParticipantCount = 0;
let captchaPassphrase = "kkkmeet";
const peers = new Map();
function persistentClientId() {
@@ -206,13 +211,74 @@ function openNameDialog(mode) {
const renaming = mode === "rename";
nameDialogTitle.textContent = renaming ? "Rename yourself" : "Choose your name";
nameDialogSubmit.textContent = renaming ? "Save name" : "Join call";
initialNameInput.value = renaming ? displayName : "";
initialNameInput.value = renaming ? displayName : displayName || "";
captchaControl.hidden = renaming;
captchaInput.required = !renaming;
if (!renaming) {
captchaInput.value = localStorage.getItem(captchaAnswerStorageKey) || "";
drawCaptchaPassphrase();
}
nameDialog.dataset.mode = mode;
nameDialog.showModal();
initialNameInput.focus();
initialNameInput.select();
}
async function loadRuntimeConfig() {
try {
const response = await fetch("/config", { cache: "no-store" });
if (!response.ok) throw new Error("Config request failed");
const config = await response.json();
captchaPassphrase = String(config?.captcha?.passphrase || "kkkmeet");
} catch (error) {
console.warn("Could not load runtime config, using default captcha passphrase.", error);
captchaPassphrase = "kkkmeet";
}
}
function drawCaptchaPassphrase() {
const context = captchaCanvas.getContext("2d");
if (!context) return;
const width = captchaCanvas.width;
const height = captchaCanvas.height;
context.clearRect(0, 0, width, height);
context.fillStyle = "#eef2f6";
context.fillRect(0, 0, width, height);
for (let x = -20; x < width; x += 22) {
context.strokeStyle = x % 44 === 0 ? "rgba(31, 143, 95, 0.25)" : "rgba(31, 90, 143, 0.22)";
context.lineWidth = 2;
context.beginPath();
context.moveTo(x, height);
context.lineTo(x + 34, 0);
context.stroke();
}
context.fillStyle = "#18202a";
context.font = "700 19px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
context.textAlign = "center";
context.textBaseline = "middle";
context.setTransform(1, -0.04, 0.05, 1, 0, 0);
context.fillText(captchaPassphrase, width / 2, height / 2 + 2);
context.setTransform(1, 0, 0, 1, 0, 0);
context.strokeStyle = "rgba(24, 32, 42, 0.22)";
context.lineWidth = 1;
context.beginPath();
context.moveTo(8, height / 2 + 7);
context.bezierCurveTo(44, 8, 92, 48, width - 8, 16);
context.stroke();
}
function captchaAnswer() {
return String(captchaInput.value || localStorage.getItem(captchaAnswerStorageKey) || "").trim();
}
function captchaIsCorrect() {
return captchaAnswer() === captchaPassphrase;
}
function labelForPeer(peer) {
return peer?.name || `Participant ${peer?.id?.slice(0, 4) || ""}`.trim();
}
@@ -1279,14 +1345,20 @@ async function joinCall() {
openNameDialog("join");
return;
}
if (!captchaIsCorrect()) {
setStatus("Enter the captcha passphrase to join the call.");
openNameDialog("join");
captchaInput.focus();
return;
}
leaveCall();
await ensureLocalStream();
const params = new URLSearchParams({ name: displayName, clientId });
const params = new URLSearchParams({ name: displayName, clientId, captcha: captchaAnswer() });
events = new EventSource(`/events/${encodeURIComponent(roomId)}?${params}`);
events.onmessage = (event) => handleSignal(JSON.parse(event.data)).catch(console.error);
events.onerror = () => setStatus("Connection to signaling server was interrupted.");
events.onerror = () => setStatus("Connection to signaling server was interrupted or captcha was denied.");
setStatus(`Joining as ${displayName}...`);
}
@@ -1430,7 +1502,18 @@ async function stopScreenShare() {
initialNameForm.addEventListener("submit", (event) => {
event.preventDefault();
if (nameDialog.dataset.mode !== "rename" && !captchaIsCorrect()) {
localStorage.removeItem(captchaAnswerStorageKey);
captchaInput.value = "";
drawCaptchaPassphrase();
setStatus("Captcha passphrase did not match.");
captchaInput.focus();
return;
}
setDisplayName(initialNameInput.value);
if (nameDialog.dataset.mode !== "rename") {
localStorage.setItem(captchaAnswerStorageKey, captchaAnswer());
}
nameDialog.close();
if (events) {
joinCall().catch((error) => {
@@ -1578,19 +1661,33 @@ selfMicVolumeInput.value = String(savedSelfMicVolume());
selfMicMeter.value = 0;
noiseSuppressionInput.checked = savedNoiseSuppression();
const savedName = localStorage.getItem(nameStorageKey);
if (savedName) {
setDisplayName(savedName);
joinCall().catch((error) => {
console.error(error);
setStatus("Could not start camera or join the call.");
});
} else {
setStatus("Choose your name to join the call.");
async function initializeApp() {
await loadRuntimeConfig();
const savedName = localStorage.getItem(nameStorageKey);
if (savedName) {
setDisplayName(savedName);
if (captchaIsCorrect()) {
joinCall().catch((error) => {
console.error(error);
setStatus("Could not start camera or join the call.");
});
return;
}
}
setStatus(savedName
? "Enter the captcha passphrase to join the call."
: "Choose your name to join the call.");
openNameDialog("join");
refreshDeviceLists().catch(console.error);
}
initializeApp().catch((error) => {
console.error(error);
setStatus("Could not initialize the application.");
});
navigator.mediaDevices?.addEventListener?.("devicechange", () => {
refreshDeviceLists().catch(console.error);
});
+18 -7
View File
@@ -150,13 +150,24 @@
<dialog id="name-dialog" class="name-dialog">
<form id="initial-name-form" class="initial-name-form">
<h2 id="name-dialog-title">Choose your name</h2>
<input
id="initial-name-input"
autocomplete="name"
maxlength="48"
placeholder="Your name"
required
/>
<div class="join-fields">
<input
id="initial-name-input"
autocomplete="name"
maxlength="48"
placeholder="Your name"
required
/>
<div id="captcha-control" class="captcha-control">
<canvas id="captcha-canvas" width="160" height="42" aria-label="Captcha passphrase"></canvas>
<input
id="captcha-input"
autocomplete="off"
placeholder="Passphrase"
required
/>
</div>
</div>
<button id="name-dialog-submit" type="submit">Join call</button>
</form>
</dialog>
+45 -2
View File
@@ -74,7 +74,7 @@ h1 {
}
.name-dialog {
width: min(420px, calc(100vw - 32px));
width: min(560px, calc(100vw - 32px));
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
background: #171b20;
@@ -98,12 +98,44 @@ h1 {
letter-spacing: 0;
}
.initial-name-form input,
.initial-name-form textarea,
.initial-name-form button {
width: 100%;
}
.join-fields {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(240px, 1fr);
gap: 12px;
align-items: stretch;
}
.join-fields > input {
width: 100%;
}
.captcha-control {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 8px;
align-items: stretch;
}
.captcha-control[hidden] {
display: none;
}
.captcha-control canvas {
width: 160px;
height: 42px;
border-radius: 8px;
background: #f2f4f7;
}
.captcha-control input {
width: 100%;
}
.initial-name-form textarea {
min-height: 110px;
resize: vertical;
@@ -115,6 +147,17 @@ h1 {
gap: 10px;
}
@media (max-width: 620px) {
.join-fields,
.captcha-control {
grid-template-columns: 1fr;
}
.captcha-control canvas {
width: 100%;
}
}
input,
select,
textarea {
+20
View File
@@ -5,6 +5,7 @@ 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.KKKMEET_CAPTCHA_PASSPHRASE || "kkkmeet";
const publicDir = join(process.cwd(), "public");
const rooms = new Map();
const watchStates = new Map();
@@ -48,6 +49,10 @@ function normalizeClientId(value) {
return /^[a-z0-9_-]{16,80}$/i.test(normalized) ? normalized : "";
}
function captchaAccepted(value) {
return String(value || "") === captchaPassphrase;
}
function normalizePlaylist(playlist) {
if (!Array.isArray(playlist)) return [];
@@ -137,6 +142,11 @@ const server = http.createServer(async (req, res) => {
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"));
@@ -180,6 +190,16 @@ const server = http.createServer(async (req, res) => {
return;
}
if (req.method === "GET" && url.pathname === "/config") {
sendJson(res, 200, {
captcha: {
enabled: true,
passphrase: captchaPassphrase
}
});
return;
}
if (req.method === "POST" && url.pathname.startsWith("/room-event/")) {
const roomId = url.pathname.split("/").at(-1) || defaultRoomId;
const payload = await parseJson(req);