import { createReader, createWriter, Endian } from "bufferstuff"; import { WebSocketServer } from "ws"; import Fastify from "fastify"; import FastifyFormBody from "@fastify/formbody"; import FastifyCookie from "@fastify/cookie"; import FastifyView from "@fastify/view"; import EJS from "ejs"; import Config from "./objects/Config"; import FunkyArray from "./objects/FunkyArray"; import RemoteUser from "./objects/RemoteUser"; import { MessageType } from "./enums/MessageType"; import Database from "./objects/Database"; import { Console } from "hsconsole"; import UserService from "./services/UserService"; import UsernameData from "./interfaces/UsernameData"; import { randomBytes } from "crypto"; import SessionUser from "./objects/SessionUser"; import PasswordUtility from "./utilities/PasswordUtility"; import CreateEditPartyData from "./interfaces/CreateEditPartyData"; import JoinPartyData from "./interfaces/JoinPartyData"; import IdData from "./interfaces/IdData"; import Party from "./objects/Party"; Console.customHeader(`MultiProbe server started at ${new Date()}`); const users = new FunkyArray(); new Database(Config.database.address, Config.database.port, Config.database.username, Config.database.password, Config.database.name); // Web stuff const sessions = new FunkyArray(); const sessionExpiryInterval = setInterval(() => { const currentTime = Date.now(); for (const key of sessions.keys) { const session = sessions.get(key); if (!session || (session && currentTime >= session.validityPeriod.getTime())) { sessions.remove(key); } } }, 3600000); const fastify = Fastify({ logger: false }); fastify.register(FastifyView, { engine: { ejs: EJS } }); fastify.register(FastifyFormBody); fastify.register(FastifyCookie, { secret: Config.session.secret, parseOptions: { path: "/", secure: true } }); fastify.setNotFoundHandler(async (req, res) => { return res.status(404).view("templates/404.ejs", { }); }); function validateSession(cookies:{ [cookieName: string]: string | undefined }) { if ("MP_SESSION" in cookies && typeof(cookies["MP_SESSION"]) === "string") { const key = FastifyCookie.unsign(cookies["MP_SESSION"], Config.session.secret); if (key.valid && sessions.has(key.value ?? "badkey")) { return sessions.get(key.value ?? "badkey"); } } return undefined; } // Get Methods fastify.get("/", async (req, res) => { let session:SessionUser | undefined; if (session = validateSession(req.cookies)) { const user = await UserService.GetUser(session.userId); const parties = await UserService.GetUserParties(session.userId); const activeUserParty = await UserService.GetActiveParty(session.userId); if (user) { return res.view("templates/home.ejs", { user, parties, activeUserParty }); } return res.view("templates/index.ejs", { }); } return res.view("templates/index.ejs", { }); }); fastify.get("/account", async (req, res) => { return "TODO"; }); fastify.get("/account/login", async (req, res) => { return res.view("templates/account/login.ejs", { }); }); fastify.get("/account/register", async (req, res) => { return res.view("templates/account/register.ejs", { }); }); fastify.get("/account/logout", async (req, res) => { res.clearCookie("MP_SESSION"); return res.redirect(302, "/"); }); fastify.get("/party/create", async (req, res) => { let session:SessionUser | undefined; if (!(session = validateSession(req.cookies))) { return res.redirect(302, "/"); } return res.view("templates/party/createedit.ejs", { session }); }); fastify.get("/party/join", async (req, res) => { let session:SessionUser | undefined; if (!(session = validateSession(req.cookies))) { return res.redirect(302, "/"); } return res.view("templates/party/join.ejs", { session }); }); fastify.get("/party/setactive", async (req, res) => { let session:SessionUser | undefined; if (!(session = validateSession(req.cookies))) { return res.redirect(302, "/"); } const data = req.query as IdData; const numericId = parseInt(data.id ?? "-1"); if (typeof(data.id) !== "string" || isNaN(numericId)) { return res.redirect(302, "/"); } await UserService.SetActiveParty(session.userId, numericId); return res.redirect(302, "/"); }); fastify.get("/party/deactivate", async (req, res) => { let session:SessionUser | undefined; if (!(session = validateSession(req.cookies))) { return res.redirect(302, "/"); } await UserService.DeactivateCurrentParty(session.userId); return res.redirect(302, "/"); }); // Post Methods fastify.post("/account/register", async (req, res) => { const data = req.body as UsernameData; if (typeof(data.username) !== "string" || typeof(data.password) !== "string" || data.username.length > 32 || data.password.length < 8) { return res.view("templates/account/register.ejs", { }); } const username = data.username.replaceAll("<", "<").replaceAll(">", ">"); await UserService.CreateUser(0, username, data.password); const user = await UserService.GetUserByUsername(username); if (!user) { return res.view("templates/account/register.ejs", { }); } const validPeriod = new Date(); validPeriod.setTime(validPeriod.getTime() + Config.session.validity); const key = randomBytes(Config.session.length).toString("hex"); sessions.set(key, new SessionUser(user.Id, validPeriod)); res.setCookie("MP_SESSION", key, { path: "/", signed: true }); return res.redirect(302, "/"); }); fastify.post("/account/login", async (req, res) => { const data = req.body as UsernameData; if (typeof(data.username) !== "string" || typeof(data.password) !== "string" || data.username.length > 32 || data.password.length < 8) { return res.view("templates/account/login.ejs", { }); } const user = await UserService.GetUserByUsername(data.username); if (!user) { return res.view("templates/account/login.ejs", { }); } if (await PasswordUtility.ValidatePassword(user.PasswordHash, user.PasswordSalt, data.password)) { const validPeriod = new Date(); validPeriod.setTime(validPeriod.getTime() + Config.session.validity); const key = randomBytes(Config.session.length).toString("hex"); sessions.set(key, new SessionUser(user.Id, validPeriod)); res.setCookie("MP_SESSION", key, { path: "/", signed: true }); return res.redirect(302, "/"); } return res.view("templates/account/login.ejs", { }); }); fastify.post("/party/create", async (req, res) => { try { let session:SessionUser | undefined; if (!(session = validateSession(req.cookies))) { return res.redirect(302, "/"); } const data = req.body as CreateEditPartyData; if (typeof(data.partyName) !== "string" || typeof(data.partyRef) !== "string" || data.partyName.length === 0 || data.partyRef.length === 0) { return res.view("templates/party/createedit.ejs", { session, partyName: data.partyName ?? "", partyRef: data.partyRef ?? "" }); } const party = await UserService.GetPartyByPartyRef(data.partyRef) if (party != null) { return res.view("templates/party/createedit.ejs", { session, partyName: data.partyName ?? "", partyRef: data.partyRef ?? "", error: "A group with that Party ID already exists" }); } await UserService.CreateParty(session.userId, data.partyName, data.partyRef); return res.redirect(302, "/"); } catch (e) { console.error(e); } }); fastify.post("/party/join", async (req, res) => { try { let session:SessionUser | undefined; if (!(session = validateSession(req.cookies))) { return res.redirect(302, "/"); } const data = req.body as JoinPartyData; if (typeof(data.partyRef) !== "string" || data.partyRef.length === 0) { return res.view("templates/party/join.ejs", { partyRef: data.partyRef ?? "" }); } const party = await UserService.GetPartyByPartyRef(data.partyRef); if (party == null) { return res.view("templates/party/join.ejs", { session, partyRef: data.partyRef ?? "", error: "That Join Code / Party ID is invalid." }); } const userPartyExisting = await UserService.GetUserPartyForUser(session.userId, party.Id); if (userPartyExisting != null) { return res.view("templates/party/join.ejs", { session, partyRef: data.partyRef ?? "", error: "You are already in this group." }); } await UserService.AddUserToParty(session.userId, party.Id); return res.redirect(302, "/"); } catch (e) { console.error(e); } }); // API fastify.post("/api/login", async (req, res) => { res.header("access-control-allow-origin", "*"); const data = req.body as UsernameData; if (typeof(data.username) !== "string" || typeof(data.password) !== "string" || data.username.length > 32 || data.password.length < 8) { return res.status(401).send("Username or Password incorrect"); } const user = await UserService.GetUserByUsername(data.username); if (!user) { return res.status(401).send("Username or Password incorrect"); } if (await PasswordUtility.ValidatePassword(user.PasswordHash, user.PasswordSalt, data.password)) { return res.status(200).send(user.APIKey); } return res.status(401).send("Username or Password incorrect"); }); let cachedVersion = ""; let cacheExpiry = 0; fastify.post("/api/version", async (req, res) => { res.header("access-control-allow-origin", "*"); if (Date.now() < cacheExpiry) { res.send(cachedVersion); } else { const response = await fetch(`http://${Config.githost}/tgpholly/t00-multiuser/raw/branch/master/client/Terminal-00-Multiuser.user.js?${Date.now()}`); if (response.status === 200) { const content = await response.text(); if (content.includes("@version")) { cachedVersion = content.split("@version")[1].split("\n")[0].trim().split(".").join(""); cacheExpiry = Date.now() + 30000; return res.send(cachedVersion); } else { return res.send("0"); } } else { return res.send("0"); } } }); // Websocket stuff const websocketServer = new WebSocketServer({ port: Config.ports.ws }, () => { Console.printInfo(`WebsocketServer listening at ws://localhost:${Config.ports.ws}`); fastify.listen({ port: Config.ports.http, host: "0.0.0.0" }, (err, address) => { if (err) { Console.printError(`Error occured while spinning up fastify:\n${err}`); process.exit(1); } Console.printInfo(`Fastify listening at ${address.replace("0.0.0.0", "localhost")}`); Console.printInfo("MultiProbe is ready to go!"); }); }); function sendToAllButSelf(user:RemoteUser, data:Buffer) { users.forEach(otherUser => { if (otherUser.id !== user.id && otherUser.currentURL === user.currentURL) { otherUser.send(data); } }); } function sendToAll(user:RemoteUser, data:Buffer) { users.forEach(otherUser => { if (otherUser.currentURL === user.currentURL) { otherUser.send(data); } }); } function sendToAllInGroup(user:RemoteUser, data:Buffer) { users.forEach(otherUser => { if (otherUser.groupId === user.groupId && otherUser.userId !== user.userId) { otherUser.send(data); } }); } websocketServer.on("connection", (socket) => { const myUUID = crypto.randomUUID(); let user:RemoteUser; function closeOrError() { if (users.has(myUUID)) { users.remove(myUUID); const userLeftPacket = createWriter(Endian.LE, 5).writeByte(MessageType.ClientLeft).writeUInt(user.id).toBuffer(); users.forEach(otherUser => otherUser.send(userLeftPacket)); sendGroupUpdate(user); } } async function sendGroupUpdate(sendUser:RemoteUser, groupSend = false) { if (!sendUser || user.groupId === Number.MIN_VALUE) { return; } const usersInGroup = new FunkyArray(); let totalUsernameLength = 0; await users.forEach(otherUser => { if (sendUser.groupId === otherUser.groupId && sendUser.userId !== otherUser.userId) { if (usersInGroup.has(otherUser.userId)) { totalUsernameLength += otherUser.username.length; } usersInGroup.set(otherUser.userId, otherUser); } if (!groupSend && sendUser.userId !== otherUser.userId) { sendGroupUpdate(otherUser, true); } }); const writer = createWriter(Endian.LE) .writeByte(MessageType.GroupData) .writeShortString(sendUser.groupName) .writeUShort(usersInGroup.length); await usersInGroup.forEach(otherUser => { writer.writeShortString(otherUser.username).writeString(otherUser.rawURL); }); const groupData = writer.toBuffer(); sendUser.send(groupData); } socket.on("close", closeOrError); socket.on("error", closeOrError); socket.on("message", async (data) => { const reader = createReader(Endian.LE, data as Buffer); // There is absolutely no reason we should ever get // more than 50 bytes legit. if (reader.length > 0 && reader.length < 1024) { switch (reader.readByte()) { case MessageType.ClientDetails: { if (user !== undefined) { return; } const apiKey = reader.readShortString(); const rawURL = reader.readString(); const dbUser = await UserService.GetUserByAPIKey(apiKey); if (dbUser == null) { return; } const dbUserParty = await UserService.GetActiveParty(dbUser.Id); let dbParty: Party | null = null; if (dbUserParty) { dbParty = await UserService.GetParty(dbUserParty.PartyId); } let page = rawURL.toLowerCase().replace(".htm", "").replace(".html", ""); if (page === "index") { page = ""; } let lengthOfUsernames = 0; const usersOnPage = new Array(); await users.forEach(otherUser => { if (otherUser.currentURL === page) { usersOnPage.push(otherUser); lengthOfUsernames += otherUser.username.length + 1; // + 1 for length byte } }); const usersToSend = createWriter(Endian.LE, 3 + (usersOnPage.length * 12) + lengthOfUsernames).writeByte(MessageType.Clients).writeUShort(usersOnPage.length); for (const otherUser of usersOnPage) { usersToSend.writeUInt(otherUser.id).writeShortString(otherUser.username).writeFloat(otherUser.cursorX).writeInt(otherUser.cursorY); } if (dbParty) { user = users.set(myUUID, new RemoteUser(socket, dbUser.Username, page, rawURL, dbUser.Id, dbParty.Id, dbParty.Name)); } else { user = users.set(myUUID, new RemoteUser(socket, dbUser.Username, page, rawURL, dbUser.Id, Number.MIN_VALUE, "")); } sendToAllButSelf(user, createWriter(Endian.LE, 6 + dbUser.Username.length).writeByte(MessageType.ClientJoined).writeUInt(user.id).writeShortString(dbUser.Username).toBuffer()); user.send(usersToSend.toBuffer()); sendGroupUpdate(user); break; } case MessageType.CursorPos: { if (user === undefined) { return; } user.cursorX = reader.readFloat(); user.cursorY = reader.readInt(); sendToAllButSelf(user, createWriter(Endian.LE, 13).writeByte(MessageType.CursorPos).writeUInt(user.id).writeFloat(user.cursorX).writeInt(user.cursorY).toBuffer()); break; } case MessageType.Ping: { if (user === undefined) { return; } if ((Date.now() - user.lastPingReset) >= 1000) { user.allowedPings = 10; user.lastPingReset = Date.now(); } if (user.allowedPings > 0) { user.allowedPings--; const cursorX = reader.readFloat(); const cursorY = reader.readInt(); const packet = createWriter(Endian.LE, 9).writeByte(MessageType.Ping).writeFloat(cursorX).writeInt(cursorY).toBuffer(); sendToAll(user, packet); } break; } } } }); }); let isShuttingDown = false; function shutdown() { if (isShuttingDown) { return; } isShuttingDown = true; Console.printInfo("Shutting down..."); websocketServer.close(async () => { await fastify.close(); clearInterval(sessionExpiryInterval); Console.cleanup(); console.log("Goodbye!"); }); } process.on("SIGQUIT", shutdown); process.on("SIGINT", shutdown); //process.on("SIGUSR2", shutdown);