From fecd4ab892cdeb3b94a813499aef8d507bc3e1fc Mon Sep 17 00:00:00 2001 From: Holly Date: Thu, 19 Sep 2024 00:41:40 +0100 Subject: [PATCH] mvc the whole web side of multiprobe --- server/controller/AccountController.ts | 60 ++++ server/controller/ApiController.ts | 47 +++ server/controller/Controller.ts | 83 +++++ server/controller/HomeController.ts | 28 ++ server/controller/PartyController.ts | 82 +++++ server/{objects => entities}/Party.ts | 0 server/{objects => entities}/User.ts | 0 server/{objects => entities}/UserParty.ts | 0 server/index.ts | 294 +----------------- server/interfaces/CreateEditPartyData.ts | 4 - server/interfaces/IdData.ts | 3 - server/interfaces/JoinPartyData.ts | 3 - server/interfaces/UsernameData.ts | 4 - server/models/account/LoginViewModel.ts | 5 + server/models/account/RegisterViewModel.ts | 5 + server/models/api/ApiLoginModel.ts | 4 + server/models/home/HomeViewModel.ts | 9 + .../models/party/CreateEditPartyViewModel.ts | 7 + server/models/party/JoinPartyViewModel.ts | 4 + server/models/party/LeavePartyModel.ts | 3 + server/models/party/SetActivePartyModel.ts | 3 + server/objects/RequestCtx.ts | 60 ++++ server/objects/Session.ts | 55 ++++ server/repos/PartyRepo.ts | 6 +- server/repos/UserPartyRepo.ts | 4 +- server/repos/UserRepo.ts | 4 +- server/services/UserService.ts | 44 ++- server/templates/account/login.ejs | 44 +-- server/templates/account/register.ejs | 44 +-- server/templates/{ => home}/home.ejs | 4 +- server/templates/{ => home}/index.ejs | 4 +- server/templates/party/createEdit.ejs | 32 ++ server/templates/party/createedit.ejs | 34 -- server/templates/party/join.ejs | 40 ++- 34 files changed, 614 insertions(+), 409 deletions(-) create mode 100644 server/controller/AccountController.ts create mode 100644 server/controller/ApiController.ts create mode 100644 server/controller/Controller.ts create mode 100644 server/controller/HomeController.ts create mode 100644 server/controller/PartyController.ts rename server/{objects => entities}/Party.ts (100%) rename server/{objects => entities}/User.ts (100%) rename server/{objects => entities}/UserParty.ts (100%) delete mode 100644 server/interfaces/CreateEditPartyData.ts delete mode 100644 server/interfaces/IdData.ts delete mode 100644 server/interfaces/JoinPartyData.ts delete mode 100644 server/interfaces/UsernameData.ts create mode 100644 server/models/account/LoginViewModel.ts create mode 100644 server/models/account/RegisterViewModel.ts create mode 100644 server/models/api/ApiLoginModel.ts create mode 100644 server/models/home/HomeViewModel.ts create mode 100644 server/models/party/CreateEditPartyViewModel.ts create mode 100644 server/models/party/JoinPartyViewModel.ts create mode 100644 server/models/party/LeavePartyModel.ts create mode 100644 server/models/party/SetActivePartyModel.ts create mode 100644 server/objects/RequestCtx.ts create mode 100644 server/objects/Session.ts rename server/templates/{ => home}/home.ejs (94%) rename server/templates/{ => home}/index.ejs (79%) create mode 100644 server/templates/party/createEdit.ejs delete mode 100644 server/templates/party/createedit.ejs diff --git a/server/controller/AccountController.ts b/server/controller/AccountController.ts new file mode 100644 index 0000000..4fbc36a --- /dev/null +++ b/server/controller/AccountController.ts @@ -0,0 +1,60 @@ +import LoginViewModel from "../models/account/LoginViewModel"; +import RegisterViewModel from "../models/account/RegisterViewModel"; +import Session from "../objects/Session"; +import UserService from "../services/UserService"; +import Controller from "./Controller" + +export default class AccountController extends Controller { + public async Login_Get_AllowAnonymous() { + return this.view(); + } + + public async Login_Post_AllowAnonymous(loginViewModel: LoginViewModel) { + if (typeof(loginViewModel.username) !== "string" || typeof(loginViewModel.password) !== "string") { + return this.badRequest(); + } + + const user = await UserService.AuthenticateUser(loginViewModel.username, loginViewModel.password); + if (!user) { + loginViewModel.password = ""; + loginViewModel.message = "Username or Password is incorrect"; + + return this.view(loginViewModel); + } + + Session.AssignUserSession(this.res, user); + + return this.redirectToAction("index", "home"); + } + + public async Register_Get_AllowAnonymous() { + return this.view(); + } + + public async Register_Post_AllowAnonymous(registerViewModel: RegisterViewModel) { + if (typeof(registerViewModel.username) !== "string" || typeof(registerViewModel.password) !== "string") { + return this.badRequest(); + } + + const username = registerViewModel.username.replaceAll("<", "<").replaceAll(">", ">"); + await UserService.CreateUser(0, username, registerViewModel.password); + + const user = await UserService.GetUserByUsername(username); + if (!user) { + registerViewModel.password = ""; + registerViewModel.message = "Failed to create your account, please try again later."; + + return this.view(registerViewModel); + } + + Session.AssignUserSession(this.res, user); + + return this.redirectToAction("index", "home"); + } + + public async Logout_Get_AllowAnonymous() { + Session.Clear(this.req.cookies, this.res); + + return this.redirectToAction("index", "home"); + } +} \ No newline at end of file diff --git a/server/controller/ApiController.ts b/server/controller/ApiController.ts new file mode 100644 index 0000000..bc6b4af --- /dev/null +++ b/server/controller/ApiController.ts @@ -0,0 +1,47 @@ +import ApiLoginModel from "../models/api/ApiLoginModel"; +import Config from "../objects/Config"; +import UserService from "../services/UserService"; +import Controller from "./Controller"; + +// for Git server lookup +let cachedVersion = ""; +let cacheExpiry = 0; + +export default class ApiController extends Controller { + public async Login_Post_AllowAnonymous(apiLoginModel: ApiLoginModel) { + this.res.header("access-control-allow-origin", "*"); + + if (typeof(apiLoginModel.username) !== "string" || typeof(apiLoginModel.password) !== "string") { + return this.badRequest(); + } + + const user = await UserService.AuthenticateUser(apiLoginModel.username, apiLoginModel.password); + if (user) { + return this.ok(user.APIKey); + } + + return this.unauthorised("Username or Password incorrect"); + } + + public async Version_Post_AllowAnonymous() { + this.res.header("access-control-allow-origin", "*"); + + if (Date.now() < cacheExpiry) { + this.ok(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 this.ok(cachedVersion); + } else { + return this.ok("0"); + } + } else { + return this.ok("0"); + } + } + } +} \ No newline at end of file diff --git a/server/controller/Controller.ts b/server/controller/Controller.ts new file mode 100644 index 0000000..279ceb9 --- /dev/null +++ b/server/controller/Controller.ts @@ -0,0 +1,83 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { Console } from "hsconsole"; +import Session from "../objects/Session"; +import SessionUser from "../objects/SessionUser"; +import RequestCtx from "../objects/RequestCtx"; + +// prepare for ts-ignore :3 +// TODO: figure out some runtime field / type checking so +// can auto badRequest on missing stuff. +export default abstract class Controller { + public static FastifyInstance:FastifyInstance; + + public constructor() { + const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(this)); + const controllerName = this.constructor.name.replace("Controller", "").toLowerCase(); + for (const method of methods) { + if (method === "constructor" || method[0] !== method[0].toUpperCase()) { // * Anything that starts with lowercase we'll consider "private" + continue; + } + + const params = method.split("_"); + const methodNameRaw = params.splice(0, 1)[0] + const methodName = methodNameRaw.toLowerCase(); + const doAuth = !params.includes("AllowAnonymous"); + + // @ts-ignore + const controllerRequestHandler = this[method]; + const requestHandler = (req:FastifyRequest, res:FastifyReply) => { + let session = Session.CheckValiditiy(req.cookies); + if (doAuth && session === undefined) { + return res.redirect(`/account/login?returnTo=${encodeURIComponent(req.url)}`); + } + + const requestCtx = new RequestCtx(req, res, controllerName, methodName, session); + controllerRequestHandler.bind(requestCtx)(req.method === "GET" ? req.query : req.body); + } + + let funcMethods:Array = []; + for (const param of params) { + if (param === "Get" || param === "Post" || param === "Put") { + funcMethods.push(param); + // @ts-ignore + Controller.FastifyInstance[param.toLowerCase()](`/${controllerName}/${methodName === "index" ? "" : methodName}`, requestHandler); + Console.printInfo(`Registered ${this.constructor.name}.${method} to "/${controllerName}/${methodName === "index" ? "" : methodName}" as ${param}`); + if (methodName === "index") { + // @ts-ignore + Controller.FastifyInstance[param.toLowerCase()](`/${controllerName}/${methodName}`, requestHandler); + Console.printInfo(`Registered ${this.constructor.name}.${method} to "/${controllerName}/${methodName}" as ${param}`); + // @ts-ignore + Controller.FastifyInstance[param.toLowerCase()](`/${controllerName}`, requestHandler); + Console.printInfo(`Registered ${this.constructor.name}.${method} to "/${controllerName}" as ${param}`); + } + } + } + + if (controllerName === "home" && methodName === "index") { + for (const httpMethod of funcMethods) { + Console.printInfo(`Registered ${this.constructor.name}.${method} to "/" as ${httpMethod}`); + // @ts-ignore + Controller.FastifyInstance[httpMethod.toLowerCase()](`/`, requestHandler); + } + } + } + } + + // not real, these RequestCtx so they autocomplete :) + // yeah, i know. this is terrible. + + // Fields + // @ts-ignore + public session:SessionUser; + // @ts-ignore + public req: FastifyRequest; + // @ts-ignore + public res: FastifyReply; + + // Methods + view(view?:string | Object, model?: Object) {} + redirectToAction(action:string, controller?:string) {} + ok(message?:string) {} + badRequest(message?:string) {} + unauthorised(message?:string) {} +} \ No newline at end of file diff --git a/server/controller/HomeController.ts b/server/controller/HomeController.ts new file mode 100644 index 0000000..8e84c63 --- /dev/null +++ b/server/controller/HomeController.ts @@ -0,0 +1,28 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import Controller from "./Controller"; +import UserService from "../services/UserService"; +import HomeViewModel from "../models/home/HomeViewModel"; + +export default class HomeController extends Controller { + public async Index_Get_AllowAnonymous() { + if (this.session) { + const user = await UserService.GetUser(this.session.userId); + if (!user) { + return this.unauthorised(); + } + + const parties = await UserService.GetUserParties(this.session.userId); + const activeUserParty = await UserService.GetActiveParty(this.session.userId); + + const homeViewModel: HomeViewModel = { + user, + parties, + activeUserParty + }; + + return this.view("home", homeViewModel); + } + + return this.view(); + } +} \ No newline at end of file diff --git a/server/controller/PartyController.ts b/server/controller/PartyController.ts new file mode 100644 index 0000000..c6e9265 --- /dev/null +++ b/server/controller/PartyController.ts @@ -0,0 +1,82 @@ +import CreateEditPartyViewModel from "../models/party/CreateEditPartyViewModel"; +import JoinPartyViewModel from "../models/party/JoinPartyViewModel"; +import LeavePartyModel from "../models/party/LeavePartyModel"; +import SetActivePartyModel from "../models/party/SetActivePartyModel"; +import UserService from "../services/UserService"; +import Controller from "./Controller"; + +export default class PartyController extends Controller { + public async Create_Get() { + return this.view("createEdit"); + } + + public async Create_Post(createEditPartyViewModel: CreateEditPartyViewModel) { + if (typeof(createEditPartyViewModel.name) !== "string" || typeof(createEditPartyViewModel.partyRef) !== "string") { + return this.badRequest(); + } + + const party = await UserService.GetPartyByPartyRef(createEditPartyViewModel.partyRef); + if (party) { + createEditPartyViewModel.message = "That Party ID is already taken!"; + return this.view("createEdit", createEditPartyViewModel); + } + + await UserService.CreateParty(this.session.userId, createEditPartyViewModel.name, createEditPartyViewModel.partyRef); + + return this.redirectToAction("index", "home"); + } + + public async Join_Get() { + return this.view(); + } + + public async Join_Post(joinPartyViewModel: JoinPartyViewModel) { + if (typeof(joinPartyViewModel.partyRef) !== "string") { + return this.badRequest(); + } + + const party = await UserService.GetPartyByPartyRef(joinPartyViewModel.partyRef); + if (!party) { + joinPartyViewModel.message = "That Join Code / Party ID is invalid."; + return this.view(joinPartyViewModel); + } + + const userPartyExisting = await UserService.GetUserPartyForUser(this.session.userId, party.Id); + if (userPartyExisting) { + joinPartyViewModel.message = "You are already in this party."; + return this.view(joinPartyViewModel); + } + + await UserService.AddUserToParty(this.session.userId, party.Id); + + return this.redirectToAction("index", "home"); + } + + public async Leave_Get(leavePartyModel: LeavePartyModel) { + const partyId = parseInt(leavePartyModel.id ?? "-1"); + if (typeof(leavePartyModel.id) !== "string" || isNaN(partyId)) { + return this.badRequest(); + } + + await UserService.LeaveParty(this.session.userId, partyId); + + return this.redirectToAction("index", "home"); + } + + public async SetActive_Get(setActivePartyModel: SetActivePartyModel) { + const partyId = parseInt(setActivePartyModel.id ?? "-1"); + if (typeof(setActivePartyModel.id) !== "string" || isNaN(partyId)) { + return this.badRequest(); + } + + await UserService.SetActiveParty(this.session.userId, partyId); + + return this.redirectToAction("index", "home"); + } + + public async Deactivate_Get() { + await UserService.DeactivateCurrentParty(this.session.userId); + + return this.redirectToAction("index", "home"); + } +} \ No newline at end of file diff --git a/server/objects/Party.ts b/server/entities/Party.ts similarity index 100% rename from server/objects/Party.ts rename to server/entities/Party.ts diff --git a/server/objects/User.ts b/server/entities/User.ts similarity index 100% rename from server/objects/User.ts rename to server/entities/User.ts diff --git a/server/objects/UserParty.ts b/server/entities/UserParty.ts similarity index 100% rename from server/objects/UserParty.ts rename to server/entities/UserParty.ts diff --git a/server/index.ts b/server/index.ts index 673b642..49999c5 100644 --- a/server/index.ts +++ b/server/index.ts @@ -12,17 +12,15 @@ 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"; +import Party from "./entities/Party"; import SimpleProm from "simple-prom"; import Gauge from "simple-prom/lib/objects/Gauge"; import Counter from "simple-prom/lib/objects/Counter"; +import Controller from "./controller/Controller"; +import HomeController from "./controller/HomeController"; +import AccountController from "./controller/AccountController"; +import PartyController from "./controller/PartyController"; +import ApiController from "./controller/ApiController"; Console.customHeader(`MultiProbe server started at ${new Date()}`); @@ -52,30 +50,12 @@ dataOut.setHelpText("Data sent by the server in bytes"); // 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); - } - } - webSessions.Value = sessions.length; -}, 3600000); - const fastify = Fastify({ logger: false }); -fastify.register(FastifyView, { - engine: { - ejs: EJS - } -}); - +fastify.register(FastifyView, { engine: { ejs: EJS } }); fastify.register(FastifyFormBody); - fastify.register(FastifyCookie, { secret: Config.session.secret, parseOptions: { @@ -88,260 +68,11 @@ 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); - if (user) { - const parties = await UserService.GetUserParties(session.userId); - const activeUserParty = await UserService.GetActiveParty(session.userId); - 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 res.redirect(302, "/"); -}); - -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)); - webSessions.Value = sessions.length; - - 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)); - webSessions.Value = sessions.length; - - 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"); - } - } -}); +Controller.FastifyInstance = fastify; +new HomeController(); +new AccountController(); +new PartyController(); +new ApiController(); // Websocket stuff @@ -580,7 +311,6 @@ function shutdown() { Console.printInfo("Shutting down..."); websocketServer.close(async () => { await fastify.close(); - clearInterval(sessionExpiryInterval); clearInterval(afkInterval); Console.cleanup(); diff --git a/server/interfaces/CreateEditPartyData.ts b/server/interfaces/CreateEditPartyData.ts deleted file mode 100644 index 8068c24..0000000 --- a/server/interfaces/CreateEditPartyData.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default interface CreateEditPartyData { - partyName?:string; - partyRef?:string; -} \ No newline at end of file diff --git a/server/interfaces/IdData.ts b/server/interfaces/IdData.ts deleted file mode 100644 index 0252b10..0000000 --- a/server/interfaces/IdData.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default interface IdData { - id?: string -} \ No newline at end of file diff --git a/server/interfaces/JoinPartyData.ts b/server/interfaces/JoinPartyData.ts deleted file mode 100644 index 8ac6757..0000000 --- a/server/interfaces/JoinPartyData.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default interface JoinPartyData { - partyRef: string -} \ No newline at end of file diff --git a/server/interfaces/UsernameData.ts b/server/interfaces/UsernameData.ts deleted file mode 100644 index 2653431..0000000 --- a/server/interfaces/UsernameData.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default interface UsernameData { - username?: string, - password?: string -} \ No newline at end of file diff --git a/server/models/account/LoginViewModel.ts b/server/models/account/LoginViewModel.ts new file mode 100644 index 0000000..0efb9d3 --- /dev/null +++ b/server/models/account/LoginViewModel.ts @@ -0,0 +1,5 @@ +export default interface LoginViewModel { + message?: string, + username: string, + password: string +} \ No newline at end of file diff --git a/server/models/account/RegisterViewModel.ts b/server/models/account/RegisterViewModel.ts new file mode 100644 index 0000000..e6c7a67 --- /dev/null +++ b/server/models/account/RegisterViewModel.ts @@ -0,0 +1,5 @@ +export default interface RegisterViewModel { + message?: string, + username: string, + password: string +} \ No newline at end of file diff --git a/server/models/api/ApiLoginModel.ts b/server/models/api/ApiLoginModel.ts new file mode 100644 index 0000000..0529eb5 --- /dev/null +++ b/server/models/api/ApiLoginModel.ts @@ -0,0 +1,4 @@ +export default interface ApiLoginModel { + username: string, + password: string +} \ No newline at end of file diff --git a/server/models/home/HomeViewModel.ts b/server/models/home/HomeViewModel.ts new file mode 100644 index 0000000..965baf2 --- /dev/null +++ b/server/models/home/HomeViewModel.ts @@ -0,0 +1,9 @@ +import Party from "../../entities/Party"; +import User from "../../entities/User"; +import UserParty from "../../entities/UserParty"; + +export default interface HomeViewModel { + user: User, + parties: Array, + activeUserParty: UserParty | null +} \ No newline at end of file diff --git a/server/models/party/CreateEditPartyViewModel.ts b/server/models/party/CreateEditPartyViewModel.ts new file mode 100644 index 0000000..b120288 --- /dev/null +++ b/server/models/party/CreateEditPartyViewModel.ts @@ -0,0 +1,7 @@ +export default interface CreateEditPartyViewModel { + message?:string + + id?: string + name: string, + partyRef: string, +} \ No newline at end of file diff --git a/server/models/party/JoinPartyViewModel.ts b/server/models/party/JoinPartyViewModel.ts new file mode 100644 index 0000000..ad1aa21 --- /dev/null +++ b/server/models/party/JoinPartyViewModel.ts @@ -0,0 +1,4 @@ +export default interface JoinPartyViewModel { + message?: string + partyRef: string +} \ No newline at end of file diff --git a/server/models/party/LeavePartyModel.ts b/server/models/party/LeavePartyModel.ts new file mode 100644 index 0000000..cae54a1 --- /dev/null +++ b/server/models/party/LeavePartyModel.ts @@ -0,0 +1,3 @@ +export default interface LeavePartyModel { + id: string +} \ No newline at end of file diff --git a/server/models/party/SetActivePartyModel.ts b/server/models/party/SetActivePartyModel.ts new file mode 100644 index 0000000..bfb4d84 --- /dev/null +++ b/server/models/party/SetActivePartyModel.ts @@ -0,0 +1,3 @@ +export default interface SetActivePartyModel { + id: string +} \ No newline at end of file diff --git a/server/objects/RequestCtx.ts b/server/objects/RequestCtx.ts new file mode 100644 index 0000000..a4ee2e1 --- /dev/null +++ b/server/objects/RequestCtx.ts @@ -0,0 +1,60 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import SessionUser from "./SessionUser"; + +export default class RequestCtx { + public controllerName:string; + public actionName:string; + public session?:SessionUser; + public req: FastifyRequest; + public res: FastifyReply; + + public constructor(req: FastifyRequest, res: FastifyReply, controllerName:string, actionName:string, sessionUser?:SessionUser) { + this.session = sessionUser; + this.req = req; + this.res = res; + this.controllerName = controllerName; + this.actionName = actionName; + } + + view(view?:string | Object, model?: Object) { + let viewName: string = this.actionName; + let viewModel: Object = {}; + if (typeof(view) === "string") { + viewName = view; + } else if (typeof(view) === "object") { + viewModel = view; + } + if (typeof(model) === "object") { + viewModel = model; + } + // @ts-ignore inject session + viewModel["session"] = this.session; + return this.res.view(`templates/${this.controllerName}/${viewName}.ejs`, viewModel); + } + + // TODO: query params + redirectToAction(action:string, controller?:string) { + const controllerName = controller ?? this.controllerName; + if (action === "index") { + if (controllerName === "home") { + return this.res.redirect(302, `/`); + } else { + return this.res.redirect(302, `/${controllerName}`); + } + } else { + return this.res.redirect(302, `/${controllerName}/${action}`); + } + } + + ok(message?:string) { + return this.res.status(200).send(message ?? ""); + } + + badRequest(message?:string) { + return this.res.status(400).send(message ?? ""); + } + + unauthorised(message?:string) { + return this.res.status(401).send(message ?? ""); + } +} \ No newline at end of file diff --git a/server/objects/Session.ts b/server/objects/Session.ts new file mode 100644 index 0000000..1ad208e --- /dev/null +++ b/server/objects/Session.ts @@ -0,0 +1,55 @@ +import Config from "./Config"; +import FastifyCookie from "@fastify/cookie"; +import FunkyArray from "funky-array"; +import SessionUser from "./SessionUser"; +import { FastifyReply, FastifyRequest } from "fastify"; +import User from "../entities/User"; +import { randomBytes } from "crypto"; + +type Cookies = { [cookieName: string]: string | undefined } + +export default abstract class Session { + public static Sessions = new FunkyArray(); + public static SessionExpiryInterval = setInterval(() => { + const currentTime = Date.now(); + for (const key of Session.Sessions.keys) { + const session = Session.Sessions.get(key); + if (!session || (session && currentTime >= session.validityPeriod.getTime())) { + Session.Sessions.remove(key); + } + } + }, 3600000); + + public static AssignUserSession(res:FastifyReply, user:User) { + const validPeriod = new Date(); + validPeriod.setTime(validPeriod.getTime() + Config.session.validity); + const key = randomBytes(Config.session.length).toString("hex"); + + Session.Sessions.set(key, new SessionUser(user.Id, validPeriod)); + + res.setCookie("MP_SESSION", key, { + path: "/", + signed: true + }); + } + + public static Clear(cookies:Cookies, res:FastifyReply) { + if ("MP_SESSION" in cookies && typeof(cookies["MP_SESSION"]) === "string") { + const key:unknown = FastifyCookie.unsign(cookies["MP_SESSION"], Config.session.secret); + Session.Sessions.remove(key as string); + + res.clearCookie("MP_SESSION"); + } + } + + public static CheckValiditiy(cookies:Cookies) { + if ("MP_SESSION" in cookies && typeof(cookies["MP_SESSION"]) === "string") { + const key = FastifyCookie.unsign(cookies["MP_SESSION"], Config.session.secret); + if (key.valid && Session.Sessions.has(key.value ?? "badkey")) { + return Session.Sessions.get(key.value ?? "badkey"); + } + } + + return undefined; + } +} \ No newline at end of file diff --git a/server/repos/PartyRepo.ts b/server/repos/PartyRepo.ts index 5a9f5a5..e830a30 100644 --- a/server/repos/PartyRepo.ts +++ b/server/repos/PartyRepo.ts @@ -1,6 +1,6 @@ import Database from "../objects/Database"; -import Party from "../objects/Party"; -import User from "../objects/User"; +import Party from "../entities/Party"; +import User from "../entities/User"; import RepoBase from "./RepoBase"; export default class PartyRepo { @@ -65,5 +65,5 @@ function populatePartyFromDB(party:Party, dbParty:any) { party.LastModifiedDatetime = RepoBase.convertNullableDatetimeIntToDate(dbParty.LastModifiedDatetime); party.DeletedByUserId = dbParty.DeletedByUserId; party.DeletedDatetime = RepoBase.convertNullableDatetimeIntToDate(dbParty.DeletedDatetime); - party.IsDeleted = dbParty.IsDeleted; + party.IsDeleted = dbParty.IsDeleted === 1; } \ No newline at end of file diff --git a/server/repos/UserPartyRepo.ts b/server/repos/UserPartyRepo.ts index 5ca1645..f489763 100644 --- a/server/repos/UserPartyRepo.ts +++ b/server/repos/UserPartyRepo.ts @@ -1,5 +1,5 @@ import Database from "../objects/Database"; -import UserParty from "../objects/UserParty"; +import UserParty from "../entities/UserParty"; import RepoBase from "./RepoBase"; export default class UserPartyRepo { @@ -82,5 +82,5 @@ function populateUserPartyFromDB(userParty:UserParty, dbUserParty:any) { userParty.LastModifiedDatetime = RepoBase.convertNullableDatetimeIntToDate(dbUserParty.LastModifiedDatetime); userParty.DeletedByUserId = dbUserParty.DeletedByUserId; userParty.DeletedDatetime = RepoBase.convertNullableDatetimeIntToDate(dbUserParty.DeletedDatetime); - userParty.IsDeleted = dbUserParty.IsDeleted; + userParty.IsDeleted = dbUserParty.IsDeleted === 1; } \ No newline at end of file diff --git a/server/repos/UserRepo.ts b/server/repos/UserRepo.ts index da448ca..26dd454 100644 --- a/server/repos/UserRepo.ts +++ b/server/repos/UserRepo.ts @@ -1,5 +1,5 @@ import Database from "../objects/Database"; -import User from "../objects/User"; +import User from "../entities/User"; import RepoBase from "./RepoBase"; export default class UserRepo { @@ -61,5 +61,5 @@ function populateUserFromDB(user:User, dbUser:any) { user.LastModifiedDatetime = RepoBase.convertNullableDatetimeIntToDate(dbUser.LastModifiedDatetime); user.DeletedByUserId = dbUser.DeletedByUserId; user.DeletedDatetime = RepoBase.convertNullableDatetimeIntToDate(dbUser.DeletedDatetime); - user.IsDeleted = dbUser.IsDeleted; + user.IsDeleted = dbUser.IsDeleted === 1; } \ No newline at end of file diff --git a/server/services/UserService.ts b/server/services/UserService.ts index 4d9769b..5d130ce 100644 --- a/server/services/UserService.ts +++ b/server/services/UserService.ts @@ -1,13 +1,31 @@ import { Console } from "hsconsole"; -import User from "../objects/User"; +import User from "../entities/User"; import PartyRepo from "../repos/PartyRepo"; import UserRepo from "../repos/UserRepo"; import PasswordUtility from "../utilities/PasswordUtility"; -import Party from "../objects/Party"; -import UserParty from "../objects/UserParty"; +import Party from "../entities/Party"; +import UserParty from "../entities/UserParty"; import UserPartyRepo from "../repos/UserPartyRepo"; export default class UserService { + public static async AuthenticateUser(username:string, password:string) { + try { + const user = await UserRepo.selectByUsername(username); + if (!user) { + return null; + } + + if (await PasswordUtility.ValidatePassword(user.PasswordHash, user.PasswordSalt, password)) { + return user; + } + + return null; + } catch (e) { + Console.printError(`MultiProbe server service error:\n${e}`); + throw e; + } + } + public static async GetUser(id:number) { try { return await UserRepo.selectById(id); @@ -169,4 +187,24 @@ export default class UserService { throw e; } } + + public static async LeaveParty(currentUserId:number, partyId:number) { + try { + const userParty = await UserPartyRepo.selectByUserIdPartyId(currentUserId, partyId); + if (!userParty) { + return null; + } + + userParty.DeletedByUserId = currentUserId; + userParty.DeletedDatetime = new Date(); + userParty.IsDeleted = true; + + await UserPartyRepo.insertUpdate(userParty); + + return userParty; + } catch (e) { + Console.printError(`MultiProbe server service error:\n${e}`); + throw e; + } + } } \ No newline at end of file diff --git a/server/templates/account/login.ejs b/server/templates/account/login.ejs index 053a78c..22445d7 100644 --- a/server/templates/account/login.ejs +++ b/server/templates/account/login.ejs @@ -1,27 +1,27 @@ <%- include("../base/header", { title: "Login" }) %> -

Login

-
-
-
-
-
- - -
-
- - -
- - -
- -
+
+
+
+

MultiProbe Login

+ <% if (typeof(message) === "string") { %> + + <% } %> + + " > + " required /> + +
+ +
+ +
+
+
-
- +
+ <%- include("../base/footer") %> \ No newline at end of file diff --git a/server/templates/account/register.ejs b/server/templates/account/register.ejs index 17198cb..37f113f 100644 --- a/server/templates/account/register.ejs +++ b/server/templates/account/register.ejs @@ -1,27 +1,27 @@ <%- include("../base/header", { title: "Register" }) %> -

Register

-
-
-
-
-
- - -
-
- - -
- - -
- -
+
+
+
+

MultiProbe Registration

+ <% if (typeof(message) === "string") { %> + + <% } %> + + " > + " required autocomplete="new-password" /> + + +
-
- +
+ <%- include("../base/footer") %> \ No newline at end of file diff --git a/server/templates/home.ejs b/server/templates/home/home.ejs similarity index 94% rename from server/templates/home.ejs rename to server/templates/home/home.ejs index 3a49364..b872d0b 100644 --- a/server/templates/home.ejs +++ b/server/templates/home/home.ejs @@ -1,4 +1,4 @@ -<%- include("base/header", { title: "Home", userId: user.Id }) %> +<%- include("../base/header", { title: "Home", userId: session.userId }) %>

Welcome back <%= user.Username %>!

@@ -51,4 +51,4 @@ <% } %>
-<%- include("base/footer") %> \ No newline at end of file +<%- include("../base/footer") %> \ No newline at end of file diff --git a/server/templates/index.ejs b/server/templates/home/index.ejs similarity index 79% rename from server/templates/index.ejs rename to server/templates/home/index.ejs index f1e3425..bb5e236 100644 --- a/server/templates/index.ejs +++ b/server/templates/home/index.ejs @@ -1,8 +1,8 @@ -<%- include("base/header", { title: "Home" }) %> +<%- include("../base/header", { title: "Home" }) %>

MultiProbe

A way to explore Terminal 00 with friends.

-<%- include("base/footer") %> \ No newline at end of file +<%- include("../base/footer") %> \ No newline at end of file diff --git a/server/templates/party/createEdit.ejs b/server/templates/party/createEdit.ejs new file mode 100644 index 0000000..6239e0d --- /dev/null +++ b/server/templates/party/createEdit.ejs @@ -0,0 +1,32 @@ +<%- include("../base/header", { title: typeof(party) === "undefined" ? "Create Party" : `Editing ${name}`, userId: session.userId }) %> +
+
+
+ <% if (typeof(party) === "undefined") { %> +

Create New Party

+ <% } else { %> +

Editing <%= name %>

+ <% } %> + <% if (typeof(message) === "string") { %> + + <% } %> + +
+
+ + " required /> +
+
+ + " required /> +
+ +
+ + Cancel +
+
+
+
+
+<%- include("../base/footer") %> \ No newline at end of file diff --git a/server/templates/party/createedit.ejs b/server/templates/party/createedit.ejs deleted file mode 100644 index 5aa078a..0000000 --- a/server/templates/party/createedit.ejs +++ /dev/null @@ -1,34 +0,0 @@ -<%- include("../base/header", { title: typeof(party) === "undefined" ? "Create Party" : `Editing ${party.Name}`, userId: session.userId }) %> -<% if (typeof(party) === "undefined") { %> -

Create Party

-<% } else { %> -

Editing <%= party.Name %>

-<% } %> -
-
-
-
- <% if (typeof(error) === "string") { %> -
- <%= error %> -
- <% } %> - -
- - " required /> -
-
- - " required /> -
- -
- - Cancel -
-
-
-
-
-<%- include("../base/footer") %> \ No newline at end of file diff --git a/server/templates/party/join.ejs b/server/templates/party/join.ejs index 9a47905..74296a7 100644 --- a/server/templates/party/join.ejs +++ b/server/templates/party/join.ejs @@ -1,26 +1,24 @@ <%- include("../base/header", { title: "Join Party", userId: session.userId }) %> -

Join Party

-
-
-
-
- <% if (typeof(error) === "string") { %> -
- <%= error %> -
+
+
+
+

Join Party

+ <% if (typeof(message) === "string") { %> + <% } %> - -
- - " required /> -
- -
- - Cancel -
+ + +
+ + " required /> +
+ +
+ + Cancel +
+
-
- +
<%- include("../base/footer") %> \ No newline at end of file