diff --git a/kkkmeet-server.el b/kkkmeet-server.el index b118fc1..ec23bd8 100644 --- a/kkkmeet-server.el +++ b/kkkmeet-server.el @@ -43,6 +43,46 @@ ("json" . "application/json; charset=utf-8") ("svg" . "image/svg+xml"))) +(defconst kkkmeet-server--captcha-glyphs + '(("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")))) + (defun kkkmeet-server--uuid () "Return a UUID-like random peer id." (let ((hex (md5 (format "%s-%s-%s" (current-time) (random) (emacs-pid))))) @@ -97,6 +137,44 @@ "Return non-nil when VALUE matches the configured captcha passphrase." (equal (or value "") (kkkmeet-server--captcha-passphrase))) +(defun kkkmeet-server--captcha-svg () + "Return an SVG captcha image for the configured passphrase." + (let* ((cell 4) + (gap 4) + (padding 10) + (passphrase (downcase (kkkmeet-server--captcha-passphrase))) + (chars (append passphrase nil)) + (width (max 160 (- (+ (* 2 padding) + (* (length chars) (+ (* 5 cell) gap))) + gap))) + (height 42) + rects) + (cl-loop for char in chars + for index from 0 + for key = (char-to-string char) + for glyph = (or (cdr (assoc key kkkmeet-server--captcha-glyphs)) + (cdr (assoc "_" kkkmeet-server--captcha-glyphs))) + do (cl-loop for row in glyph + for y from 0 + do (cl-loop for pixel across row + for x from 0 + when (eq pixel ?1) + do (push + (format + "" + (+ padding (* index (+ (* 5 cell) gap)) (* x cell)) + (+ 7 (* y cell)) + (1- cell) + (1- cell)) + rects)))) + (format " + + + + %s +" + width height width height width (string-join (nreverse rects) "")))) + (defun kkkmeet-server--nonnegative-number (value) "Return VALUE as a nonnegative number, or zero." (max 0 (if (numberp value) value 0))) @@ -276,11 +354,19 @@ 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-config (process) - "Send public runtime configuration to PROCESS." +(defun kkkmeet-server--handle-captcha (process method) + "Send the captcha image to PROCESS." + (kkkmeet-server--send-response + process 200 + '(("Content-Type" . "image/svg+xml; charset=utf-8") + ("Cache-Control" . "no-store")) + (unless (equal method "HEAD") + (kkkmeet-server--captcha-svg)))) + +(defun kkkmeet-server--handle-captcha-verify (process answer) + "Send captcha verification result for ANSWER to PROCESS." (kkkmeet-server--send-json - process 200 `((captcha . ((enabled . t) - (passphrase . ,(kkkmeet-server--captcha-passphrase))))))) + process 200 `((ok . ,(if (kkkmeet-server--captcha-accepted-p answer) t :false))))) (defun kkkmeet-server--handle-events (process room-id display-name client-id captcha) "Attach PROCESS to ROOM-ID as an SSE client." @@ -434,8 +520,11 @@ 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 (member method '("GET" "HEAD")) (equal path "/captcha.svg")) + (kkkmeet-server--handle-captcha process method)) + ((and (equal method "GET") (equal path "/captcha/verify")) + (kkkmeet-server--handle-captcha-verify + process (kkkmeet-server--query-param target "answer"))) ((and (equal method "GET") (string-prefix-p "/events/" path)) (kkkmeet-server--handle-events process diff --git a/public/app.js b/public/app.js index 76fc468..a450b91 100644 --- a/public/app.js +++ b/public/app.js @@ -79,7 +79,6 @@ let watchApplySequence = 0; let youtubePlaybackRequestId = 0; let watchPlaylist = []; let remoteParticipantCount = 0; -let captchaPassphrase = "kkkmeet"; const peers = new Map(); function persistentClientId() { @@ -216,7 +215,10 @@ function openNameDialog(mode) { captchaInput.required = !renaming; if (!renaming) { captchaInput.value = localStorage.getItem(captchaAnswerStorageKey) || ""; - drawCaptchaPassphrase(); + drawCaptchaPassphrase().catch((error) => { + console.error(error); + setStatus("Could not load captcha image."); + }); } nameDialog.dataset.mode = mode; nameDialog.showModal(); @@ -224,19 +226,7 @@ function openNameDialog(mode) { 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() { +async function drawCaptchaPassphrase() { const context = captchaCanvas.getContext("2d"); if (!context) return; @@ -246,37 +236,27 @@ function drawCaptchaPassphrase() { 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(); + const image = new Image(); + image.decoding = "async"; + image.src = `/captcha.svg?${Date.now()}`; + await image.decode(); + context.clearRect(0, 0, width, height); + context.drawImage(image, 0, 0, width, height); } function captchaAnswer() { return String(captchaInput.value || localStorage.getItem(captchaAnswerStorageKey) || "").trim(); } -function captchaIsCorrect() { - return captchaAnswer() === captchaPassphrase; +async function captchaIsCorrect() { + const answer = captchaAnswer(); + if (!answer) return false; + + const params = new URLSearchParams({ answer }); + const response = await fetch(`/captcha/verify?${params}`, { cache: "no-store" }); + if (!response.ok) return false; + const result = await response.json(); + return Boolean(result.ok); } function labelForPeer(peer) { @@ -1345,7 +1325,7 @@ async function joinCall() { openNameDialog("join"); return; } - if (!captchaIsCorrect()) { + if (!(await captchaIsCorrect())) { setStatus("Enter the captcha passphrase to join the call."); openNameDialog("join"); captchaInput.focus(); @@ -1500,12 +1480,15 @@ async function stopScreenShare() { setActivity(displayName, "stopped sharing their screen."); } -initialNameForm.addEventListener("submit", (event) => { +initialNameForm.addEventListener("submit", async (event) => { event.preventDefault(); - if (nameDialog.dataset.mode !== "rename" && !captchaIsCorrect()) { + if (nameDialog.dataset.mode !== "rename" && !(await captchaIsCorrect())) { localStorage.removeItem(captchaAnswerStorageKey); captchaInput.value = ""; - drawCaptchaPassphrase(); + drawCaptchaPassphrase().catch((error) => { + console.error(error); + setStatus("Could not load captcha image."); + }); setStatus("Captcha passphrase did not match."); captchaInput.focus(); return; @@ -1662,12 +1645,10 @@ selfMicMeter.value = 0; noiseSuppressionInput.checked = savedNoiseSuppression(); async function initializeApp() { - await loadRuntimeConfig(); - const savedName = localStorage.getItem(nameStorageKey); if (savedName) { setDisplayName(savedName); - if (captchaIsCorrect()) { + if (await captchaIsCorrect()) { joinCall().catch((error) => { console.error(error); setStatus("Could not start camera or join the call."); diff --git a/server.js b/server.js index a1e7305..0972831 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,46 @@ 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", @@ -53,6 +93,35 @@ function captchaAccepted(value) { return String(value || "") === captchaPassphrase; } +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(``); + } + }); + }); + }); + + return ` + + + + ${rects.join("")} +`; +} + function normalizePlaylist(playlist) { if (!Array.isArray(playlist)) return []; @@ -190,13 +259,17 @@ const server = http.createServer(async (req, res) => { return; } - if (req.method === "GET" && url.pathname === "/config") { - sendJson(res, 200, { - captcha: { - enabled: true, - passphrase: captchaPassphrase - } + 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; }