├── src ├── views │ ├── includes │ │ ├── globals.pug │ │ ├── background.pug │ │ ├── status.pug │ │ └── head.pug │ ├── delete.pug │ ├── login.pug │ ├── user.pug │ ├── signup.pug │ └── index.pug ├── less │ ├── includes │ │ └── base.less │ ├── status.less │ └── main.less ├── authorities.ts ├── log │ ├── morgan2winston.ts │ └── loginstance.ts ├── maps.ts ├── entities │ └── user.ts ├── app.ts ├── pingservice.ts ├── serviceinstance.ts ├── hash.ts ├── services │ └── usersservice.ts └── controller │ └── web.ts ├── public ├── favicon.ico └── images │ ├── aim_dust.png │ ├── cs_italy.png │ ├── de_aztec.png │ ├── de_cbble.png │ ├── de_dust.png │ ├── de_dust2.png │ ├── de_nuke.png │ ├── de_sa02.png │ ├── de_sa03.png │ ├── de_storm.png │ ├── de_tides.png │ ├── de_tosca.png │ ├── de_train.png │ ├── dm_sewer.png │ ├── awp_metro.png │ ├── awp_museum.png │ ├── cs_aquarium.png │ ├── cs_assault.png │ ├── cs_estate.png │ ├── cs_militia.png │ ├── cs_office.png │ ├── de_centered.png │ ├── de_highland.png │ ├── de_inferno.png │ ├── de_prodigy.png │ ├── de_storage.png │ ├── de_survivor.png │ ├── de_vantage.png │ ├── de_vertigo.png │ ├── dm_shutdown.png │ ├── dm_uprising.png │ ├── hm_multi01.png │ ├── hs_italy2.png │ ├── hs_studio.png │ ├── de_cornerwork.png │ ├── dm_cargoship.png │ ├── dm_killhouse.png │ ├── cs_italy_night.png │ ├── cs_office_xmas.png │ ├── de_cornerwork2.png │ └── de_headquarter.png ├── .dockerignore ├── .prettierrc ├── Dockerfile ├── .gitattributes ├── tsconfig.json ├── LICENSE ├── .gitignore ├── README.md ├── .travis.yml ├── gulpfile.js ├── package.json └── .eslintrc.js /src/views/includes/globals.pug: -------------------------------------------------------------------------------- 1 | - const serverTitle = 'Master Server for Nexon\'s CSO2' -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/images/aim_dust.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/aim_dust.png -------------------------------------------------------------------------------- /public/images/cs_italy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/cs_italy.png -------------------------------------------------------------------------------- /public/images/de_aztec.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_aztec.png -------------------------------------------------------------------------------- /public/images/de_cbble.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_cbble.png -------------------------------------------------------------------------------- /public/images/de_dust.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_dust.png -------------------------------------------------------------------------------- /public/images/de_dust2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_dust2.png -------------------------------------------------------------------------------- /public/images/de_nuke.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_nuke.png -------------------------------------------------------------------------------- /public/images/de_sa02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_sa02.png -------------------------------------------------------------------------------- /public/images/de_sa03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_sa03.png -------------------------------------------------------------------------------- /public/images/de_storm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_storm.png -------------------------------------------------------------------------------- /public/images/de_tides.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_tides.png -------------------------------------------------------------------------------- /public/images/de_tosca.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_tosca.png -------------------------------------------------------------------------------- /public/images/de_train.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_train.png -------------------------------------------------------------------------------- /public/images/dm_sewer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/dm_sewer.png -------------------------------------------------------------------------------- /public/images/awp_metro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/awp_metro.png -------------------------------------------------------------------------------- /public/images/awp_museum.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/awp_museum.png -------------------------------------------------------------------------------- /public/images/cs_aquarium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/cs_aquarium.png -------------------------------------------------------------------------------- /public/images/cs_assault.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/cs_assault.png -------------------------------------------------------------------------------- /public/images/cs_estate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/cs_estate.png -------------------------------------------------------------------------------- /public/images/cs_militia.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/cs_militia.png -------------------------------------------------------------------------------- /public/images/cs_office.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/cs_office.png -------------------------------------------------------------------------------- /public/images/de_centered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_centered.png -------------------------------------------------------------------------------- /public/images/de_highland.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_highland.png -------------------------------------------------------------------------------- /public/images/de_inferno.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_inferno.png -------------------------------------------------------------------------------- /public/images/de_prodigy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_prodigy.png -------------------------------------------------------------------------------- /public/images/de_storage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_storage.png -------------------------------------------------------------------------------- /public/images/de_survivor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_survivor.png -------------------------------------------------------------------------------- /public/images/de_vantage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_vantage.png -------------------------------------------------------------------------------- /public/images/de_vertigo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_vertigo.png -------------------------------------------------------------------------------- /public/images/dm_shutdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/dm_shutdown.png -------------------------------------------------------------------------------- /public/images/dm_uprising.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/dm_uprising.png -------------------------------------------------------------------------------- /public/images/hm_multi01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/hm_multi01.png -------------------------------------------------------------------------------- /public/images/hs_italy2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/hs_italy2.png -------------------------------------------------------------------------------- /public/images/hs_studio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/hs_studio.png -------------------------------------------------------------------------------- /public/images/de_cornerwork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_cornerwork.png -------------------------------------------------------------------------------- /public/images/dm_cargoship.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/dm_cargoship.png -------------------------------------------------------------------------------- /public/images/dm_killhouse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/dm_killhouse.png -------------------------------------------------------------------------------- /public/images/cs_italy_night.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/cs_italy_night.png -------------------------------------------------------------------------------- /public/images/cs_office_xmas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/cs_office_xmas.png -------------------------------------------------------------------------------- /public/images/de_cornerwork2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_cornerwork2.png -------------------------------------------------------------------------------- /public/images/de_headquarter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L-Leite/cso2-webapp/HEAD/public/images/de_headquarter.png -------------------------------------------------------------------------------- /src/less/includes/base.less: -------------------------------------------------------------------------------- 1 | .container-base { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | } 6 | -------------------------------------------------------------------------------- /src/views/includes/background.pug: -------------------------------------------------------------------------------- 1 | div(class="container-bg") 2 | img(src="/static/images/" + mapImage, alt="" class="container-bg-video") -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | dist 3 | docs 4 | node_modules 5 | public/styles 6 | 7 | *.log 8 | .eslintrc.json 9 | .gitattributes 10 | .gitignore 11 | LICENSE 12 | README.md -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "semi": false, 4 | "singleQuote": true, 5 | "quoteProps": "as-needed", 6 | "trailingComma": "none", 7 | "bracketSpacing": true, 8 | "arrowParens": "always", 9 | "endOfLine": "lf" 10 | } 11 | -------------------------------------------------------------------------------- /src/authorities.ts: -------------------------------------------------------------------------------- 1 | import { PingService } from 'pingservice' 2 | 3 | export function userSvcAuthority(): string { 4 | return process.env.USERSERVICE_HOST + ':' + process.env.USERSERVICE_PORT 5 | } 6 | 7 | export const UserSvcPing: PingService = new PingService(userSvcAuthority()) 8 | -------------------------------------------------------------------------------- /src/views/includes/status.pug: -------------------------------------------------------------------------------- 1 | if status 2 | div(class="status-container") 3 | div(class="status-container-content status-container-info") 4 | p= status 5 | if error 6 | div(class="status-container") 7 | div(class="status-container-content status-container-error") 8 | p= 'An error has occured: ' + error -------------------------------------------------------------------------------- /src/views/includes/head.pug: -------------------------------------------------------------------------------- 1 | head 2 | meta(charset="UTF-8") 3 | meta(name="viewport", content="width=device-width, initial-scale=1.0") 4 | meta(http-equiv="X-UA-Compatible", content="ie=edge") 5 | title= pageTitle + ' | ' + serverTitle 6 | link(rel="stylesheet", href="/static/styles/main.css") 7 | link(rel="stylesheet", href="/static/styles/status.css") -------------------------------------------------------------------------------- /src/log/morgan2winston.ts: -------------------------------------------------------------------------------- 1 | import { LogInstance } from 'log/loginstance' 2 | 3 | /** 4 | * writes logged data from morgan to a winston logger instance 5 | * @class MorganToWinstonStream 6 | */ 7 | export class MorganToWinstonStream { 8 | /** 9 | * Output stream for writing log lines. 10 | */ 11 | public write(str: string): void { 12 | LogInstance.info(str) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mhart/alpine-node:13 2 | 3 | WORKDIR /srv/webapp 4 | COPY package.json yarn.lock gulpfile.js tsconfig.json tslint.json ./ 5 | 6 | # get source code 7 | COPY src ./src 8 | 9 | # copy static files 10 | COPY public ./public 11 | 12 | # install dependencies 13 | RUN yarn install --frozen-lockfile 14 | 15 | # build app from source 16 | RUN npx gulp build 17 | 18 | # start the service 19 | CMD [ "node", "dist/app.js" ] -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "esModuleInterop": true, 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "noImplicitAny": true, 10 | "outDir": "dist", 11 | "removeComments": true, 12 | "sourceMap": true, 13 | "paths": { 14 | "*": ["src/*"] 15 | }, 16 | "target": "es6" 17 | }, 18 | "include": ["src/**/*"] 19 | } 20 | -------------------------------------------------------------------------------- /src/less/status.less: -------------------------------------------------------------------------------- 1 | @import "base"; 2 | 3 | @statusContainerHeight: 5rem; 4 | @statusBgPaddingWidth: 8rem; 5 | 6 | @infoBgColor: fadeout(lighten(green, 10%), 25%); 7 | @infoTextColor: white; 8 | 9 | @errorBgColor: fadeout(desaturate(red, 10%), 25%); 10 | @errorTextColor: white; 11 | 12 | .status-container { 13 | .container-base(); 14 | height: @statusContainerHeight; 15 | } 16 | 17 | .status-container-content { 18 | padding: 1rem @statusBgPaddingWidth - 5; 19 | 20 | @media (min-width: 480px) { 21 | padding: 1rem @statusBgPaddingWidth; 22 | } 23 | 24 | p { 25 | text-align: center; 26 | } 27 | } 28 | 29 | .status-container-info { 30 | .status-container-content(); 31 | background-color: @infoBgColor; 32 | color: @infoTextColor; 33 | } 34 | 35 | .status-container-error { 36 | .status-container-content(); 37 | background-color: @errorBgColor; 38 | color: @errorTextColor; 39 | } -------------------------------------------------------------------------------- /src/maps.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import pify from 'pify' 3 | 4 | /** 5 | * stores the list of images inside public/images 6 | */ 7 | export class MapImageList { 8 | /** 9 | * builds the image list 10 | */ 11 | public static async build(): Promise { 12 | this.files = (await pify(fs.readdir)('public/images')) as string[] 13 | } 14 | 15 | /** 16 | * get a random file from the list 17 | * @returns the random file, or null if a bad index was generated 18 | */ 19 | public static getRandomFile(): string { 20 | const index: number = Math.floor(Math.random() * this.files.length) 21 | 22 | if (index < 0 || index >= this.files.length) { 23 | return null 24 | } 25 | 26 | return this.files[index] 27 | } 28 | 29 | public static getNumOfFiles(): number { 30 | return this.files.length 31 | } 32 | 33 | private static files: string[] = [] 34 | } 35 | -------------------------------------------------------------------------------- /src/views/delete.pug: -------------------------------------------------------------------------------- 1 | include includes/globals 2 | 3 | - const pageTitle = 'Account deletion' 4 | 5 | doctype html 6 | html(lang="en") 7 | include includes/head 8 | body 9 | include includes/background 10 | include includes/status 11 | div(class="container-userstats") 12 | div(class="container-content") 13 | div(class="page-title") 14 | h1 Deleting your account 15 | form(action="/do_delete", method="post") 16 | div(class="page-content") 17 | h3= 'Are you sure you want to delete \'' + user.playerName + '\'?' 18 | div(class="form-item") 19 | label(for="confirmation") Delete this account 20 | input#confirmation(type="checkbox", name="confirmation" required="true" class="form-text-input") 21 | div(class="form-item") 22 | input(type="submit", value="Delete" class="button") 23 | div(class="help-url") 24 | a(href='/user') Go back -------------------------------------------------------------------------------- /src/views/login.pug: -------------------------------------------------------------------------------- 1 | include includes/globals 2 | 3 | - const pageTitle = 'Log in' 4 | 5 | doctype html 6 | html(lang="en") 7 | include includes/head 8 | body 9 | include includes/background 10 | include includes/status 11 | div(class="container-login") 12 | div(class="container-content") 13 | div(class="page-title") 14 | h1 Log in to your account 15 | form(action="/do_login", method="post") 16 | div(class="page-content") 17 | div(class="form-item") 18 | input(type="text", name="username" placeholder="Account's user name" required="true" class="form-text-input") 19 | div(class="form-item") 20 | input(type="password", name="password" placeholder="Account's password" required="true" class="form-text-input") 21 | div(class="form-item") 22 | input(type="submit", value="Log in" class="button") 23 | div(class="help-url") 24 | a(href='/signup') Create an account 25 | span | 26 | a(href='/') Go back -------------------------------------------------------------------------------- /src/views/user.pug: -------------------------------------------------------------------------------- 1 | include includes/globals 2 | 3 | - const pageTitle = user.playerName + '\'s statistics' 4 | 5 | doctype html 6 | html(lang="en") 7 | include includes/head 8 | body 9 | include includes/background 10 | include includes/status 11 | div(class="container-userstats") 12 | div(class="container-content") 13 | div(class="page-title") 14 | h1= user.playerName + '\'s statistics' 15 | div(class="page-content") 16 | p= 'Level: ' + user.level 17 | p= 'Current experience: ' + user.cur_xp 18 | p= 'Experience until next level: ' + user.max_xp 19 | p= 'Avatar ID: ' + user.avatar 20 | p= 'Rank: ' + user.rank 21 | p= 'VIP level: ' + user.vip_level 22 | p= 'Wins: ' + user.wins 23 | p= 'Kills: ' + user.kills 24 | p= 'Deaths: ' + user.deaths 25 | p= 'Assists: ' + user.assists 26 | div(class="help-url") 27 | a(href='/logout') Log out of your account 28 | span | 29 | a(href='/user/delete') Delete your account -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Luís Leite 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/views/signup.pug: -------------------------------------------------------------------------------- 1 | include includes/globals 2 | 3 | - const pageTitle = 'Create an account' 4 | 5 | doctype html 6 | html(lang="en") 7 | include includes/head 8 | body 9 | include includes/background 10 | include includes/status 11 | div(class="container-signup") 12 | div(class="container-content") 13 | div(class="page-title") 14 | h1 Create a new account 15 | form(action="/do_signup", method="post") 16 | div(class="page-content") 17 | div(class="form-item") 18 | input(type="text", name="username" placeholder="Your new user's name" required="true" class="form-text-input") 19 | div(class="form-item") 20 | input(type="text", name="playername" placeholder="Your new player in-game name" required="true" class="form-text-input") 21 | div(class="form-item") 22 | input(type="password", name="password" placeholder="Your user's password" required="true" class="form-text-input") 23 | div(class="form-item") 24 | input(type="password", name="confirmed_password" placeholder="Repeat your user's password" required="true" class="form-text-input") 25 | div(class="form-item") 26 | input(type="submit", value="Create" class="button") 27 | div(class="help-url") 28 | a(href='/login') Log in to an existing account 29 | span | 30 | a(href='/') Go back 31 | -------------------------------------------------------------------------------- /src/entities/user.ts: -------------------------------------------------------------------------------- 1 | export const USER_MAX_LEVEL = 99 2 | 3 | /** 4 | * represents an user and its data 5 | */ 6 | export class User { 7 | public id: number 8 | public username: string 9 | public playername: string 10 | 11 | public gm: boolean 12 | 13 | public points: number 14 | public cash: number 15 | public mpoints: number 16 | 17 | public level: number 18 | public cur_xp: BigInt 19 | public max_xp: BigInt 20 | public vip_level: number 21 | public vip_xp: number 22 | 23 | public rank: number 24 | 25 | public rank_frame: number 26 | 27 | public played_matches: number 28 | public wins: number 29 | public seconds_played: number 30 | 31 | public kills: number 32 | public deaths: number 33 | public assists: number 34 | public headshots: number 35 | public accuracy: number 36 | 37 | public avatar: number 38 | public unlocked_avatars: number[] 39 | 40 | public title: number 41 | public unlocked_titles: number[] 42 | public signature: string 43 | 44 | public unlocked_achievements: number[] 45 | 46 | public netcafe_name: string 47 | 48 | public clan_name: string 49 | public clan_mark: number 50 | 51 | public world_rank: number 52 | 53 | public best_gamemode: number 54 | public best_map: number 55 | 56 | public skill_human_curxp: BigInt 57 | public skill_human_maxxp: BigInt 58 | public skill_human_points: BigInt 59 | public skill_zombie_curxp: BigInt 60 | public skill_zombie_maxxp: BigInt 61 | public skill_zombie_points: BigInt 62 | } 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # typescript out build directory 76 | /dist/ 77 | 78 | # logged packets 79 | /packets/ 80 | 81 | # version generated 82 | version.txt 83 | 84 | # ignore vscode config files 85 | /.vscode/ 86 | 87 | # ignore generated css files 88 | /public/styles -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cso2-webapp 2 | 3 | [![Build Status](https://travis-ci.org/Ochii/cso2-webapp.svg?branch=master)](https://travis-ci.org/Ochii/cso2-webapp) 4 | 5 | A web app to manage users for a Nexon's Counter-Strike: Online 2 master server written in Typescript on top of Node.js. 6 | 7 | You can find download and build scripts in [cso2-master-services](https://github.com/Ochii/cso2-master-services#running-the-services). 8 | 9 | ## Building 10 | 11 | After downloading the source code, go to a terminal instance, inside the source code's directory and: 12 | 13 | ```sh 14 | npm install # installs the required dependencies 15 | gulp build # builds the service 16 | ``` 17 | 18 | ## Starting the app 19 | 20 | **Note: You must have an user service and an inventory service running somewhere.** 21 | 22 | You can start the web app with: 23 | 24 | ```sh 25 | # environment variables 26 | export USERSERVICE_HOST=127.0.0.1 # the user service's host 27 | export USERSERVICE_PORT=30100 # the user service's port 28 | export INVSERVICE_HOST=127.0.0.1 # the inventory service's host 29 | export INVSERVICE_PORT=30101 # the inventory service's port 30 | 31 | # starts the service 32 | node dist/app.js 33 | ``` 34 | 35 | You **must** set those environment variables, or the web app will not start. 36 | 37 | If you want to know how to run this with the other services, see [cso2-master-services](https://github.com/Ochii/cso2-master-services). 38 | 39 | ## Contributing 40 | 41 | Bug reports and pull requests are very much welcome. 42 | 43 | See the [current project's progress](https://github.com/Ochii/cso2-master-services/projects/1) for more information. 44 | 45 | ## License 46 | 47 | Read ```LICENSE``` for the project's license information. 48 | 49 | This project is not affiliated with either Valve or Nexon. Counter-Strike: Online 2 is owned by these companies. 50 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - '10' 5 | - '11' 6 | cache: npm 7 | script: 8 | - gulp build 9 | jobs: 10 | include: 11 | - stage: GitHub Release 12 | node_js: '11' 13 | before_deploy: 14 | # Set up git user name and tag this commit 15 | - git config --local user.name "Luís Leite" 16 | - git config --local user.email "ochii@leite.xyz" 17 | # write npm version to version.txt file 18 | - npm run write-version 19 | # setup vars 20 | - export GIT_COMMIT_HASH=$(git rev-parse --short=8 HEAD) 21 | - export GIT_BRANCH=${TRAVIS_PULL_REQUEST_BRANCH:-$TRAVIS_BRANCH} 22 | - export SERVER_VERSION=$(cat version.txt) 23 | - export FILENAME_SUFFIX="$SERVER_VERSION-$GIT_COMMIT_HASH-$GIT_BRANCH" 24 | - git tag $SERVER_VERSION 25 | # Pack the transpiled source code 26 | - tar -zcf cso2-webapp_$FILENAME_SUFFIX.tar.gz dist public package.json package-lock.json LICENSE README.md 27 | deploy: 28 | name: Version $SERVER_VERSION 29 | provider: releases 30 | api_key: 31 | secure: mGzui1eyr65GtXWVjeq1icqw/7Cmo5PXVtNCnV35N8HcgVWzSULQpoBrno6bC87o9Wtq0pVLwLH+26jMm1thTihg17gOD6fojZGTejIuwlk5KGePV4TQqs1ufVDPvXvmgiTJ07Wg2Q+CjkWi4Ez2ZGNb3c8lyePOjUXq04Mxp3x/NxEufuwUik0iYOJiCoNGtktaw2r3WpYspIDobwEM5wWf4I0HXLKRWq/buc7GUxh3bWMShtz1awf0cAPH9UfPBDTyFgPGcu58L66+znwbDs0pyf5ctGUudFdxDq278I/pO7fireV9Ae7Q459HE5WpSsPVou+cy1iQsh2GPY8+VxaxjqikfaoKdLcdBDCoV+oGfnapnpSr7VSpRaRwQx/Vc/9vxOnkI7ayQhxS/YoWO7eimEuD5eVhKILiMfubSCyg4Xy85Ez8HNoDkMMdiSsTnBm+qvzzlYTH+2uMBSzAvEMmAZFF+VFjdmzEx4eDcJ7hbO4V69SvqOQDYVa2IdG00Hb9JAYM9dMgXVS+eaa/sMx16Zrl0Fj75d8SCkrJWtdefwkc3e2UFGqTU8eob1j6+2joU4OI/houGfY2r+1vcYaV1jO/mE7rNnofxxPg9cNCJMUhn54cqwATa9PlR/sfKE1seqxW3oEiBE0TNpE4IU4/ORKEsxdJPWkZn6cQb6o= 32 | file: "cso2-webapp_$FILENAME_SUFFIX.tar.gz" 33 | skip_cleanup: true 34 | overwrite: true -------------------------------------------------------------------------------- /src/log/loginstance.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston' 2 | 3 | /** 4 | * sets up and stores the winston logger 5 | */ 6 | export class LogInstance { 7 | /** 8 | * log a debug type message 9 | * @param message the message to log 10 | */ 11 | public static debug(message: string): void { 12 | LogInstance.logger.debug(message) 13 | } 14 | 15 | /** 16 | * log a error type message 17 | * @param message the message to log 18 | */ 19 | public static error(message: string): void { 20 | LogInstance.logger.error(message) 21 | } 22 | 23 | /** 24 | * log a information type message 25 | * @param message the message to log 26 | */ 27 | public static info(message: string): void { 28 | LogInstance.logger.info(message) 29 | } 30 | 31 | /** 32 | * log a warning type message 33 | * @param message the message to log 34 | */ 35 | public static warn(message: string): void { 36 | LogInstance.logger.warn(message) 37 | } 38 | 39 | /** 40 | * setup winston logger 41 | */ 42 | public static init(): void { 43 | LogInstance.logger = winston.createLogger({ 44 | format: winston.format.json(), 45 | level: 'info', 46 | transports: [ 47 | new winston.transports.File({ 48 | filename: 'error.log', 49 | level: 'error' 50 | }), 51 | new winston.transports.File({ filename: 'combined.log' }) 52 | ] 53 | }) 54 | 55 | if (process.env.NODE_ENV === 'development') { 56 | LogInstance.logger.add( 57 | new winston.transports.Console({ 58 | format: winston.format.simple(), 59 | level: 'debug' 60 | }) 61 | ) 62 | } 63 | } 64 | private static logger: winston.Logger 65 | } 66 | 67 | // init the logger 68 | LogInstance.init() 69 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const eslint = require('gulp-eslint') 4 | const gulp = require('gulp') 5 | const less = require('gulp-less') 6 | const log = require('fancy-log') 7 | const path = require('path') 8 | const sourcemaps = require('gulp-sourcemaps') 9 | const ts = require('gulp-typescript') 10 | const typedoc = require('gulp-typedoc') 11 | 12 | gulp.task('readme', () => { 13 | log('Generating documentation...') 14 | return gulp.src(['src/**/*.ts']).pipe( 15 | typedoc({ 16 | excludeExternals: true, 17 | ignoreCompilerErrors: false, 18 | includeDeclarations: true, 19 | name: 'cso2-inventory-service', 20 | out: './docs', 21 | plugins: ['mdFlavour github'], 22 | theme: 'markdown', 23 | tsconfig: 'tsconfig.json', 24 | version: true 25 | }) 26 | ) 27 | }) 28 | 29 | gulp.task('eslint', () => { 30 | log('Linting source code...') 31 | return gulp 32 | .src(['src/**/*.ts']) 33 | .pipe(eslint()) 34 | .pipe(eslint.format()) 35 | .pipe(eslint.failAfterError()) 36 | }) 37 | 38 | gulp.task('typescript', () => { 39 | log('Transpiling source code...') 40 | const project = ts.createProject('tsconfig.json') 41 | return project 42 | .src() 43 | .pipe(sourcemaps.init()) 44 | .pipe(project()) 45 | .pipe( 46 | sourcemaps.write('.', { 47 | includeContent: false, 48 | sourceRoot: '../src' 49 | }) 50 | ) 51 | .pipe(gulp.dest('dist')) 52 | }) 53 | 54 | gulp.task('less', () => { 55 | log('Transpiling style sheets...') 56 | return gulp 57 | .src('src/less/*.less') 58 | .pipe( 59 | less({ 60 | paths: [path.join('src/less/includes')] 61 | }) 62 | ) 63 | .pipe(gulp.dest('public/styles')) 64 | }) 65 | 66 | gulp.task('build', gulp.series('less', 'eslint', 'typescript')) 67 | 68 | gulp.task('typedoc', gulp.series('readme')) 69 | 70 | gulp.task('default', gulp.series('less', 'typescript')) 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cso2-webapp", 3 | "version": "0.0.4", 4 | "repository": "github:Ochii/cso2-webapp", 5 | "description": "Web user app for a Nexon's Counter-Strike: Online 2 master server", 6 | "author": "Luís Leite", 7 | "license": "MIT", 8 | "main": "src/app.ts", 9 | "scripts": { 10 | "write-version": "cross-var \"echo $npm_package_version > version.txt\"" 11 | }, 12 | "dependencies": { 13 | "@types/lru-cache": "^5.1.0", 14 | "app-module-path": "^2.2.0", 15 | "body-parser": "^1.19.0", 16 | "commander": "^2.20.3", 17 | "cookie-parser": "^1.4.4", 18 | "errorhandler": "^1.5.1", 19 | "express": "^4.17.1", 20 | "express-session": "^1.17.0", 21 | "helmet": "^3.21.2", 22 | "int64-buffer": "^0.99.1007", 23 | "lru-cache": "^5.1.1", 24 | "morgan": "^1.9.1", 25 | "pify": "^4.0.1", 26 | "pug": "^2.0.4", 27 | "serve-favicon": "^2.5.0", 28 | "superagent": "^5.2.2", 29 | "uuid": "^3.3.3", 30 | "winston": "^3.2.1" 31 | }, 32 | "devDependencies": { 33 | "@types/app-module-path": "^2.2.0", 34 | "@types/cookie-parser": "^1.4.2", 35 | "@types/errorhandler": "0.0.32", 36 | "@types/express": "^4.17.2", 37 | "@types/express-session": "^1.15.15", 38 | "@types/helmet": "0.0.42", 39 | "@types/morgan": "^1.7.37", 40 | "@types/node": "^10.17.3", 41 | "@types/pify": "^3.0.2", 42 | "@types/serve-favicon": "^2.2.31", 43 | "@types/superagent": "^4.1.7", 44 | "@types/uuid": "^3.4.6", 45 | "@typescript-eslint/eslint-plugin": "^3.6.1", 46 | "@typescript-eslint/parser": "^3.6.1", 47 | "babel-register": "^6.26.0", 48 | "cross-var": "^1.1.0", 49 | "eslint": "^7.4.0", 50 | "eslint-config-prettier": "^6.11.0", 51 | "eslint-plugin-jsdoc": "^29.2.0", 52 | "gulp": "^4.0.2", 53 | "gulp-cli": "^2.1.0", 54 | "gulp-eslint": "^6.0.0", 55 | "gulp-less": "^4.0.1", 56 | "gulp-sourcemaps": "^2.6.5", 57 | "gulp-typedoc": "^2.2.2", 58 | "gulp-typescript": "^5.0.1", 59 | "less": "^3.10.3", 60 | "ts-node": "^7.0.1", 61 | "tslint": "^5.20.0", 62 | "typedoc": "^0.15.0", 63 | "typedoc-plugin-markdown": "^1.2.1", 64 | "typescript": "^3.6.4" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict' 4 | 5 | // add the src directory to the module search path 6 | import 'app-module-path/register' 7 | 8 | import { UserSvcPing } from 'authorities' 9 | import { LogInstance } from 'log/loginstance' 10 | import { ServiceInstance } from 'serviceinstance' 11 | 12 | /** 13 | * check if the required environment variables are set on start 14 | * throws an error if one is missing 15 | */ 16 | function validateEnvVars(): void { 17 | if (process.env.WEBAPP_PORT == null) { 18 | throw new Error('WEBAPP_PORT environment variable is not set.') 19 | } 20 | 21 | if (process.env.USERSERVICE_HOST == null) { 22 | throw new Error('USERSERVICE_HOST environment variable is not set.') 23 | } 24 | 25 | if (process.env.USERSERVICE_PORT == null) { 26 | throw new Error('USERSERVICE_PORT environment variable is not set.') 27 | } 28 | } 29 | 30 | async function checkServices(): Promise { 31 | await Promise.all([UserSvcPing.checkNow()]) 32 | 33 | return UserSvcPing.isAlive() 34 | } 35 | 36 | let instance: ServiceInstance = null 37 | 38 | /** 39 | * start a service instance 40 | */ 41 | async function EntryPoint(): Promise { 42 | validateEnvVars() 43 | 44 | if (await checkServices()) { 45 | LogInstance.warn('Connected to user and inventory services') 46 | LogInstance.warn('User service is at ' + UserSvcPing.getHost()) 47 | instance = new ServiceInstance() 48 | await instance.listen() 49 | return true 50 | } 51 | 52 | LogInstance.warn( 53 | 'Could not connect to the services, waiting 5 seconds until another connection attempt' 54 | ) 55 | LogInstance.warn( 56 | `User service is ${UserSvcPing.isAlive() ? 'online' : 'offline'}` 57 | ) 58 | 59 | return false 60 | } 61 | 62 | /** 63 | * wait until the required services are online 64 | */ 65 | const loop: NodeJS.Timeout = setInterval(() => { 66 | void EntryPoint().then((res) => { 67 | if (res === true) { 68 | clearInterval(loop) 69 | } 70 | }) 71 | }, 1000 * 5) 72 | 73 | process 74 | .on('SIGINT', () => { 75 | instance.stop() 76 | }) 77 | .on('SIGTERM', () => { 78 | instance.stop() 79 | }) 80 | -------------------------------------------------------------------------------- /src/pingservice.ts: -------------------------------------------------------------------------------- 1 | import superagent from 'superagent' 2 | 3 | import { LogInstance } from 'log/loginstance' 4 | 5 | const PING_ALIVE_DELAY: number = 1000 * 15 6 | const PING_DOWN_DELAY: number = 1000 * 5 7 | 8 | /** 9 | * checks if a service is alive every x seconds 10 | */ 11 | export class PingService { 12 | /** 13 | * pings a service to check if they're online 14 | * @param thisInstance the callee's PingService instance 15 | */ 16 | private static async onPingCheck(thisInstance: PingService): Promise { 17 | try { 18 | const res: superagent.Response = await superagent 19 | .get('http://' + thisInstance.host + '/ping') 20 | .accept('json') 21 | thisInstance.updateDelay(res.status === 200) 22 | } catch (error) { 23 | thisInstance.updateDelay(false) 24 | } 25 | } 26 | 27 | private host: string 28 | private alive: boolean 29 | private timerId: NodeJS.Timeout 30 | 31 | constructor(host: string) { 32 | this.host = host 33 | } 34 | 35 | /** 36 | * performs a ping to the host 37 | */ 38 | public async checkNow(): Promise { 39 | await PingService.onPingCheck(this) 40 | } 41 | 42 | /** 43 | * get the host being checked 44 | */ 45 | public getHost(): string { 46 | return this.host 47 | } 48 | 49 | /** 50 | * is the service up? 51 | * @returns true if so, false if not 52 | */ 53 | public isAlive(): boolean { 54 | return this.alive 55 | } 56 | 57 | /** 58 | * stops the ping check 59 | */ 60 | public stopTimer(): void { 61 | clearInterval(this.timerId) 62 | } 63 | 64 | /** 65 | * updates the ping check interval according to the alive state 66 | * @param isAlive the new alive state 67 | */ 68 | private updateDelay(isAlive: boolean): void { 69 | if (this.alive === isAlive) { 70 | return 71 | } 72 | 73 | LogInstance.warn( 74 | 'Host ' + this.host + ' is now ' + (isAlive ? 'up' : 'down') 75 | ) 76 | 77 | clearInterval(this.timerId) 78 | this.timerId = setInterval( 79 | () => { 80 | void PingService.onPingCheck(this) 81 | }, 82 | isAlive ? PING_ALIVE_DELAY : PING_DOWN_DELAY, 83 | this 84 | ) 85 | 86 | this.alive = isAlive 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/views/index.pug: -------------------------------------------------------------------------------- 1 | include includes/globals 2 | 3 | - const pageTitle = 'Welcome' 4 | 5 | - let playerCountSentence = 'Could not get the number of players online.' 6 | - if (playersOnline === 0) { playerCountSentence = 'There are NO players connected to the server at the moment.' } 7 | - else if (playersOnline === 1) { playerCountSentence = 'There is ONE player connected to the server at the moment.'} 8 | - else { playerCountSentence = `There are ${playersOnline} players connected to the server at the moment.`} 9 | 10 | doctype html 11 | html(lang="en") 12 | include includes/head 13 | body 14 | include includes/background 15 | include includes/status 16 | div(class="container-main") 17 | div(class="container-content") 18 | div(class="page-title") 19 | h1= serverTitle 20 | div(class="page-content") 21 | div(class="main-url-container") 22 | div(class="main-url") 23 | a(href='/signup' class="button") Create a new account 24 | div(class="main-url") 25 | a(href='/login' class="button") Log in to your account 26 | div 27 | p= 'Welcome to ' + serverTitle + '\'s user management web page.' 28 | p You may 29 | a(href='/signup') create an account 30 | | to play in this server, or 31 | a(href='/login') login to an existing one 32 | | to further manage your data. 33 | p= playerCountSentence 34 | div(class="disclaimer") 35 | h4 Disclaimer 36 | p This website stores a cookie in your computer to remember your logged in user's session. 37 | p By creating an account, your user name, player name and password will be saved in a database, along with player statistics such as kills and wins. 38 | p When logging in to the master server through CSO2, your IP address, client and server ports and the room you're currently in will be saved in the database, until you log off from or exit CSO2. 39 | p You may delete your account by 40 | a(href='/login') login in 41 | | and 42 | a(href='/user/delete') going here. 43 | p Counter-Strike Online 2 is owned by Valve and Nexon. Neither Valve or Nexon are associated with this. 44 | div(class="help-url") 45 | a(href='https://leite.xyz') Created by Luís Leite 46 | span | 47 | a(href='https://github.com/Ochii/cso2-webapp') Source code -------------------------------------------------------------------------------- /src/serviceinstance.ts: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser' 2 | import crypto from 'crypto' 3 | import express from 'express' 4 | import session from 'express-session' 5 | import helmet from 'helmet' 6 | import http from 'http' 7 | import morgan from 'morgan' 8 | import favicon from 'serve-favicon' 9 | 10 | import { LogInstance } from 'log/loginstance' 11 | import { MorganToWinstonStream } from 'log/morgan2winston' 12 | 13 | import { WebController } from 'controller/web' 14 | import { MapImageList } from 'maps' 15 | 16 | const sessionSettings: session.SessionOptions = { 17 | secret: crypto.randomBytes(32).toString('hex'), 18 | name: 'cso2-web-session', 19 | cookie: { 20 | maxAge: 1000 * 60 * 30, 21 | sameSite: true 22 | }, 23 | resave: false, 24 | saveUninitialized: false 25 | } 26 | 27 | /** 28 | * the service's entrypoint 29 | */ 30 | export class ServiceInstance { 31 | public app: express.Express 32 | private server: http.Server 33 | 34 | constructor() { 35 | this.app = express() 36 | 37 | this.applyConfigs() 38 | this.setupRoutes() 39 | 40 | this.app.set('port', process.env.WEBAPP_PORT) 41 | } 42 | 43 | /** 44 | * start the service 45 | */ 46 | public async listen(): Promise { 47 | await MapImageList.build() 48 | 49 | LogInstance.info(`Found ${MapImageList.getNumOfFiles()} map images`) 50 | 51 | this.server = this.app.listen(this.app.get('port')) 52 | 53 | LogInstance.info('Started web page service') 54 | LogInstance.info(`Listening at ${this.app.get('port') as number}`) 55 | } 56 | 57 | /** 58 | * stop the service instance 59 | */ 60 | public stop(): void { 61 | this.server.close() 62 | } 63 | 64 | /** 65 | * apply configurations to the service 66 | */ 67 | private applyConfigs(): void { 68 | // set the log format according to the current environment 69 | let morganLogFormat = '' 70 | 71 | if (this.isDevEnv()) { 72 | morganLogFormat = 'dev' 73 | sessionSettings.cookie.secure = false 74 | } else { 75 | morganLogFormat = 'common' 76 | // sessionSettings.cookie.secure = true 77 | } 78 | 79 | // use morgan as middleware, and pass the logs to winston 80 | this.app.use( 81 | morgan(morganLogFormat, { stream: new MorganToWinstonStream() }) 82 | ) 83 | 84 | // parse json 85 | this.app.use(bodyParser.json()) 86 | this.app.use(bodyParser.urlencoded({ extended: true })) 87 | 88 | // setup helmet 89 | this.app.use(helmet({ frameguard: false })) 90 | 91 | // use session with cookies 92 | this.app.use(session(sessionSettings)) 93 | 94 | // use pug 95 | this.app.set('views', 'src/views') 96 | this.app.set('view engine', 'pug') 97 | 98 | // set static files location 99 | this.app.use('/static', express.static('public')) 100 | 101 | // set favicon 102 | this.app.use(favicon('public/favicon.ico')) 103 | } 104 | 105 | /** 106 | * setup the service's API routes 107 | */ 108 | private setupRoutes(): void { 109 | WebController.setup(this.app) 110 | } 111 | 112 | /** 113 | * are we in a development environment? 114 | * @returns true if so, false if not 115 | */ 116 | private isDevEnv(): boolean { 117 | return process.env.NODE_ENV === 'development' 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/hash.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import pify from 'pify' 3 | 4 | export const HASH_PASSWORD_VERSION = 1 5 | 6 | const DEFAULT_HASH_ITERATIONS = 100000 7 | const DEFAULT_KEY_LENGTH = 64 8 | const DEFAULT_COMPONENT_COUNT = 4 9 | 10 | /** 11 | * hashes a password with PBKDF2 12 | * @param password the password to be hashed 13 | * @param iterations the number of iterations to perform 14 | * @param salt the hash's salt 15 | * @returns a string with a string format version, salt, iterations and hash respectively 16 | */ 17 | async function generatePasswordHash( 18 | password: string, 19 | iterations: number, 20 | salt: string, 21 | keylen: number 22 | ): Promise { 23 | return (await pify(crypto.pbkdf2)( 24 | password, 25 | salt, 26 | iterations, 27 | keylen, 28 | 'sha512' 29 | )) as Buffer 30 | } 31 | 32 | /** 33 | * stores and manages password hashes 34 | */ 35 | export class HashContainer { 36 | /** 37 | * creates a new composed hash from a password and returns a new container object 38 | * @param password the password to be hashed 39 | * @returns a new hash container object based on the input password 40 | */ 41 | public static async create( 42 | password: string, 43 | iterations: number = DEFAULT_HASH_ITERATIONS, 44 | salt: string = null, 45 | keylen: number = DEFAULT_KEY_LENGTH 46 | ): Promise { 47 | if (salt == null) { 48 | salt = crypto.randomBytes(16).toString('hex') 49 | } 50 | 51 | const hash: Buffer = await generatePasswordHash( 52 | password, 53 | iterations, 54 | salt, 55 | keylen 56 | ) 57 | 58 | return new HashContainer(salt, iterations, hash) 59 | } 60 | 61 | /** 62 | * parses a composed hash and returns a new hash container object 63 | * @param composedHash the composed hash to be parsed 64 | * @returns a new hash container object with the parsed composed hash 65 | */ 66 | public static from(composedHash: string): HashContainer { 67 | const hashComponents: string[] = composedHash.split(':') 68 | 69 | if (hashComponents.length !== DEFAULT_COMPONENT_COUNT) { 70 | throw new Error( 71 | `The target's hash length ${hashComponents.length} is invalid` 72 | ) 73 | } 74 | 75 | const passwordVersion = Number(hashComponents[0]) 76 | 77 | if (passwordVersion !== HASH_PASSWORD_VERSION) { 78 | throw new Error( 79 | `The target's hash version ${passwordVersion} is different from ours ${HASH_PASSWORD_VERSION}` 80 | ) 81 | } 82 | 83 | const salt: string = hashComponents[1] 84 | const iterations = Number(hashComponents[2]) 85 | const hash: Buffer = Buffer.from(hashComponents[3], 'hex') 86 | 87 | return new HashContainer(salt, iterations, hash) 88 | } 89 | 90 | private salt: string 91 | private iterations: number 92 | private hash: Buffer 93 | 94 | protected constructor(salt: string, iterations: number, hash: Buffer) { 95 | this.salt = salt 96 | this.iterations = iterations 97 | this.hash = hash 98 | } 99 | 100 | /** 101 | * compare this hash container with another hash container 102 | * @param right the other hash container to compare to 103 | * @returns true if they're equal, false if not 104 | */ 105 | public compare(right: HashContainer): boolean { 106 | if (this.salt !== right.salt || this.iterations !== right.iterations) { 107 | return false 108 | } 109 | 110 | return crypto.timingSafeEqual(this.hash, right.hash) 111 | } 112 | 113 | /** 114 | * clone a hash's container salt and iterations and use them to hash a different password 115 | * @param password to be hashed 116 | * @returns a new hash container object with the new hash and the cloned salt and iterations 117 | */ 118 | public async cloneSettings(password: string): Promise { 119 | return await HashContainer.create(password, this.iterations, this.salt) 120 | } 121 | 122 | /** 123 | * outputs the hash in the following format: 124 | * {version number}:{salt}:{iterations}:{hash} 125 | * @returns the combined hash 126 | */ 127 | public build(): string { 128 | return `${HASH_PASSWORD_VERSION}:${this.salt}:${ 129 | this.iterations 130 | }:${this.hash.toString('hex')}` 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/less/main.less: -------------------------------------------------------------------------------- 1 | @import "base"; 2 | 3 | @mainContainerOffset: 45rem; 4 | @loginContainerOffset: 30rem; 5 | @signupContainerOffset: 35rem; 6 | @statsContainerOffset: 40rem; 7 | 8 | @anchorColor: saturate(blue, 20%); 9 | @anchorHoverColor: darken(@anchorColor, 20%); 10 | @anchorActiveColor: darken(@anchorHoverColor, 20%); 11 | 12 | @containerBgColor: desaturate(fadeout(darken(cyan, 10%), 25%), 50%); 13 | 14 | @pageTitleBgColor: saturate(@containerBgColor, 25%); 15 | @pageTitleWidth: 2rem; 16 | @pageTitleHeight: 4rem; 17 | 18 | @formMargin: 1rem; 19 | @formItemSeparator: 1rem; 20 | 21 | @inputPadding: 0.65rem; 22 | 23 | @buttonBgColor: fadeout(desaturate(blue, 50%), 10%); 24 | @buttonBgHoverColor: darken(@buttonBgColor, 20%); 25 | @buttonBgClickedColor: darken(@buttonBgColor, 30%); 26 | @buttonBorderColor: darken(@buttonBgColor, 20%); 27 | @buttonBorderSize: 0.15rem; 28 | @buttonPadding: 1rem; 29 | 30 | @helpUrlColor: black; 31 | @helpUrlFontSize: 0.9rem; 32 | 33 | @mainUrlColor: darken(white, 5%); 34 | @mainUrlFontSize: 1rem; 35 | @mainUrlPadding: 1rem 2rem; 36 | 37 | body { 38 | font-family: -apple-system, 39 | BlinkMacSystemFont, 40 | "Segoe UI", 41 | "Helvetica Neue", 42 | Arial, 43 | "Noto Sans", 44 | sans-serif, 45 | "Apple Color Emoji", 46 | "Segoe UI Emoji", 47 | "Segoe UI Symbol", 48 | "Noto Color Emoji"; 49 | margin: 0; 50 | } 51 | 52 | a { 53 | color: @anchorColor; 54 | text-decoration: none; 55 | 56 | &:hover { 57 | color: @anchorHoverColor; 58 | text-decoration: underline; 59 | } 60 | 61 | &:focus { 62 | color: @anchorActiveColor; 63 | text-decoration: underline; 64 | } 65 | } 66 | 67 | .container-main { 68 | .container-base(); 69 | height: @mainContainerOffset + 20rem; 70 | 71 | @media (min-width: 480px) { 72 | height: @mainContainerOffset + 10rem; 73 | } 74 | 75 | @media (min-width: 768px) { 76 | height: @mainContainerOffset; 77 | } 78 | } 79 | 80 | .container-login { 81 | .container-base(); 82 | height: @loginContainerOffset; 83 | } 84 | 85 | .container-signup { 86 | .container-base(); 87 | height: @signupContainerOffset; 88 | } 89 | 90 | .container-userstats { 91 | .container-base(); 92 | height: @statsContainerOffset; 93 | } 94 | 95 | .container-bg { 96 | position: fixed; 97 | top: 0; 98 | right: 0; 99 | bottom: 0; 100 | left: 0; 101 | overflow: hidden; 102 | z-index: -100; 103 | } 104 | 105 | .container-bg-video { 106 | position: absolute; 107 | top: 50%; 108 | left: 50%; 109 | width: auto; 110 | height: auto; 111 | min-width: 100%; 112 | min-height: 100%; 113 | transform: translate(-50%, -50%); 114 | } 115 | 116 | .container-content { 117 | background-color: @containerBgColor; 118 | max-width: 75%; 119 | 120 | @media (min-width: 480px) { 121 | max-width: 62.5%; 122 | } 123 | 124 | @media (min-width: 1024px) { 125 | max-width: 50%; 126 | } 127 | 128 | @media (min-width: 1280px) { 129 | max-width: 35%; 130 | } 131 | } 132 | 133 | .page-title { 134 | padding: @pageTitleWidth @pageTitleHeight; 135 | background-color: @pageTitleBgColor; 136 | 137 | h1 { 138 | margin: 0; 139 | text-align: center; 140 | } 141 | } 142 | 143 | .page-content { 144 | margin: @formMargin; 145 | } 146 | 147 | .form-item { 148 | margin: @formItemSeparator 0; 149 | 150 | input { 151 | box-sizing: border-box; 152 | } 153 | 154 | input[type="text"], 155 | input[type="password"] { 156 | background-color: @buttonBgClickedColor; 157 | padding: @inputPadding; 158 | width: 100%; 159 | } 160 | 161 | input[type="submit"] { 162 | width: 100%; 163 | } 164 | 165 | label { 166 | font-weight: bold; 167 | 168 | input[type="checkbox"] { 169 | position: relative; 170 | vertical-align: middle; 171 | bottom: 1px; 172 | } 173 | } 174 | } 175 | 176 | .button { 177 | background-color: @buttonBgColor; 178 | padding: @buttonPadding; 179 | border: @buttonBorderSize @buttonBorderColor solid; 180 | border-radius: 0; 181 | transition: 0.3s; 182 | 183 | &:hover { 184 | background-color: @buttonBgHoverColor; 185 | } 186 | 187 | &:focus { 188 | background-color: @buttonBgClickedColor; 189 | } 190 | } 191 | 192 | .help-url { 193 | margin: 1rem 0; 194 | 195 | a { 196 | color: @helpUrlColor; 197 | font-size: @helpUrlFontSize; 198 | padding: 0 0.2rem; 199 | text-decoration: none; 200 | 201 | &:hover { 202 | text-decoration: underline; 203 | } 204 | } 205 | } 206 | 207 | .main-url-container { 208 | display: inline-block; 209 | width: 100%; 210 | } 211 | 212 | .main-url { 213 | display: block; 214 | float: left; 215 | width: 50%; 216 | 217 | a { 218 | color: @mainUrlColor; 219 | box-sizing: border-box; 220 | display: inline-block; 221 | font-size: @mainUrlFontSize; 222 | padding: @mainUrlPadding; 223 | text-align: center; 224 | text-decoration: none; 225 | width: 100%; 226 | } 227 | } 228 | 229 | .disclaimer { 230 | h4 { 231 | margin: 0; 232 | } 233 | 234 | p { 235 | font-size: 0.9rem; 236 | margin: 0; 237 | margin-bottom: 0.25rem; 238 | } 239 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true 5 | }, 6 | extends: [ 7 | 'plugin:@typescript-eslint/recommended', 8 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 9 | 'prettier', 10 | 'prettier/@typescript-eslint' 11 | ], 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | project: 'tsconfig.json', 15 | sourceType: 'module' 16 | }, 17 | plugins: ['@typescript-eslint'], 18 | rules: { 19 | '@typescript-eslint/adjacent-overload-signatures': 'error', 20 | '@typescript-eslint/array-type': [ 21 | 'error', 22 | { 23 | default: 'array' 24 | } 25 | ], 26 | '@typescript-eslint/ban-types': [ 27 | 'error', 28 | { 29 | types: { 30 | Object: { 31 | message: 32 | 'Avoid using the `Object` type. Did you mean `object`?' 33 | }, 34 | Function: { 35 | message: 36 | 'Avoid using the `Function` type. Prefer a specific function type, like `() => void`.' 37 | }, 38 | Boolean: { 39 | message: 40 | 'Avoid using the `Boolean` type. Did you mean `boolean`?' 41 | }, 42 | Number: { 43 | message: 44 | 'Avoid using the `Number` type. Did you mean `number`?' 45 | }, 46 | String: { 47 | message: 48 | 'Avoid using the `String` type. Did you mean `string`?' 49 | }, 50 | Symbol: { 51 | message: 52 | 'Avoid using the `Symbol` type. Did you mean `symbol`?' 53 | } 54 | } 55 | } 56 | ], 57 | '@typescript-eslint/consistent-type-assertions': 'error', 58 | '@typescript-eslint/dot-notation': 'error', 59 | '@typescript-eslint/member-delimiter-style': [ 60 | 'off', 61 | { 62 | multiline: { 63 | delimiter: 'none', 64 | requireLast: true 65 | }, 66 | singleline: { 67 | delimiter: 'semi', 68 | requireLast: false 69 | } 70 | } 71 | ], 72 | '@typescript-eslint/no-empty-function': 'error', 73 | '@typescript-eslint/no-empty-interface': 'error', 74 | '@typescript-eslint/no-explicit-any': 'off', 75 | '@typescript-eslint/no-misused-new': 'error', 76 | '@typescript-eslint/no-namespace': 'error', 77 | '@typescript-eslint/no-parameter-properties': 'off', 78 | '@typescript-eslint/no-unused-expressions': 'error', 79 | '@typescript-eslint/no-use-before-define': 'off', 80 | '@typescript-eslint/no-var-requires': 'error', 81 | '@typescript-eslint/prefer-for-of': 'error', 82 | '@typescript-eslint/prefer-function-type': 'error', 83 | '@typescript-eslint/prefer-namespace-keyword': 'error', 84 | '@typescript-eslint/quotes': ['error', 'single'], 85 | '@typescript-eslint/semi': ['off', 'never'], 86 | '@typescript-eslint/triple-slash-reference': [ 87 | 'error', 88 | { 89 | path: 'always', 90 | types: 'prefer-import', 91 | lib: 'always' 92 | } 93 | ], 94 | '@typescript-eslint/unified-signatures': 'error', 95 | //camelcase: 'error', 96 | complexity: 'off', 97 | 'constructor-super': 'error', 98 | eqeqeq: ['error', 'smart'], 99 | 'guard-for-in': 'error', 100 | 'id-blacklist': [ 101 | 'error', 102 | 'any', 103 | 'Number', 104 | 'number', 105 | 'String', 106 | 'string', 107 | 'Boolean', 108 | 'boolean', 109 | 'Undefined', 110 | 'undefined' 111 | ], 112 | 'id-match': 'error', 113 | 'max-classes-per-file': ['error', 1], 114 | 'new-parens': 'error', 115 | 'no-bitwise': 'error', 116 | 'no-caller': 'error', 117 | 'no-cond-assign': 'error', 118 | 'no-console': 'off', 119 | 'no-debugger': 'error', 120 | 'no-empty': 'error', 121 | 'no-eval': 'error', 122 | 'no-fallthrough': 'off', 123 | 'no-invalid-this': 'off', 124 | 'no-new-wrappers': 'error', 125 | 'no-shadow': [ 126 | 'error', 127 | { 128 | hoist: 'all' 129 | } 130 | ], 131 | 'no-throw-literal': 'error', 132 | 'no-trailing-spaces': 'error', 133 | 'no-undef-init': 'error', 134 | 'no-underscore-dangle': 'error', 135 | 'no-unsafe-finally': 'error', 136 | 'no-unused-labels': 'error', 137 | 'no-var': 'error', 138 | 'object-shorthand': 'error', 139 | 'one-var': ['error', 'never'], 140 | 'prefer-const': 'error', 141 | radix: 'error', 142 | 'spaced-comment': [ 143 | 'error', 144 | 'always', 145 | { 146 | markers: ['/'] 147 | } 148 | ], 149 | 'use-isnan': 'error', 150 | 'valid-typeof': 'off' 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/services/usersservice.ts: -------------------------------------------------------------------------------- 1 | import LRU from 'lru-cache' 2 | import superagent from 'superagent' 3 | 4 | import { userSvcAuthority, UserSvcPing } from 'authorities' 5 | import { User } from 'entities/user' 6 | 7 | /** 8 | * represents an user account 9 | */ 10 | export class UsersService { 11 | /** 12 | * create an user 13 | * @param username the new user's name 14 | * @param playername the user's ingame player name 15 | * @param password the user's account password 16 | * @returns the user ID if created, false if not 17 | */ 18 | public static async create( 19 | username: string, 20 | playername: string, 21 | password: string 22 | ): Promise { 23 | try { 24 | const res: superagent.Response = await superagent 25 | .post(`http://${userSvcAuthority()}/users/`) 26 | .send({ 27 | username, 28 | playername, 29 | password 30 | }) 31 | .accept('json') 32 | 33 | if (res.status !== 201) { 34 | return null 35 | } 36 | 37 | return res.body as User 38 | } catch (error) { 39 | await UserSvcPing.checkNow() 40 | throw error 41 | } 42 | } 43 | 44 | /** 45 | * get an user's by its ID 46 | * @param userId the user's ID 47 | * @returns the user object if found, null otherwise 48 | */ 49 | public static async get(userId: number): Promise { 50 | try { 51 | let foundUser: User = userCache.get(userId) 52 | 53 | if (foundUser != null) { 54 | return foundUser 55 | } 56 | 57 | if (UserSvcPing.isAlive() === false) { 58 | return null 59 | } 60 | 61 | const res: superagent.Response = await superagent 62 | .get(`http://${userSvcAuthority()}/users/${userId}`) 63 | .accept('json') 64 | 65 | if (res.status !== 200) { 66 | return null 67 | } 68 | 69 | // HACK to get methods working 70 | foundUser = new User() 71 | Object.assign(foundUser, res.body) 72 | userCache.set(foundUser.id, foundUser) 73 | return foundUser 74 | } catch (error) { 75 | await UserSvcPing.checkNow() 76 | throw error 77 | } 78 | } 79 | 80 | /** 81 | * get an user's by its name 82 | * @param userName the target's user name 83 | */ 84 | public static async getByName(userName: string): Promise { 85 | try { 86 | if (UserSvcPing.isAlive() === false) { 87 | return null 88 | } 89 | 90 | const res: superagent.Response = await superagent 91 | .get(`http://${userSvcAuthority()}/users/byname/${userName}`) 92 | .accept('json') 93 | 94 | if (res.status !== 200) { 95 | return null 96 | } 97 | 98 | // HACK to get, methods working 99 | const foundUser: User = new User() 100 | Object.assign(foundUser, res.body) 101 | userCache.set(foundUser.id, foundUser) 102 | return foundUser 103 | } catch (error) { 104 | await UserSvcPing.checkNow() 105 | throw error 106 | } 107 | } 108 | 109 | /** 110 | * delete an user 111 | * @param userId the user's to be deleted ID 112 | * @returns true if deleted successfully, false if not 113 | */ 114 | public static async delete(userId: number): Promise { 115 | try { 116 | const res: superagent.Response = await superagent 117 | .delete(`http://${userSvcAuthority()}/users/${userId}`) 118 | .accept('json') 119 | return res.status === 200 120 | } catch (error) { 121 | await UserSvcPing.checkNow() 122 | throw error 123 | } 124 | } 125 | 126 | /** 127 | * validates an user's credentials and gets the matching user's id 128 | * @param username the user's name 129 | * @param password the user's password 130 | * @returns the matching user id if found, null if not 131 | */ 132 | public static async validate( 133 | username: string, 134 | password: string 135 | ): Promise { 136 | try { 137 | const res: superagent.Response = await superagent 138 | .post(`http://${userSvcAuthority()}/users/auth/validate`) 139 | .send({ 140 | username, 141 | password 142 | }) 143 | .accept('json') 144 | 145 | if (res.status !== 200) { 146 | return null 147 | } 148 | 149 | const typedBody = res.body as { userId: number } 150 | return typedBody.userId 151 | } catch (error) { 152 | await UserSvcPing.checkNow() 153 | throw error 154 | } 155 | } 156 | 157 | public static async getSessions(): Promise { 158 | try { 159 | const res: superagent.Response = await superagent 160 | .get(`http://${userSvcAuthority()}/ping`) 161 | .accept('json') 162 | 163 | if (res.ok === false) { 164 | return 0 165 | } 166 | 167 | const typedBody = res.body as { sessions: number } 168 | return typedBody.sessions 169 | } catch (error) { 170 | await UserSvcPing.checkNow() 171 | throw error 172 | } 173 | } 174 | 175 | /** 176 | * create a new inventory for an user 177 | * @param userId the new owner's user ID 178 | * @returns true if successful, false if not 179 | */ 180 | public static async createInventory(userId: number): Promise { 181 | const res: superagent.Response = await superagent 182 | .post(`http://${userSvcAuthority()}/inventory/${userId}`) 183 | .accept('json') 184 | return res.status === 201 185 | } 186 | 187 | /** 188 | * create new cosmetic slots for an user 189 | * @param userId the new owner's user ID 190 | * @returns true if successful, false if not 191 | */ 192 | public static async createCosmetics(userId: number): Promise { 193 | const res: superagent.Response = await superagent 194 | .post(`http://${userSvcAuthority()}/inventory/${userId}/cosmetics`) 195 | .accept('json') 196 | return res.status === 201 197 | } 198 | 199 | /** 200 | * create new loadouts for an user 201 | * @param userId the new owner's user ID 202 | * @returns true if successful, false if not 203 | */ 204 | public static async createLoadouts(userId: number): Promise { 205 | const res: superagent.Response = await superagent 206 | .post(`http://${userSvcAuthority()}/inventory/${userId}/loadout`) 207 | .accept('json') 208 | return res.status === 201 209 | } 210 | 211 | /** 212 | * create new buy menu slots for an user 213 | * @param userId the new owner's user ID 214 | * @returns true if successful, false if not 215 | */ 216 | public static async createBuymenu(userId: number): Promise { 217 | const res: superagent.Response = await superagent 218 | .post(`http://${userSvcAuthority()}/inventory/${userId}/buymenu`) 219 | .accept('json') 220 | return res.status === 201 221 | } 222 | } 223 | 224 | const userCache = new LRU({ max: 100, maxAge: 1000 * 15 }) 225 | -------------------------------------------------------------------------------- /src/controller/web.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | 3 | import { LogInstance } from 'log/loginstance' 4 | 5 | import { MapImageList } from 'maps' 6 | 7 | import { User } from 'entities/user' 8 | import { UsersService } from 'services/usersservice' 9 | 10 | type WebSession = { 11 | error?: string 12 | status?: string 13 | userId?: number 14 | } 15 | 16 | /** 17 | * handles requests to / 18 | */ 19 | export class WebController { 20 | /** 21 | * setup the controller's routes 22 | * @param app the server's express instance 23 | */ 24 | public static setup(app: express.Express): void { 25 | app.route('/').get(async (req, res) => { 26 | await WebController.OnGetIndex(req, res) 27 | }) 28 | app.route('/signup').get((req, res) => { 29 | WebController.OnGetSignup(req, res) 30 | }) 31 | app.route('/login').get((req, res) => { 32 | WebController.OnGetLogin(req, res) 33 | }) 34 | app.route('/logout').get((req, res) => { 35 | WebController.OnGetLogout(req, res) 36 | }) 37 | app.route('/user').get(async (req, res) => { 38 | await WebController.OnGetUser(req, res) 39 | }) 40 | app.route('/user/delete').get(async (req, res) => { 41 | await WebController.OnGetUserDelete(req, res) 42 | }) 43 | app.route('/do_signup').post(async (req, res) => { 44 | await WebController.OnPostDoSignup(req, res) 45 | }) 46 | app.route('/do_login').post(async (req, res) => { 47 | await WebController.OnPostDoLogin(req, res) 48 | }) 49 | app.route('/do_delete').post(async (req, res) => { 50 | await WebController.OnPostDoDelete(req, res) 51 | }) 52 | } 53 | 54 | /** 55 | * redirect to a page with an error message set in the session 56 | * @param error the error message 57 | * @param redirPage the page to redirect the user to 58 | * @param req the user's request object 59 | * @param res the response object to the user 60 | */ 61 | private static redirectWithError( 62 | error: string, 63 | redirPage: string, 64 | req: express.Request, 65 | res: express.Response 66 | ): void { 67 | req.session.error = error 68 | return res.redirect(redirPage) 69 | } 70 | 71 | /** 72 | * clean up an user's session status and error messages 73 | * @param req the user's request 74 | */ 75 | private static cleanUpStatus(req: express.Request): void { 76 | req.session.status = null 77 | req.session.error = null 78 | req.session.save((err) => { 79 | if (err) { 80 | throw err 81 | } 82 | }) 83 | } 84 | 85 | /** 86 | * called when a GET request to / is done 87 | * renders the index page 88 | * @param req the request data 89 | * @param res the response data 90 | */ 91 | private static async OnGetIndex( 92 | req: express.Request, 93 | res: express.Response 94 | ): Promise { 95 | if (req.session.userId != null) { 96 | return res.redirect('/user') 97 | } 98 | 99 | const session = req.session as WebSession 100 | const numSessions: number = await UsersService.getSessions() 101 | 102 | res.render('index', { 103 | playersOnline: numSessions, 104 | mapImage: MapImageList.getRandomFile(), 105 | status: session.status, 106 | error: session.error 107 | }) 108 | 109 | WebController.cleanUpStatus(req) 110 | } 111 | 112 | /** 113 | * called when a GET request to /signup is done 114 | * renders the signup page 115 | * @param req the request data 116 | * @param res the response data 117 | */ 118 | private static OnGetSignup(req: express.Request, res: express.Response) { 119 | if (req.session.userId != null) { 120 | return res.redirect('/user') 121 | } 122 | 123 | const session = req.session as WebSession 124 | 125 | res.render('signup', { 126 | mapImage: MapImageList.getRandomFile(), 127 | status: session.status, 128 | error: session.error 129 | }) 130 | 131 | WebController.cleanUpStatus(req) 132 | } 133 | 134 | /** 135 | * called when a GET request to /login is done 136 | * renders the login page 137 | * @param req the request data 138 | * @param res the response data 139 | */ 140 | private static OnGetLogin(req: express.Request, res: express.Response) { 141 | if (req.session.userId != null) { 142 | return res.redirect('/user') 143 | } 144 | 145 | const session = req.session as WebSession 146 | 147 | res.render('login', { 148 | mapImage: MapImageList.getRandomFile(), 149 | status: session.status, 150 | error: session.error 151 | }) 152 | 153 | WebController.cleanUpStatus(req) 154 | } 155 | 156 | /** 157 | * called when a GET request to /logout is done 158 | * deletes the userId in session and redirects to the /login page 159 | * @param req the request data 160 | * @param res the response data 161 | */ 162 | private static OnGetLogout( 163 | req: express.Request, 164 | res: express.Response 165 | ): void { 166 | if (req.session.userId != null) { 167 | req.session.userId = null 168 | } 169 | 170 | req.session.status = 'Logged out succesfully.' 171 | 172 | req.session.save((err) => { 173 | if (err) { 174 | throw err 175 | } 176 | }) 177 | 178 | res.redirect('/user') 179 | } 180 | 181 | /** 182 | * called when a GET request to /user is done 183 | * renders the user's info page 184 | * @param req the request data 185 | * @param res the response data 186 | */ 187 | private static async OnGetUser( 188 | req: express.Request, 189 | res: express.Response 190 | ): Promise { 191 | if (req.session.userId == null) { 192 | return res.redirect('/login') 193 | } 194 | 195 | const session = req.session as WebSession 196 | const user: User = await UsersService.get(req.session.userId) 197 | 198 | res.render('user', { 199 | user, 200 | mapImage: MapImageList.getRandomFile(), 201 | status: session.status, 202 | error: session.error 203 | }) 204 | 205 | WebController.cleanUpStatus(req) 206 | } 207 | 208 | /** 209 | * called when a GET request to /user/delete is done 210 | * renders the user's info page 211 | * @param req the request data 212 | * @param res the response data 213 | */ 214 | private static async OnGetUserDelete( 215 | req: express.Request, 216 | res: express.Response 217 | ): Promise { 218 | if (req.session.userId == null) { 219 | return res.redirect('/login') 220 | } 221 | 222 | const session = req.session as WebSession 223 | const user: User = await UsersService.get(req.session.userId) 224 | 225 | res.render('delete', { 226 | user, 227 | mapImage: MapImageList.getRandomFile(), 228 | status: session.status, 229 | error: session.error 230 | }) 231 | 232 | WebController.cleanUpStatus(req) 233 | } 234 | 235 | /** 236 | * called when a POST request to /do_signup is done 237 | * creates a new user account 238 | * @param req the request data 239 | * @param res the response data 240 | */ 241 | private static async OnPostDoSignup( 242 | req: express.Request, 243 | res: express.Response 244 | ): Promise { 245 | if (req.session.userId != null) { 246 | return res.redirect('/user') 247 | } 248 | 249 | type signupBody = { 250 | username?: string 251 | playername?: string 252 | password?: string 253 | confirmed_password?: string 254 | } 255 | 256 | const typedBody = req.body as signupBody 257 | 258 | const userName: string = typedBody.username 259 | const playerName: string = typedBody.playername 260 | const password: string = typedBody.password 261 | const confirmedPassword: string = typedBody.confirmed_password 262 | 263 | if ( 264 | userName == null || 265 | playerName == null || 266 | password == null || 267 | confirmedPassword == null 268 | ) { 269 | return WebController.redirectWithError( 270 | 'A bad request was made.', 271 | '/signup', 272 | req, 273 | res 274 | ) 275 | } 276 | 277 | if (password !== confirmedPassword) { 278 | return WebController.redirectWithError( 279 | 'The passwords are not the same.', 280 | '/signup', 281 | req, 282 | res 283 | ) 284 | } 285 | 286 | try { 287 | const newUser: User = await UsersService.create( 288 | userName, 289 | playerName, 290 | password 291 | ) 292 | 293 | if (newUser == null) { 294 | WebController.redirectWithError( 295 | 'Invalid new user credentials', 296 | '/signup', 297 | req, 298 | res 299 | ) 300 | return 301 | } 302 | 303 | const results: boolean[] = await Promise.all([ 304 | UsersService.createInventory(newUser.id), 305 | UsersService.createCosmetics(newUser.id), 306 | UsersService.createLoadouts(newUser.id), 307 | UsersService.createBuymenu(newUser.id) 308 | ]) 309 | 310 | for (const r of results) { 311 | if (r === false) { 312 | WebController.redirectWithError( 313 | 'Internal error: could not create inventory for user', 314 | '/signup', 315 | req, 316 | res 317 | ) 318 | return 319 | } 320 | } 321 | 322 | req.session.userId = newUser.id 323 | req.session.save((err) => { 324 | if (err) { 325 | throw err 326 | } 327 | }) 328 | 329 | return res.redirect('/user') 330 | } catch (error) { 331 | if (error) { 332 | const typedError = error as { toString: () => string } 333 | const errorMessage: string = typedError.toString() 334 | LogInstance.error(errorMessage) 335 | WebController.redirectWithError( 336 | errorMessage, 337 | '/signup', 338 | req, 339 | res 340 | ) 341 | } 342 | } 343 | } 344 | 345 | /** 346 | * called when a POST request to /do_login is done 347 | * logs in to an user's account 348 | * @param req the request data 349 | * @param res the response data 350 | */ 351 | private static async OnPostDoLogin( 352 | req: express.Request, 353 | res: express.Response 354 | ): Promise { 355 | if (req.session.userId != null) { 356 | return res.redirect('/user') 357 | } 358 | 359 | type loginBody = { 360 | username?: string 361 | password?: string 362 | } 363 | 364 | const typedBody = req.body as loginBody 365 | 366 | const username: string = typedBody.username 367 | const password: string = typedBody.password 368 | 369 | if (username == null || password == null) { 370 | return WebController.redirectWithError( 371 | 'A bad request was made.', 372 | '/login', 373 | req, 374 | res 375 | ) 376 | } 377 | 378 | try { 379 | const authedUserId: number = await UsersService.validate( 380 | username, 381 | password 382 | ) 383 | 384 | if (authedUserId) { 385 | req.session.userId = authedUserId 386 | req.session.save((err) => { 387 | if (err) { 388 | throw err 389 | } 390 | }) 391 | 392 | return res.redirect('/user') 393 | } 394 | 395 | WebController.redirectWithError( 396 | 'Bad credentials', 397 | '/login', 398 | req, 399 | res 400 | ) 401 | } catch (error) { 402 | if (error) { 403 | let errorMessage: string = null 404 | 405 | const typedError = error as { 406 | status: number 407 | toString: () => string 408 | } 409 | 410 | if (typedError.status === 404) { 411 | errorMessage = 'User was not found' 412 | } else { 413 | errorMessage = typedError.toString() 414 | } 415 | 416 | LogInstance.error(errorMessage) 417 | WebController.redirectWithError( 418 | errorMessage, 419 | '/login', 420 | req, 421 | res 422 | ) 423 | } 424 | } 425 | } 426 | 427 | /** 428 | * called when a POST request to /do_delete is done 429 | * delete's an user's account 430 | * @param req the request data 431 | * @param res the response data 432 | */ 433 | private static async OnPostDoDelete( 434 | req: express.Request, 435 | res: express.Response 436 | ): Promise { 437 | const session = req.session as WebSession 438 | const typedBody = req.body as { confirmation: string } 439 | 440 | const targetUserId: number = session.userId 441 | 442 | if (targetUserId == null) { 443 | return res.redirect('/login') 444 | } 445 | 446 | const confirmation: string = typedBody.confirmation 447 | 448 | if (confirmation !== 'on') { 449 | return WebController.redirectWithError( 450 | 'The user did not tick the confirmation box', 451 | '/user/delete', 452 | req, 453 | res 454 | ) 455 | } 456 | 457 | try { 458 | const deleted: boolean = await UsersService.delete(targetUserId) 459 | 460 | if (deleted) { 461 | req.session.userId = null 462 | req.session.status = 'Account deleted successfully.' 463 | 464 | req.session.save((err) => { 465 | if (err) { 466 | throw err 467 | } 468 | }) 469 | 470 | return res.redirect('/login') 471 | } 472 | 473 | WebController.redirectWithError( 474 | 'Failed to delete account.', 475 | '/user', 476 | req, 477 | res 478 | ) 479 | } catch (error) { 480 | if (error) { 481 | const typedError = error as { toString: () => string } 482 | const errorMessage: string = typedError.toString() 483 | LogInstance.error(errorMessage) 484 | WebController.redirectWithError( 485 | errorMessage, 486 | '/signup', 487 | req, 488 | res 489 | ) 490 | } 491 | } 492 | } 493 | } 494 | --------------------------------------------------------------------------------