Add join captcha gate
This commit is contained in:
+54
-34
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user