From ceb8f7edf5062fc9fac2e044ca5b14d1007faa47 Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 11 Jun 2026 20:50:14 +0200 Subject: [PATCH] Add join captcha gate --- kkkmeet-server.el | 88 ++++++++++++++++++++------------- public/app.js | 121 +++++++++++++++++++++++++++++++++++++++++----- public/index.html | 25 +++++++--- public/styles.css | 47 +++++++++++++++++- server.js | 20 ++++++++ 5 files changed, 246 insertions(+), 55 deletions(-) diff --git a/kkkmeet-server.el b/kkkmeet-server.el index d3518e3..b118fc1 100644 --- a/kkkmeet-server.el +++ b/kkkmeet-server.el @@ -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)) diff --git a/public/app.js b/public/app.js index 15a2ce3..76fc468 100644 --- a/public/app.js +++ b/public/app.js @@ -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); }); diff --git a/public/index.html b/public/index.html index 7031314..e119f1d 100644 --- a/public/index.html +++ b/public/index.html @@ -150,13 +150,24 @@

Choose your name

- +
+ +
+ + +
+
diff --git a/public/styles.css b/public/styles.css index 2314547..bd211a5 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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 { diff --git a/server.js b/server.js index 1b6b9e4..a1e7305 100644 --- a/server.js +++ b/server.js @@ -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);