t00-multiuser/server/index.ts

515 lines
No EOL
16 KiB
TypeScript

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<string, RemoteUser>();
new Database(Config.database.address, Config.database.port, Config.database.username, Config.database.password, Config.database.name);
// Web stuff
const sessions = new FunkyArray<string, SessionUser>();
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("<", "&lt;").replaceAll(">", "&gt;");
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 || sendUser.groupId === Number.MIN_VALUE) {
return;
}
const usersInGroup = new FunkyArray<number, RemoteUser>();
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<RemoteUser>();
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);