Stop exposing captcha passphrase
This commit is contained in:
+95
-6
@@ -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
|
||||
"<rect x=\"%s\" y=\"%s\" width=\"%s\" height=\"%s\" rx=\"1\"/>"
|
||||
(+ padding (* index (+ (* 5 cell) gap)) (* x cell))
|
||||
(+ 7 (* y cell))
|
||||
(1- cell)
|
||||
(1- cell))
|
||||
rects))))
|
||||
(format "<?xml version=\"1.0\" encoding=\"UTF-8\"?>
|
||||
<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%s\" height=\"%s\" viewBox=\"0 0 %s %s\">
|
||||
<rect width=\"100%%\" height=\"100%%\" fill=\"#eef2f6\"/>
|
||||
<path d=\"M0 34 C35 2 78 44 %s 12\" stroke=\"rgba(24,32,42,0.22)\" stroke-width=\"1.5\" fill=\"none\"/>
|
||||
<g fill=\"#18202a\" transform=\"skewX(-4)\">%s</g>
|
||||
</svg>"
|
||||
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
|
||||
|
||||
+28
-47
@@ -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.");
|
||||
|
||||
@@ -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(`<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 [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user