commit 39f6e99abe9d1a58d62629c39682684f86a2f071 Author: Jurn Wubben Date: Wed Nov 5 22:36:41 2025 +0100 basic version working diff --git a/.helix/languages.toml b/.helix/languages.toml new file mode 100644 index 0000000..92b5c51 --- /dev/null +++ b/.helix/languages.toml @@ -0,0 +1,64 @@ +[[language]] +name = "javascript" +language-id = "javascript" +scope = "source.js" +injection-regex = "^(js|javascript)$" +file-types = ["js", "jsx", "mjs"] +shebangs = ["deno", "node"] +roots = ["deno.json", "package.json", "tsconfig.json"] +comment-token = "//" +indent = { tab-width = 2, unit = " " } +grammar = "javascript" +language-servers = ["deno-lsp"] + +[[language]] +name = "jsx" +language-id = "javascriptreact" +scope = "source.jsx" +injection-regex = "jsx" +file-types = ["jsx"] +shebangs = ["deno", "node"] +roots = ["deno.json", "package.json", "tsconfig.json"] +comment-token = "//" +indent = { tab-width = 2, unit = " " } +grammar = "javascript" +language-servers = ["deno-lsp"] + +[[language]] +name = "typescript" +language-id = "typescript" +scope = "source.ts" +injection-regex = "^(ts|typescript)$" +file-types = ["ts"] +shebangs = ["deno", "node"] +roots = ["deno.json", "package.json", "tsconfig.json"] +comment-token = "//" +indent = { tab-width = 2, unit = " " } +grammar = "typescript" +language-servers = ["deno-lsp"] + +[[language]] +name = "tsx" +language-id = "typescriptreact" +scope = "source.tsx" +injection-regex = "^(tsx)$" # |typescript +file-types = ["tsx"] +shebangs = ["deno", "node"] +roots = ["deno.json", "package.json", "tsconfig.json"] +comment-token = "//" +indent = { tab-width = 2, unit = " " } +grammar = "typescript" +language-servers = ["deno-lsp"] + +[language-server.deno-lsp] +command = "deno" +args = ["lsp"] +config = { enable = true, lint = true, unstable = true } + +[language-server.emmet-lsp] +command = "emmet-language-server" +args = ["--stdio"] + +[[language]] +name = "html" +language-servers = ["emmet-lsp", "vscode-html-language-server"] diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..41f48cc --- /dev/null +++ b/deno.json @@ -0,0 +1,11 @@ +{ + "tasks": { + "dev": "deno run -A --watch src/index.ts" + }, + "imports": { + "@std/assert": "jsr:@std/assert@1", + "@std/async": "jsr:@std/async@^1.0.15", + "@std/encoding": "jsr:@std/encoding@^1.0.10", + "@std/http": "jsr:@std/http@^1.0.21" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..34a9610 --- /dev/null +++ b/deno.lock @@ -0,0 +1,85 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1": "1.0.15", + "jsr:@std/async@^1.0.15": "1.0.15", + "jsr:@std/cli@^1.0.23": "1.0.23", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/fmt@^1.0.8": "1.0.8", + "jsr:@std/fs@^1.0.19": "1.0.19", + "jsr:@std/html@^1.0.5": "1.0.5", + "jsr:@std/http@^1.0.21": "1.0.21", + "jsr:@std/internal@^1.0.10": "1.0.12", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/media-types@^1.1.0": "1.1.0", + "jsr:@std/net@^1.0.6": "1.0.6", + "jsr:@std/path@^1.1.2": "1.1.2", + "jsr:@std/streams@^1.0.13": "1.0.13" + }, + "jsr": { + "@std/assert@1.0.15": { + "integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/async@1.0.15": { + "integrity": "55d1d9d04f99403fe5730ab16bdcc3c47f658a6bf054cafb38a50f046238116e" + }, + "@std/cli@1.0.23": { + "integrity": "bf95b7a9425ba2af1ae5a6359daf58c508f2decf711a76ed2993cd352498ccca" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/fmt@1.0.8": { + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" + }, + "@std/fs@1.0.19": { + "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06" + }, + "@std/html@1.0.5": { + "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" + }, + "@std/http@1.0.21": { + "integrity": "abb5c747651ee6e3ea6139858fd9b1810d2c97f53a5e6722f3b6d27a6d263edc", + "dependencies": [ + "jsr:@std/cli", + "jsr:@std/encoding", + "jsr:@std/fmt", + "jsr:@std/fs", + "jsr:@std/html", + "jsr:@std/media-types", + "jsr:@std/net", + "jsr:@std/path", + "jsr:@std/streams" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/net@1.0.6": { + "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" + }, + "@std/path@1.1.2": { + "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", + "dependencies": [ + "jsr:@std/internal@^1.0.10" + ] + }, + "@std/streams@1.0.13": { + "integrity": "772d208cd0d3e5dac7c1d9e6cdb25842846d136eea4a41a62e44ed4ab0c8dd9e" + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@1", + "jsr:@std/async@^1.0.15", + "jsr:@std/encoding@^1.0.10", + "jsr:@std/http@^1.0.21" + ] + } +} diff --git a/log b/log new file mode 100644 index 0000000..79450d5 --- /dev/null +++ b/log @@ -0,0 +1,2 @@ +2025-11-05T13:45:22.658 helix_term::application [WARN] Ignoring a client/registerCapability request because dynamic capability registration is not enabled. Please report this upstream to the language server +2025-11-05T13:45:25.229 helix_lsp::transport [ERROR] Tried sending response into a closed channel (id=Num(5)), original request likely timed out diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..b775c1b --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,87 @@ +import { + buildCommand, + buildError, + Command, + parseCommand, +} from "./commandbuilder.ts"; +import { Device } from "./device.ts"; + +function toHex(data: Uint8Array): string { + return Array.from(data).map((b) => ("00" + b.toString(16)).slice(-2)).join( + "", + ); +} +async function generateHMAC(key: string, message: string) { + const encoder = new TextEncoder(); + const keyData = encoder.encode(key); + const messageData = encoder.encode(message); + + const cryptoKey = await crypto.subtle.importKey( + "raw", + keyData, + { name: "HMAC", hash: { name: "SHA-256" } }, + false, + ["sign"], + ); + + const signature = await crypto.subtle.sign( + "HMAC", + cryptoKey, + messageData, + ); + + return toHex(new Uint8Array(signature)); +} + +export class Authentication { + _socket: WebSocket; + _encoder: TextEncoder; + nonce: string; + device: Device | undefined; + signature: string | undefined; + + constructor(socket: WebSocket) { + this._socket = socket; + this._encoder = new TextEncoder(); + this.nonce = toHex(crypto.getRandomValues(new Uint8Array(32))); + } + + async authenticate(message: Command) { // ugh, js doesn't have async constructors, hence the seeprate init function. + if ( + message.d == undefined || !("id" in message.d) || + typeof (message.d.id) !== "string" || !(message.d.id in Device.devices) + ) { + this._socket.send(buildError(1, "Invalid packet. Missing ID")); + this._socket.close(); + return; + } + + this._socket.addEventListener("message", (msg) => this._validate(msg)); + this.device = Device.devices[message.d.id]; + this.signature = await generateHMAC(this.device.password, this.nonce); + + this._socket.send( + buildCommand("auth_nonce", { nonce: this.nonce }), + ); + } + + _validate(msg: MessageEvent) { + if (!this.device || !this.signature) return; + + const parsed = parseCommand(msg.data); + if ( + parsed === null || parsed.c !== "auth_validate" || + parsed.d === undefined || !("signature" in parsed.d) + ) return; + + if (this.signature !== parsed.d.signature) { + this._socket.send(buildError(2, "Invalid signature.")); + this._socket.close; + return; + } + + this._socket.send(buildCommand("auth_ok")); + this.device?.connect(this._socket); + this._socket.removeEventListener("message", this._validate); + } +} diff --git a/src/commandbuilder.ts b/src/commandbuilder.ts new file mode 100644 index 0000000..3562136 --- /dev/null +++ b/src/commandbuilder.ts @@ -0,0 +1,14 @@ +export type Command = { + c: string, + d: object | undefined +} + +export const buildCommand = (command: string, data?: any) => JSON.stringify({c: command, d: data}) +export const buildError = (error: number, info?: string) => JSON.stringify({e: error, info}) +export const parseCommand = (command: string): Command | null => { + const parsed = JSON.parse(command); + if (!("c" in parsed && ("d" in parsed && typeof(parsed.d) == "object") || !("d" in parsed))) + return null + + return parsed as Command +} diff --git a/src/device.ts b/src/device.ts new file mode 100644 index 0000000..3f10667 --- /dev/null +++ b/src/device.ts @@ -0,0 +1,65 @@ +import { buildCommand, buildError, parseCommand } from "./commandbuilder.ts"; + +type Votes = [number, number, number, number, number] +const EMPTY_VOTES: Votes = Array(5).fill(0) as Votes; + +export class Device { + static devices: {[x: string]: Device} = {} + password: string; + id: string; + _socket: WebSocket | undefined; + + latestVotes: Votes | undefined + sessionTimeout: number | undefined; + + + get connected() { + return this._socket !== undefined; + } + + constructor(password: string, id: string) { + this.password = password; + this.id = id; + + if (id in Device.devices) throw new Error("id already taken") + Device.devices[id] = this; + } + connect(socket: WebSocket) { + if (this._socket !== undefined) { + this._socket.send(buildError(4, "Logged in at other place.")) + this._socket.close(); + } + this._socket = socket; + + this._socket.addEventListener("close", _ => this._close()) + this._socket.addEventListener("error", _ => this._close()) + } + + startSession(text: string) { + if (!this.connected) return + this.latestVotes = structuredClone(EMPTY_VOTES); + this._socket?.send(buildCommand("session_start", {text})) + this._socket?.addEventListener("message", msg => this._handleVote(msg)) + + this.sessionTimeout = setTimeout(() => this.stopSession, 5 * 60 * 1000); + } + stopSession() { + this._socket?.removeEventListener("message", msg => this._handleVote(msg)) + this._socket?.send(buildCommand("session_stop")) + this.sessionTimeout = undefined; + } + + _close() { + this._socket = undefined; + this.sessionTimeout = undefined; + clearTimeout(this.sessionTimeout); + + } + _handleVote(msg: MessageEvent) { + const parsed = parseCommand(msg.data); + if (parsed == undefined || parsed.c !== "session_vote" || parsed.d == undefined || !("vote" in parsed.d) || typeof(parsed.d.vote) !== "number") return; + if (this.latestVotes === undefined) return; + + this.latestVotes[parsed.d.vote - 1] += 1; + } +} diff --git a/src/heartbeat.ts b/src/heartbeat.ts new file mode 100644 index 0000000..e2bd58a --- /dev/null +++ b/src/heartbeat.ts @@ -0,0 +1,51 @@ +import { buildError, parseCommand } from "./commandbuilder.ts"; + +const HEARTBEAT_INTERVAL = 30_000; +const HEARTBEAT_TIMEOUT = 5_000; +const S_PING = JSON.stringify({ "c": "ping" }); + +export class WSHeartbeat { + heartbeatTimer: number | undefined; + pongTimer: number | undefined; + socket: WebSocket; + + constructor(socket: WebSocket) { + this.socket = socket; + socket.addEventListener("open", (_) => this._setHeartBeat()); + socket.addEventListener("close", _ => this._clearTimers()); + socket.addEventListener("error", _ => this._clearTimers()); + socket.addEventListener("message", (msg) => this._onMessage(msg)); + } + + _setPong(state: boolean) { + if (state) { + clearTimeout(this.pongTimer); + return; + } + + this.socket.send(S_PING); + this.pongTimer = setTimeout(() => { + this.socket.send(buildError(0, "Pong missed.")); + this.socket.close(); + }, HEARTBEAT_TIMEOUT); + } + _setHeartBeat() { + this._setPong(false); + this.heartbeatTimer = setInterval(() => { + this._setPong(false); + }, HEARTBEAT_INTERVAL); + } + _clearTimers() { + clearTimeout(this.pongTimer); + clearTimeout(this.heartbeatTimer); + } + + _onMessage(msg: MessageEvent) { + const text = msg.data; + const parsed = parseCommand(text); + + if (parsed === null || parsed.c !== "pong") return; + + this._setPong(true); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7c8fef9 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,142 @@ +import { serveFile } from "@std/http/file-server"; +import { WSHeartbeat } from "./heartbeat.ts"; +import { Registration } from "./registration.ts"; +import { parseCommand } from "./commandbuilder.ts"; +import { Authentication } from "./auth.ts"; +import { Device } from "./device.ts"; + +// deno-lint-ignore no-explicit-any +async function wsStartHandler(this: WebSocket, msg: MessageEvent) { + const data = parseCommand(msg.data); + if (data === null) return; + + switch (data.c as string) { + case "reg_start": + { + new Registration(this); + + this.removeEventListener("message", wsStartHandler); + break; + } + case "auth_start": { + await new Authentication(this).authenticate(data); + this.removeEventListener("message", wsStartHandler); + break; + } + + default: + break; + } +} + +function handlePrompt(prompt: string) { + let tokens = prompt.match(/(?:[^\s"]+|"[^"]*")+/g) || [] as string[]; + tokens = tokens.map((v) => v.replaceAll(/"([^"]*)"+/g, "$1")); + if (tokens.length === 0) return; + + switch (tokens[0]) { + case "pin": { + const pin = +tokens[1]; + if (pin === 0 || isNaN(pin)) return; + + console.log(Registration.validate(pin) ? "great success" : "invalid pin"); + break; + } + case "devices": { + console.debug(Device.devices); + break; + } + case "startsession": { + if (tokens.length < 3) return; + if (!(tokens[1] in Device.devices)) { + console.log("invalid uuid"); + return; + } + + const device = Device.devices[tokens[1]]; + if (!device.connected) { + console.log("Device not connected."); + return; + } + + device.startSession(tokens[2]); + console.log("great success"); + + break; + } + case "stopsession": { + if (tokens.length < 2) return; + if (!(tokens[1] in Device.devices)) { + console.log("invalid uuid"); + return; + } + + const device = Device.devices[tokens[1]]; + if (!device.connected) { + console.log("Device not connected."); + return; + } + + device.stopSession(); + console.log("great success"); + break; + } + case "votes": { + if (tokens.length < 2) return; + if (!(tokens[1] in Device.devices)) { + console.log("invalid uuid"); + return; + } + + const device = Device.devices[tokens[1]]; + if (!device.connected) { + console.log("Device not connected."); + return; + } + + console.log(device.latestVotes); + } + } +} + +const WS_ROUTE = new URLPattern({ pathname: "/ws" }); +Deno.serve((req) => { + if (WS_ROUTE.exec(req.url) === null) { + return serveFile(req, "./static/index.html"); + } + if (req.headers.get("upgrade") != "websocket") { + return new Response(null, { status: 426 }); + } + + const { socket, response } = Deno.upgradeWebSocket(req); + new WSHeartbeat(socket); + + socket.addEventListener("message", wsStartHandler); + return response; +}); + +const decoder = new TextDecoder(); + +console.info(` +Commands: +- pin + used to activate device. +- devices + list devices +- startsession "" + starts a voting session +- stopsession + stops a voting session +- votes + grabs the latest votes. +`); + +Deno.stdout.write(Uint8Array.from([62, 32])); // '> ' +while (true) { // ja dit is scuffed, nee het werkt niet goed, nee ik ga er niks aan doen. + for await (const chunk of Deno.stdin.readable) { + const prompt = decoder.decode(chunk).trim(); + if (prompt !== "") handlePrompt(prompt); + + Deno.stdout.write(Uint8Array.from([62, 32])); // '> ' + } +} diff --git a/src/registration.ts b/src/registration.ts new file mode 100644 index 0000000..8cd57c5 --- /dev/null +++ b/src/registration.ts @@ -0,0 +1,53 @@ +import { buildCommand } from "./commandbuilder.ts"; +import { Device } from "./device.ts"; +import { encodeBase64 } from "@std/encoding/base64"; + +type ResolveFunc = ( + value: PromiseLike | Device | undefined, +) => void; +export class Registration { + static _registrations: Registration[] = []; + _socket: WebSocket; + _pin: number; + + constructor(socket: WebSocket) { + this._socket = socket; + this._pin = Math.floor(Math.random() * 10 ** 6); // Normally you'd want to check if this is unique or not. + this._socket.send( + buildCommand("reg_pin", { pin: this._pin }), + ); + + this._socket.addEventListener("close", () => this.delete()); + this._socket.addEventListener("error", () => this.delete()); + + Registration._registrations.push(this); + } + + validate(pin: number): boolean { + if (pin !== this._pin) return false; + + const password = encodeBase64(crypto.getRandomValues(new Uint8Array(128))); // relatively secure + const id = crypto.randomUUID(); + + this._socket.send(buildCommand("reg_ok", { password, id })); + this._socket.send(buildCommand("restart")); + + new Device(password, id); + + Registration._registrations = Registration._registrations.filter((v) => + v._pin !== this._pin + ); + + this._socket.close() + return true; + } + + delete() { + Registration._registrations = Registration._registrations.filter(v => v._pin === this._pin); + } + + static validate(pin: number): boolean { + return Registration._registrations.find((v) => v.validate(pin)) !== + undefined; + } +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..b1f94d6 --- /dev/null +++ b/static/index.html @@ -0,0 +1,208 @@ + + + + + + + Document + + + + +
+
+ + + + + + + + + + + +
+ +
+ + +
+ +
+ + + + + +
+ +
+
+

+
+
+ + + + + \ No newline at end of file