Browse Source

prepare for tinyscale 2

hmt 4 months ago
parent
commit
bd76700cfa
1 changed files with 156 additions and 0 deletions
  1. 156 0
      main.ts

+ 156 - 0
main.ts

@@ -0,0 +1,156 @@
+import servers from './servers.json' with { type: 'json' };
+
+import { crypto } from "https://deno.land/std@0.224.0/crypto/mod.ts"; 
+import { encodeHex } from "https://deno.land/std@0.224.0/encoding/hex.ts"
+import { red, green, yellow } from "https://deno.land/std@0.115.1/fmt/colors.ts";
+
+const secret = Deno.env.get("TINYSCALE_SECRET");
+const _port = Deno.env.get("PORT");
+const port = _port ? parseInt(_port) : undefined;
+
+if (secret === undefined)
+	throw "No `TINYSCALE_SECRET` set. tinyscale will exit.";
+
+if (servers.length === 0)
+	throw "There are no servers listed in `servers.json`";
+
+class Server {
+	host: string;
+	#secret: string;
+	#apiPrefix: string = "/api/";
+
+	constructor(host: string, secret: string) {
+		this.host = host;
+		this.#secret = secret;
+	}
+
+	static async hashCreate(params: string) {
+		const hash = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(params));
+		return encodeHex(hash);
+	}
+
+	async urlCreate(call: string, params = "") {
+		const hash = await Server.hashCreate(`${call}${params}${this.#secret}`);
+		return new URL(`${this.host}${this.#apiPrefix}${call}?${params}${params === '' ? '' : '&'}checksum=${hash}`);
+	}
+
+	async getResponse(query: string, params = "") {
+		const url = await this.urlCreate(query, params);
+		const res = await fetch(url);
+		if (!res.ok)
+			throw "Connection error. Please check your host configuration";
+		const body = await res.text();
+		return body;
+	}
+
+	async test() {
+		try {
+			const res = await this.getResponse('getMeetings');
+			const ok = res.includes('SUCCESS');
+			console.log(`${this.host} is ${ok ? green('ok') : red('misconfigured. Please check your secret in servers.json')}`);
+			if (!ok)
+				throw Error;
+		} catch (e) {
+			throw e;
+		}
+	}
+}
+
+const listServer: Server[] = [];
+const listTest = [];
+const queue: Map<string, Promise<unknown>> = new Map();
+
+for (const server of servers) {
+	const s = new Server(server.host, server.secret);
+	listTest.push(s.test());
+	listServer.push(s);
+}
+
+let currentServerIndex = 0;
+function getNextServer() {
+	currentServerIndex++;
+	if (currentServerIndex === listServer.length)
+		currentServerIndex = 0;
+	return listServer[currentServerIndex];
+}
+
+await Promise.allSettled(listTest).then(list => list.forEach(res => res.status === 'rejected' && Deno.exit(1)));
+
+async function checkAuthenticated(url: URL) {
+	const checksum = url.searchParams.get('checksum');
+	if (checksum === null)
+		return false;
+	const call = url.pathname.slice(5);
+	const params = url.search.replace('?', '').replace(/[?&]?checksum.*$/, '');
+	const hash = await Server.hashCreate(`${call}${params}${secret}`);
+	return hash === checksum;
+}
+
+Deno.serve({ port }, async (req) => {
+	const url = new URL(req.url);
+	const call = url.pathname.slice(5);
+	const { promise, resolve } = Promise.withResolvers();
+	let log = "";
+
+	// voicemail if just looking for a life sign from the server
+	if (url.pathname === '/' || url.pathname === '/api/')
+		return new Response("<response><returncode>SUCCESS</returncode><version>2.0</version></response>");
+
+	// check the checksum and fail if not true
+	const authenticated = await checkAuthenticated(url);
+	if (!authenticated) {
+		log = red(`401: ${url.pathname}`);
+		return new Response("<response><returncode>FAILED</returncode><messageKey>checksumError</messageKey><message>Checksums do not match</message></response>",
+			{ status: 401, headers: { "content-type": "text/xml" } });
+	}
+
+	// if there's a meeting/recording id, find the server which has it
+	let selectedServer: Server | undefined;
+	const meetingID = url.searchParams.get('meetingID');
+	const recordingID = url.searchParams.get('recordingID');
+	if (meetingID !== null) {
+		if (call === 'create') {
+			// if there's a request for the same room creation, wait for it
+			if (queue.has(meetingID)) {
+				console.log(`Race pending for meeting-ID: ${red(meetingID)}`);
+				await queue.get(meetingID);
+			}
+			queue.set(meetingID, promise);
+		}
+		for (const server of listServer) {
+			const meetings = await server.getResponse('getMeetings')
+			if (meetings.includes(meetingID)) {
+				selectedServer = server;
+				break;
+			}
+		}
+	}
+	else if (recordingID !== null)
+		for (const server of listServer) {
+			const recordings = await server.getResponse('getRecordings')
+			if (recordings.includes(recordingID))
+				selectedServer = server;
+		}
+	log = `${green(`${call}`)} found, reply with`;
+
+	const params = url.search.replace('?', '').replace(/[?&]?checksum.*$/, '');
+	if (call === 'create' && selectedServer === undefined && meetingID !== null) {
+		selectedServer = getNextServer();
+		console.log(green('create')+' '+yellow('not found')+", opening a new room on "+green(selectedServer.host));
+		const body = await selectedServer.getResponse('create', params);
+		resolve(meetingID);
+		queue.delete(meetingID);
+		return new Response(body, { headers: { "content-type": "text/xml" } });
+	} else if (call === 'create' && meetingID !== null) {
+		resolve(meetingID);
+		queue.delete(meetingID);
+	}
+	if (selectedServer === undefined)
+		selectedServer = listServer[currentServerIndex];
+	log = log + ' ' + selectedServer.host;
+	console.log(log);
+
+	// return the new URL to the real BBB server
+	const newURL = await selectedServer.urlCreate(call, params);
+	return Response.redirect(newURL);
+})