2026-03-30 13:32:55 +02:00
'use strict' ;
const path = require ( 'path' ) ;
const fs = require ( 'fs' ) ;
const http = require ( 'http' ) ;
const crypto = require ( 'crypto' ) ;
const express = require ( 'express' ) ;
const session = require ( 'express-session' ) ;
const bcrypt = require ( 'bcryptjs' ) ;
const helmet = require ( 'helmet' ) ;
const rateLimit = require ( 'express-rate-limit' ) ;
const WebSocket = require ( 'ws' ) ;
const Database = require ( 'better-sqlite3' ) ;
const PORT = process . env . PORT || 3012 ;
const DATA _DIR = path . join ( _ _dirname , 'data' ) ;
if ( ! fs . existsSync ( DATA _DIR ) ) fs . mkdirSync ( DATA _DIR , { recursive : true } ) ;
// ── Stratagems (mirrored in public/stratagems.js) ─────────────────────────────
2026-03-31 08:48:56 +02:00
const ICON = ( slug ) => '/icons/' + slug + '.svg' ;
2026-03-30 13:32:55 +02:00
const STRATAGEMS = [
2026-03-31 08:48:56 +02:00
// ── Patriotic Administration Center ──────────────────────────────────────
{ name : 'Reinforce' , category : 'Patriotic Administration Center' , icon : ICON ( 'reinforce' ) , sequence : [ 'up' , 'down' , 'right' , 'left' , 'up' ] } ,
{ name : 'Resupply' , category : 'Patriotic Administration Center' , icon : ICON ( 'resupply' ) , sequence : [ 'down' , 'down' , 'up' , 'right' ] } ,
{ name : 'SOS Beacon' , category : 'Patriotic Administration Center' , icon : ICON ( 'sos_beacon' ) , sequence : [ 'up' , 'down' , 'right' , 'up' ] } ,
{ name : 'Hellbomb' , category : 'Patriotic Administration Center' , icon : ICON ( 'hellbomb' ) , sequence : [ 'down' , 'up' , 'left' , 'down' , 'up' , 'right' , 'down' , 'up' ] } ,
{ name : 'SEAF Artillery' , category : 'Patriotic Administration Center' , icon : ICON ( 'seaf_artillery' ) , sequence : [ 'right' , 'up' , 'up' , 'down' ] } ,
{ name : 'Upload Data' , category : 'Patriotic Administration Center' , icon : ICON ( 'upload_data' ) , sequence : [ 'right' , 'right' , 'left' , 'up' , 'up' ] } ,
{ name : 'Eagle Rearm' , category : 'Patriotic Administration Center' , icon : ICON ( 'eagle_rearm' ) , sequence : [ 'up' , 'up' , 'left' , 'up' , 'right' ] } ,
{ name : 'Prospecting Drill' , category : 'Patriotic Administration Center' , icon : ICON ( 'prospecting_drill' ) , sequence : [ 'down' , 'down' , 'left' , 'right' , 'down' ] } ,
// ── Orbital Cannons ───────────────────────────────────────────────────────
{ name : 'Orbital Gatling Barrage' , category : 'Orbital Cannons' , icon : ICON ( 'orbital_gatling_barrage' ) , sequence : [ 'right' , 'down' , 'left' , 'up' , 'up' ] } ,
{ name : 'Orbital Airburst Strike' , category : 'Orbital Cannons' , icon : ICON ( 'orbital_airburst_strike' ) , sequence : [ 'right' , 'right' , 'right' ] } ,
{ name : 'Orbital 120MM HE Barrage' , category : 'Orbital Cannons' , icon : ICON ( 'orbital_120mm_he_barrage' ) , sequence : [ 'right' , 'right' , 'down' , 'left' , 'right' , 'down' ] } ,
{ name : 'Orbital 380MM HE Barrage' , category : 'Orbital Cannons' , icon : ICON ( 'orbital_380mm_he_barrage' ) , sequence : [ 'right' , 'down' , 'up' , 'up' , 'left' , 'down' , 'down' ] } ,
{ name : 'Orbital Walking Barrage' , category : 'Orbital Cannons' , icon : ICON ( 'orbital_walking_barrage' ) , sequence : [ 'right' , 'down' , 'right' , 'down' , 'right' , 'down' ] } ,
{ name : 'Orbital Laser' , category : 'Orbital Cannons' , icon : ICON ( 'orbital_laser' ) , sequence : [ 'right' , 'down' , 'up' , 'right' , 'down' ] } ,
{ name : 'Orbital Railcannon Strike' , category : 'Orbital Cannons' , icon : ICON ( 'orbital_railcannon_strike' ) , sequence : [ 'right' , 'up' , 'down' , 'down' , 'right' ] } ,
{ name : 'Orbital Precision Strike' , category : 'Orbital Cannons' , icon : ICON ( 'orbital_precision_strike' ) , sequence : [ 'right' , 'right' , 'up' ] } ,
{ name : 'Orbital Gas Strike' , category : 'Orbital Cannons' , icon : ICON ( 'orbital_gas_strike' ) , sequence : [ 'right' , 'right' , 'down' , 'right' ] } ,
{ name : 'Orbital EMS Strike' , category : 'Orbital Cannons' , icon : ICON ( 'orbital_ems_strike' ) , sequence : [ 'right' , 'right' , 'left' , 'down' ] } ,
{ name : 'Orbital Smoke Strike' , category : 'Orbital Cannons' , icon : ICON ( 'orbital_smoke_strike' ) , sequence : [ 'right' , 'right' , 'down' , 'up' ] } ,
{ name : 'Orbital Illumination Flare' , category : 'Orbital Cannons' , icon : ICON ( 'orbital_illumination_flare' ) , sequence : [ 'right' , 'right' , 'left' , 'left' ] } ,
// ── Hangar ────────────────────────────────────────────────────────────────
{ name : 'Eagle Strafing Run' , category : 'Hangar' , icon : ICON ( 'eagle_strafing_run' ) , sequence : [ 'up' , 'right' , 'right' ] } ,
{ name : 'Eagle Airstrike' , category : 'Hangar' , icon : ICON ( 'eagle_airstrike' ) , sequence : [ 'up' , 'right' , 'down' , 'right' ] } ,
{ name : 'Eagle Cluster Bomb' , category : 'Hangar' , icon : ICON ( 'eagle_cluster_bomb' ) , sequence : [ 'up' , 'right' , 'down' , 'down' , 'right' ] } ,
{ name : 'Eagle Napalm Airstrike' , category : 'Hangar' , icon : ICON ( 'eagle_napalm_airstrike' ) , sequence : [ 'up' , 'right' , 'down' , 'up' ] } ,
{ name : 'LIFT-850 Jump Pack' , category : 'Hangar' , icon : ICON ( 'lift_850_jump_pack' ) , sequence : [ 'down' , 'up' , 'up' , 'down' , 'up' ] } ,
{ name : 'Eagle Smoke Strike' , category : 'Hangar' , icon : ICON ( 'eagle_smoke_strike' ) , sequence : [ 'up' , 'right' , 'up' , 'down' ] } ,
{ name : 'Eagle 110MM Rocket Pods' , category : 'Hangar' , icon : ICON ( 'eagle_110mm_rocket_pods' ) , sequence : [ 'up' , 'right' , 'up' , 'left' ] } ,
{ name : 'Eagle 500KG Bomb' , category : 'Hangar' , icon : ICON ( 'eagle_500kg_bomb' ) , sequence : [ 'up' , 'right' , 'down' , 'down' , 'down' ] } ,
// ── Bridge ────────────────────────────────────────────────────────────────
{ name : 'Patriot Exosuit' , category : 'Bridge' , icon : ICON ( 'patriot_exosuit' ) , sequence : [ 'left' , 'down' , 'right' , 'up' , 'left' , 'down' , 'right' ] } ,
{ name : 'Emancipator Exosuit' , category : 'Bridge' , icon : ICON ( 'emancipator_exosuit' ) , sequence : [ 'left' , 'down' , 'right' , 'up' , 'left' , 'down' , 'down' ] } ,
{ name : 'Tesla Tower' , category : 'Bridge' , icon : ICON ( 'tesla_tower' ) , sequence : [ 'down' , 'up' , 'right' , 'up' , 'left' , 'up' , 'up' ] } ,
{ name : 'Shield Generator Relay' , category : 'Bridge' , icon : ICON ( 'shield_generator_relay' ) , sequence : [ 'down' , 'up' , 'left' , 'right' , 'left' , 'down' ] } ,
// ── Engineering Bay – Support Weapons ────────────────────────────────────
{ name : 'Machine Gun' , category : 'Engineering Bay' , icon : ICON ( 'machine_gun' ) , sequence : [ 'down' , 'left' , 'down' , 'up' , 'right' ] } ,
{ name : 'Anti-Materiel Rifle' , category : 'Engineering Bay' , icon : ICON ( 'anti_materiel_rifle' ) , sequence : [ 'down' , 'left' , 'right' , 'up' , 'down' ] } ,
{ name : 'Stalwart' , category : 'Engineering Bay' , icon : ICON ( 'stalwart' ) , sequence : [ 'down' , 'left' , 'down' , 'up' , 'up' , 'left' ] } ,
{ name : 'Expendable Anti-Tank' , category : 'Engineering Bay' , icon : ICON ( 'expendable_anti_tank' ) , sequence : [ 'down' , 'down' , 'left' , 'up' ] } ,
{ name : 'Recoilless Rifle' , category : 'Engineering Bay' , icon : ICON ( 'recoilless_rifle' ) , sequence : [ 'down' , 'left' , 'right' , 'right' , 'left' ] } ,
{ name : 'Flamethrower' , category : 'Engineering Bay' , icon : ICON ( 'flamethrower' ) , sequence : [ 'down' , 'left' , 'up' , 'down' , 'up' ] } ,
{ name : 'Autocannon' , category : 'Engineering Bay' , icon : ICON ( 'autocannon' ) , sequence : [ 'down' , 'left' , 'down' , 'up' , 'up' , 'right' ] } ,
{ name : 'Heavy Machine Gun' , category : 'Engineering Bay' , icon : ICON ( 'heavy_machine_gun' ) , sequence : [ 'down' , 'left' , 'up' , 'down' , 'down' ] } ,
{ name : 'Airburst Rocket Launcher' , category : 'Engineering Bay' , icon : ICON ( 'airburst_rocket_launcher' ) , sequence : [ 'down' , 'up' , 'up' , 'left' , 'right' ] } ,
{ name : 'Commando' , category : 'Engineering Bay' , icon : ICON ( 'commando' ) , sequence : [ 'down' , 'left' , 'up' , 'down' , 'right' ] } ,
{ name : 'Railgun' , category : 'Engineering Bay' , icon : ICON ( 'railgun' ) , sequence : [ 'down' , 'right' , 'down' , 'up' , 'left' , 'right' ] } ,
{ name : 'Spear' , category : 'Engineering Bay' , icon : ICON ( 'spear' ) , sequence : [ 'down' , 'down' , 'up' , 'down' , 'down' ] } ,
{ name : 'Quasar Cannon' , category : 'Engineering Bay' , icon : ICON ( 'quasar_cannon' ) , sequence : [ 'down' , 'down' , 'up' , 'left' , 'right' ] } ,
{ name : 'Arc Thrower' , category : 'Engineering Bay' , icon : ICON ( 'arc_thrower' ) , sequence : [ 'down' , 'right' , 'down' , 'up' , 'left' , 'left' ] } ,
{ name : 'Laser Cannon' , category : 'Engineering Bay' , icon : ICON ( 'laser_cannon' ) , sequence : [ 'down' , 'left' , 'down' , 'up' , 'left' ] } ,
{ name : 'Grenade Launcher' , category : 'Engineering Bay' , icon : ICON ( 'grenade_launcher' ) , sequence : [ 'down' , 'left' , 'up' , 'left' , 'down' ] } ,
// ── Engineering Bay – Equipment ───────────────────────────────────────────
{ name : 'Supply Pack' , category : 'Engineering Bay' , icon : ICON ( 'supply_pack' ) , sequence : [ 'down' , 'left' , 'down' , 'up' , 'up' , 'down' ] } ,
{ name : 'Guard Dog Rover' , category : 'Engineering Bay' , icon : ICON ( 'guard_dog_rover' ) , sequence : [ 'down' , 'up' , 'left' , 'up' , 'right' , 'right' ] } ,
{ name : 'Guard Dog' , category : 'Engineering Bay' , icon : ICON ( 'guard_dog' ) , sequence : [ 'down' , 'up' , 'left' , 'up' , 'right' , 'down' ] } ,
{ name : 'Ballistic Shield Backpack' , category : 'Engineering Bay' , icon : ICON ( 'ballistic_shield_backpack' ) , sequence : [ 'down' , 'left' , 'down' , 'down' , 'up' , 'left' ] } ,
{ name : 'Shield Generator Pack' , category : 'Engineering Bay' , icon : ICON ( 'shield_generator_pack' ) , sequence : [ 'down' , 'up' , 'left' , 'right' , 'left' , 'right' ] } ,
{ name : 'Directional Shield' , category : 'Engineering Bay' , icon : ICON ( 'directional_shield' ) , sequence : [ 'down' , 'left' , 'up' , 'up' , 'right' ] } ,
// ── Engineering Bay – Mines ───────────────────────────────────────────────
{ name : 'Anti-Personnel Minefield' , category : 'Engineering Bay' , icon : ICON ( 'anti_personnel_minefield' ) , sequence : [ 'down' , 'left' , 'up' , 'right' ] } ,
{ name : 'Incendiary Mines' , category : 'Engineering Bay' , icon : ICON ( 'incendiary_mines' ) , sequence : [ 'down' , 'left' , 'left' , 'down' ] } ,
{ name : 'Anti-Tank Mines' , category : 'Engineering Bay' , icon : ICON ( 'anti_tank_mines' ) , sequence : [ 'down' , 'down' , 'left' , 'left' ] } ,
// ── Robotics Workshop ─────────────────────────────────────────────────────
{ name : 'Machine Gun Sentry' , category : 'Robotics Workshop' , icon : ICON ( 'machine_gun_sentry' ) , sequence : [ 'down' , 'up' , 'right' , 'right' , 'up' ] } ,
{ name : 'Gatling Sentry' , category : 'Robotics Workshop' , icon : ICON ( 'gatling_sentry' ) , sequence : [ 'down' , 'up' , 'right' , 'left' ] } ,
{ name : 'Mortar Sentry' , category : 'Robotics Workshop' , icon : ICON ( 'mortar_sentry' ) , sequence : [ 'down' , 'up' , 'right' , 'right' , 'down' ] } ,
{ name : 'Autocannon Sentry' , category : 'Robotics Workshop' , icon : ICON ( 'autocannon_sentry' ) , sequence : [ 'down' , 'up' , 'right' , 'up' , 'left' , 'up' ] } ,
{ name : 'Rocket Sentry' , category : 'Robotics Workshop' , icon : ICON ( 'rocket_sentry' ) , sequence : [ 'down' , 'up' , 'right' , 'right' , 'left' ] } ,
{ name : 'EMS Mortar Sentry' , category : 'Robotics Workshop' , icon : ICON ( 'ems_mortar_sentry' ) , sequence : [ 'down' , 'up' , 'right' , 'down' , 'right' ] } ,
// ── Defensive ─────────────────────────────────────────────────────────────
{ name : 'Anti-Tank Emplacement' , category : 'Defensive' , icon : ICON ( 'anti_tank_emplacement' ) , sequence : [ 'down' , 'right' , 'right' , 'up' , 'left' ] } ,
{ name : 'Orbital Shield Generator' , category : 'Defensive' , icon : null , sequence : [ 'right' , 'right' , 'left' , 'down' , 'left' , 'down' ] } ,
2026-03-30 13:32:55 +02:00
] ;
const VALID _NAMES = new Set ( STRATAGEMS . map ( s => s . name ) ) ;
// ── SQLite ────────────────────────────────────────────────────────────────────
const db = new Database ( path . join ( DATA _DIR , 'helldivers.db' ) ) ;
db . pragma ( 'journal_mode = WAL' ) ;
db . pragma ( 'foreign_keys = ON' ) ;
function initDB ( ) {
db . exec ( `
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
mustChange INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS practice_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
stratagem TEXT NOT NULL,
category TEXT NOT NULL,
time_ms INTEGER NOT NULL,
score INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
winner TEXT NOT NULL,
loser TEXT NOT NULL,
winner_rounds INTEGER NOT NULL,
loser_rounds INTEGER NOT NULL,
created_at TEXT NOT NULL
);
2026-03-30 18:31:46 +02:00
CREATE TABLE IF NOT EXISTS stratagem_stats (
username TEXT NOT NULL,
stratagem TEXT NOT NULL,
attempts INTEGER DEFAULT 0,
completions INTEGER DEFAULT 0,
best_time INTEGER DEFAULT NULL,
PRIMARY KEY (username, stratagem)
);
2026-03-30 13:32:55 +02:00
CREATE INDEX IF NOT EXISTS idx_ps_user ON practice_sessions(username);
CREATE INDEX IF NOT EXISTS idx_m_winner ON matches(winner);
CREATE INDEX IF NOT EXISTS idx_m_loser ON matches(loser);
` ) ;
2026-03-30 18:31:46 +02:00
// Schema migrations (user_version tracks applied version)
const version = db . pragma ( 'user_version' , { simple : true } ) ;
if ( version < 1 ) {
// Add elo column to users if missing
const cols = db . pragma ( 'table_info(users)' ) . map ( c => c . name ) ;
if ( ! cols . includes ( 'elo' ) ) {
db . exec ( 'ALTER TABLE users ADD COLUMN elo INTEGER DEFAULT 1000' ) ;
}
// Add mode column to practice_sessions if missing
const psCols = db . pragma ( 'table_info(practice_sessions)' ) . map ( c => c . name ) ;
if ( ! psCols . includes ( 'mode' ) ) {
db . exec ( "ALTER TABLE practice_sessions ADD COLUMN mode TEXT DEFAULT 'timed'" ) ;
}
db . pragma ( 'user_version = 1' ) ;
}
2026-03-30 13:32:55 +02:00
}
async function initUsers ( ) {
const defaults = [
{ username : 'admin' , role : 'admin' } ,
{ username : 'jeremy' , role : 'user' } ,
] ;
for ( const { username , role } of defaults ) {
const exists = db . prepare ( 'SELECT username FROM users WHERE username = ?' ) . get ( username ) ;
if ( ! exists ) {
const tempPw = crypto . randomBytes ( 6 ) . toString ( 'hex' ) ;
const hash = await bcrypt . hash ( tempPw , 12 ) ;
2026-03-30 18:31:46 +02:00
db . prepare ( 'INSERT INTO users (username, hash, role, mustChange, elo) VALUES (?, ?, ?, 1, 1000)' )
2026-03-30 13:32:55 +02:00
. run ( username , hash , role ) ;
console . log ( ` [INIT] Created user ' ${ username } ' – temp password: ${ tempPw } ` ) ;
}
}
}
2026-03-30 18:31:46 +02:00
// ── ELO calculation ───────────────────────────────────────────────────────────
function calcElo ( winnerElo , loserElo , k = 32 ) {
const expected = 1 / ( 1 + 10 * * ( ( loserElo - winnerElo ) / 400 ) ) ;
const delta = Math . round ( k * ( 1 - expected ) ) ;
return {
winner : Math . max ( 0 , winnerElo + delta ) ,
loser : Math . max ( 0 , loserElo - delta ) ,
delta ,
} ;
}
// ── ELO rank label ─────────────────────────────────────────────────────────────
function eloRank ( elo ) {
if ( elo >= 1700 ) return 'GENERAL' ;
if ( elo >= 1500 ) return 'CAPTAIN' ;
if ( elo >= 1300 ) return 'LIEUTENANT' ;
if ( elo >= 1100 ) return 'SERGEANT' ;
return 'PRIVATE' ;
}
2026-03-30 13:32:55 +02:00
// ── Session secret ────────────────────────────────────────────────────────────
function getSessionSecret ( ) {
const file = path . join ( DATA _DIR , '.session-secret' ) ;
if ( fs . existsSync ( file ) ) return fs . readFileSync ( file , 'utf8' ) . trim ( ) ;
const secret = crypto . randomBytes ( 32 ) . toString ( 'hex' ) ;
fs . writeFileSync ( file , secret , { mode : 0o600 } ) ;
return secret ;
}
// ── WebSocket shared state (module-level so routes can read userSockets) ─────
const userSockets = new Map ( ) ; // userId → ws
const pendingChallenges = new Map ( ) ; // challengerId → targetId
const rooms = new Map ( ) ; // roomId → roomState
// ── Express ───────────────────────────────────────────────────────────────────
const app = express ( ) ;
app . set ( 'trust proxy' , 1 ) ;
app . use ( helmet ( {
contentSecurityPolicy : {
directives : {
2026-03-30 13:39:28 +02:00
defaultSrc : [ "'self'" ] ,
scriptSrc : [ "'self'" ] ,
styleSrc : [ "'self'" , "'unsafe-inline'" , 'https://fonts.googleapis.com' ] ,
fontSrc : [ "'self'" , 'https://fonts.gstatic.com' ] ,
imgSrc : [ "'self'" , 'data:' ] ,
connectSrc : [ "'self'" , 'ws:' , 'wss:' ] ,
upgradeInsecureRequests : null , // Nginx handles HTTPS; this breaks HTTP on LAN/dev
2026-03-30 13:32:55 +02:00
} ,
} ,
} ) ) ;
const generalLimiter = rateLimit ( { windowMs : 60_000 , max : 300 , standardHeaders : true , legacyHeaders : false } ) ;
const loginLimiter = rateLimit ( { windowMs : 15 * 60_000 , max : 10 , message : { error : 'Too many login attempts, try again later' } } ) ;
app . use ( generalLimiter ) ;
app . use ( express . json ( { limit : '10kb' } ) ) ;
const sessionMiddleware = session ( {
secret : getSessionSecret ( ) ,
resave : false ,
saveUninitialized : false ,
cookie : {
secure : process . env . NODE _ENV === 'production' ,
httpOnly : true ,
sameSite : 'strict' ,
maxAge : 7 * 24 * 60 * 60 * 1000 ,
} ,
} ) ;
app . use ( sessionMiddleware ) ;
// ── Middleware ────────────────────────────────────────────────────────────────
function requireAuth ( req , res , next ) {
if ( ! req . session . user ) return res . status ( 401 ) . json ( { error : 'Not logged in' } ) ;
if ( req . session . mustChange ) {
const allowed = [ '/api/change-password' , '/api/logout' , '/api/me' ] ;
if ( ! allowed . includes ( req . path ) ) {
return res . status ( 403 ) . json ( { error : 'Password change required' , mustChange : true } ) ;
}
}
next ( ) ;
}
function requireAdmin ( req , res , next ) {
if ( req . session . role !== 'admin' ) return res . status ( 403 ) . json ( { error : 'Admin only' } ) ;
next ( ) ;
}
// ── Auth ──────────────────────────────────────────────────────────────────────
app . post ( '/api/login' , loginLimiter , async ( req , res ) => {
const { username , password } = req . body || { } ;
if ( ! username || ! password ) return res . status ( 400 ) . json ( { error : 'Username and password required' } ) ;
const user = db . prepare ( 'SELECT * FROM users WHERE username = ?' ) . get ( username ) ;
if ( ! user ) return res . status ( 401 ) . json ( { error : 'Invalid credentials' } ) ;
const valid = await bcrypt . compare ( password , user . hash ) ;
if ( ! valid ) return res . status ( 401 ) . json ( { error : 'Invalid credentials' } ) ;
req . session . regenerate ( ( err ) => {
if ( err ) return res . status ( 500 ) . json ( { error : 'Session error' } ) ;
req . session . user = user . username ;
req . session . role = user . role ;
req . session . mustChange = user . mustChange === 1 ;
res . json ( { ok : true , user : user . username , role : user . role , mustChange : user . mustChange === 1 } ) ;
} ) ;
} ) ;
app . post ( '/api/logout' , ( req , res ) => {
req . session . destroy ( ( ) => res . json ( { ok : true } ) ) ;
} ) ;
app . get ( '/api/me' , ( req , res ) => {
if ( ! req . session . user ) return res . json ( { user : null } ) ;
res . json ( { user : req . session . user , role : req . session . role , mustChange : ! ! req . session . mustChange } ) ;
} ) ;
app . post ( '/api/change-password' , requireAuth , async ( req , res ) => {
const { oldPassword , newPassword } = req . body || { } ;
if ( ! oldPassword || ! newPassword ) return res . status ( 400 ) . json ( { error : 'Both passwords required' } ) ;
if ( newPassword . length < 8 ) return res . status ( 400 ) . json ( { error : 'Password must be at least 8 characters' } ) ;
const user = db . prepare ( 'SELECT * FROM users WHERE username = ?' ) . get ( req . session . user ) ;
2026-03-30 14:07:36 +02:00
if ( ! user ) return res . status ( 404 ) . json ( { error : 'User not found' } ) ;
2026-03-30 13:32:55 +02:00
const valid = await bcrypt . compare ( oldPassword , user . hash ) ;
if ( ! valid ) return res . status ( 401 ) . json ( { error : 'Current password incorrect' } ) ;
const hash = await bcrypt . hash ( newPassword , 12 ) ;
db . prepare ( 'UPDATE users SET hash = ?, mustChange = 0 WHERE username = ?' ) . run ( hash , req . session . user ) ;
req . session . mustChange = false ;
res . json ( { ok : true } ) ;
} ) ;
// ── User management (admin) ───────────────────────────────────────────────────
app . get ( '/api/users' , requireAuth , requireAdmin , ( req , res ) => {
const users = db . prepare ( 'SELECT username, role, mustChange FROM users ORDER BY username' ) . all ( ) ;
res . json ( users . map ( u => ( { ... u , mustChange : u . mustChange === 1 } ) ) ) ;
} ) ;
app . post ( '/api/users' , requireAuth , requireAdmin , async ( req , res ) => {
const { username , role } = req . body || { } ;
if ( ! username || ! [ 'admin' , 'user' ] . includes ( role ) )
return res . status ( 400 ) . json ( { error : 'Valid username and role required' } ) ;
if ( ! /^[a-zA-Z0-9_-]{2,32}$/ . test ( username ) )
return res . status ( 400 ) . json ( { error : 'Username: 2-32 alphanumeric chars, _ or -' } ) ;
const exists = db . prepare ( 'SELECT username FROM users WHERE username = ?' ) . get ( username ) ;
if ( exists ) return res . status ( 409 ) . json ( { error : 'User already exists' } ) ;
const tempPw = crypto . randomBytes ( 6 ) . toString ( 'hex' ) ;
const hash = await bcrypt . hash ( tempPw , 12 ) ;
db . prepare ( 'INSERT INTO users (username, hash, role, mustChange) VALUES (?, ?, ?, 1)' ) . run ( username , hash , role ) ;
res . json ( { ok : true , tempPassword : tempPw } ) ;
} ) ;
app . delete ( '/api/users/:username' , requireAuth , requireAdmin , ( req , res ) => {
const { username } = req . params ;
if ( username === req . session . user ) return res . status ( 400 ) . json ( { error : 'Cannot delete yourself' } ) ;
const user = db . prepare ( 'SELECT * FROM users WHERE username = ?' ) . get ( username ) ;
if ( ! user ) return res . status ( 404 ) . json ( { error : 'User not found' } ) ;
if ( user . role === 'admin' ) {
const adminCount = db . prepare ( "SELECT COUNT(*) AS c FROM users WHERE role = 'admin'" ) . get ( ) . c ;
if ( adminCount <= 1 ) return res . status ( 400 ) . json ( { error : 'Cannot delete the last admin' } ) ;
}
db . prepare ( 'DELETE FROM users WHERE username = ?' ) . run ( username ) ;
res . json ( { ok : true } ) ;
} ) ;
// ── Leaderboard cache ─────────────────────────────────────────────────────────
let lbCache = null ;
let lbCacheTime = 0 ;
const LB _TTL = 30_000 ;
function invalidateLB ( ) { lbCache = null ; }
function getLeaderboard ( ) {
if ( lbCache && Date . now ( ) - lbCacheTime < LB _TTL ) return lbCache ;
lbCache = db . prepare ( `
SELECT
u.username,
2026-03-30 18:31:46 +02:00
COALESCE(u.elo, 1000) AS elo,
2026-03-30 13:32:55 +02:00
COALESCE(ps.sessions, 0) AS sessions,
COALESCE(ps.totalScore, 0) AS totalScore,
COALESCE(ps.fastestTime, 0) AS fastestTime,
COALESCE(mw.wins, 0) AS wins,
COALESCE(mw.wins, 0) + COALESCE(ml.losses, 0) AS matches
FROM users u
LEFT JOIN (
SELECT username, COUNT(*) AS sessions, SUM(score) AS totalScore, MIN(time_ms) AS fastestTime
FROM practice_sessions GROUP BY username
) ps ON ps.username = u.username
LEFT JOIN (SELECT winner AS username, COUNT(*) AS wins FROM matches GROUP BY winner) mw ON mw.username = u.username
LEFT JOIN (SELECT loser AS username, COUNT(*) AS losses FROM matches GROUP BY loser) ml ON ml.username = u.username
WHERE COALESCE(ps.sessions,0) > 0 OR COALESCE(mw.wins,0) > 0
ORDER BY totalScore DESC, fastestTime ASC
LIMIT 20
` ) . all ( ) ;
lbCacheTime = Date . now ( ) ;
return lbCache ;
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
app . get ( '/api/dashboard' , requireAuth , ( req , res ) => {
const u = req . session . user ;
const stats = db . prepare ( `
SELECT COUNT(*) AS sessions,
COALESCE(SUM(score),0) AS totalScore,
COALESCE(MAX(score),0) AS bestScore,
COALESCE(MIN(time_ms),0) AS fastestTime
FROM practice_sessions WHERE username = ?
` ) . get ( u ) ;
const matchStats = db . prepare ( `
SELECT COUNT(*) AS matches,
SUM(CASE WHEN winner = ? THEN 1 ELSE 0 END) AS wins
FROM matches WHERE winner = ? OR loser = ?
` ) . get ( u , u , u ) ;
2026-03-30 18:31:46 +02:00
const lb = getLeaderboard ( ) ;
2026-03-30 13:32:55 +02:00
const rankIdx = lb . findIndex ( r => r . username === u ) ;
2026-03-30 18:31:46 +02:00
const rank = rankIdx >= 0 ? { position : rankIdx + 1 } : null ;
2026-03-30 13:32:55 +02:00
const recent = db . prepare ( `
2026-03-30 18:31:46 +02:00
SELECT stratagem, category, score, time_ms, mode, created_at
2026-03-30 13:32:55 +02:00
FROM practice_sessions WHERE username = ?
ORDER BY created_at DESC LIMIT 5
` ) . all ( u ) ;
2026-03-30 18:31:46 +02:00
const dayOfYear = Math . floor ( ( new Date ( ) - new Date ( new Date ( ) . getFullYear ( ) , 0 , 0 ) ) / 86_400_000 ) ;
const dailyStrat = STRATAGEMS [ dayOfYear % STRATAGEMS . length ] ;
const dailyBest = db . prepare ( `
2026-03-30 13:32:55 +02:00
SELECT MIN(time_ms) AS bestTime FROM practice_sessions
WHERE stratagem = ? AND username = ?
` ) . get ( dailyStrat . name , u ) ;
2026-03-30 18:31:46 +02:00
const userRow = db . prepare ( 'SELECT elo FROM users WHERE username = ?' ) . get ( u ) ;
const elo = userRow ? . elo ? ? 1000 ;
2026-03-31 09:05:33 +02:00
const onlineNames = [ ... userSockets . keys ( ) ] ;
const eloRows = onlineNames . length > 0
? db . prepare ( ` SELECT username, elo FROM users WHERE username IN ( ${ onlineNames . map ( ( ) => '?' ) . join ( ',' ) } ) ` )
. all ( ... onlineNames )
: [ ] ;
const eloByUser = Object . fromEntries ( eloRows . map ( r => [ r . username , r . elo ] ) ) ;
const onlineWithElo = onlineNames . map ( name => {
const elo = eloByUser [ name ] ? ? 1000 ;
return { name , elo , rank : eloRank ( elo ) } ;
2026-03-30 18:31:46 +02:00
} ) ;
2026-03-30 13:32:55 +02:00
res . json ( {
stats : { ... stats , ... matchStats } ,
rank ,
2026-03-30 18:31:46 +02:00
elo ,
eloRank : eloRank ( elo ) ,
online : onlineWithElo ,
2026-03-30 13:32:55 +02:00
recent ,
daily : { stratagem : dailyStrat , bestTime : dailyBest ? . bestTime ? ? null } ,
} ) ;
} ) ;
// ── Scores ────────────────────────────────────────────────────────────────────
app . post ( '/api/scores/practice' , requireAuth , ( req , res ) => {
2026-03-30 18:31:46 +02:00
const { stratagem , category , time _ms , score , mode } = req . body || { } ;
if ( ! VALID _NAMES . has ( stratagem ) ) return res . status ( 400 ) . json ( { error : 'Invalid stratagem' } ) ;
if ( typeof time _ms !== 'number' || time _ms <= 0 || time _ms > 600_000 ) return res . status ( 400 ) . json ( { error : 'Invalid time' } ) ;
if ( typeof score !== 'number' || score < 0 || score > 50_000 ) return res . status ( 400 ) . json ( { error : 'Invalid score' } ) ;
2026-03-30 13:32:55 +02:00
2026-03-30 18:31:46 +02:00
const safeMode = [ 'timed' , 'endless' , 'drill' , 'speedrun' ] . includes ( mode ) ? mode : 'timed' ;
db . prepare ( `
INSERT INTO practice_sessions (username, stratagem, category, time_ms, score, mode, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
` ) . run ( req . session . user , stratagem , category || '' , time _ms , score , safeMode , new Date ( ) . toISOString ( ) ) ;
// Update stratagem_stats (upsert)
2026-03-30 13:32:55 +02:00
db . prepare ( `
2026-03-30 18:31:46 +02:00
INSERT INTO stratagem_stats (username, stratagem, attempts, completions, best_time)
VALUES (?, ?, 1, 1, ?)
ON CONFLICT(username, stratagem) DO UPDATE SET
attempts = attempts + 1,
completions = completions + 1,
best_time = CASE WHEN best_time IS NULL OR ? < best_time THEN ? ELSE best_time END
` ) . run ( req . session . user , stratagem , time _ms , time _ms , time _ms ) ;
2026-03-30 13:32:55 +02:00
invalidateLB ( ) ;
res . json ( { ok : true } ) ;
} ) ;
app . get ( '/api/scores/leaderboard' , requireAuth , ( req , res ) => {
res . json ( getLeaderboard ( ) ) ;
} ) ;
2026-03-30 18:31:46 +02:00
app . get ( '/api/scores/leaderboard/elo' , requireAuth , ( req , res ) => {
const rows = db . prepare ( `
SELECT username, elo, role FROM users ORDER BY elo DESC LIMIT 20
` ) . all ( ) ;
res . json ( rows . map ( r => ( { ... r , rank : eloRank ( r . elo ) } ) ) ) ;
} ) ;
app . get ( '/api/scores/leaderboard/speedrun' , requireAuth , ( req , res ) => {
// Speedrun: sum of all time_ms per user where mode = 'speedrun', order by total time ASC
const rows = db . prepare ( `
SELECT username, SUM(time_ms) AS totalTime, COUNT(*) AS stratagems
FROM practice_sessions WHERE mode = 'speedrun'
GROUP BY username ORDER BY totalTime ASC LIMIT 20
` ) . all ( ) ;
res . json ( rows ) ;
} ) ;
2026-03-30 13:32:55 +02:00
app . get ( '/api/scores/me' , requireAuth , ( req , res ) => {
const u = req . session . user ;
const practice = db . prepare ( `
2026-03-30 18:31:46 +02:00
SELECT stratagem, category, score, time_ms, mode, created_at
2026-03-30 13:32:55 +02:00
FROM practice_sessions WHERE username = ?
ORDER BY created_at DESC LIMIT 50
` ) . all ( u ) ;
const matches = db . prepare ( `
SELECT * FROM matches WHERE winner = ? OR loser = ?
ORDER BY created_at DESC LIMIT 20
` ) . all ( u , u ) ;
res . json ( { practice , matches } ) ;
} ) ;
2026-03-30 18:31:46 +02:00
// ── History (paginated) ────────────────────────────────────────────────────────
app . get ( '/api/history' , requireAuth , ( req , res ) => {
const u = req . session . user ;
const page = Math . max ( 1 , parseInt ( req . query . page ) || 1 ) ;
const limit = Math . min ( 50 , Math . max ( 1 , parseInt ( req . query . limit ) || 10 ) ) ;
const mode = req . query . mode || '' ;
const cat = req . query . cat || '' ;
const offset = ( page - 1 ) * limit ;
let where = 'WHERE username = ?' ;
const params = [ u ] ;
if ( mode ) { where += ' AND mode = ?' ; params . push ( mode ) ; }
if ( cat ) { where += ' AND category = ?' ; params . push ( cat ) ; }
const total = db . prepare ( ` SELECT COUNT(*) AS n FROM practice_sessions ${ where } ` ) . get ( ... params ) . n ;
const rows = db . prepare ( `
SELECT stratagem, category, score, time_ms, mode, created_at
FROM practice_sessions ${ where }
ORDER BY created_at DESC LIMIT ? OFFSET ?
` ) . all ( ... params , limit , offset ) ;
res . json ( { rows , total , page , pages : Math . ceil ( total / limit ) } ) ;
} ) ;
// ── Stratagem stats ────────────────────────────────────────────────────────────
app . get ( '/api/stats/stratagems' , requireAuth , ( req , res ) => {
const u = req . session . user ;
const rows = db . prepare ( `
SELECT stratagem, attempts, completions, best_time
FROM stratagem_stats WHERE username = ?
ORDER BY best_time ASC NULLS LAST
` ) . all ( u ) ;
res . json ( rows ) ;
} ) ;
2026-03-30 13:39:28 +02:00
// ── Stratagems API (authenticated) ────────────────────────────────────────────
// Stratagem sequences are served via API – not as a public static file.
app . get ( '/api/stratagems' , requireAuth , ( req , res ) => {
res . json ( STRATAGEMS ) ;
} ) ;
// ── Public static files (index.html, styles.css, app.js) ─────────────────────
2026-03-30 13:32:55 +02:00
app . use ( express . static ( path . join ( _ _dirname , 'public' ) , {
etag : false ,
setHeaders : ( res ) => res . setHeader ( 'Cache-Control' , 'no-store' ) ,
} ) ) ;
app . use ( ( req , res ) => res . sendFile ( path . join ( _ _dirname , 'public' , 'index.html' ) ) ) ;
// ── Boot ──────────────────────────────────────────────────────────────────────
async function main ( ) {
initDB ( ) ;
await initUsers ( ) ;
const server = app . listen ( PORT , ( ) => {
console . log ( ` [helldivers] listening on http://localhost: ${ PORT } ` ) ;
} ) ;
// ── WebSocket server ────────────────────────────────────────────────────────
const wss = new WebSocket . Server ( { server } ) ;
function send ( ws , type , payload ) {
if ( ws . readyState === WebSocket . OPEN ) ws . send ( JSON . stringify ( { type , payload } ) ) ;
}
function broadcastLobbyUpdate ( ) {
2026-03-31 09:05:33 +02:00
const onlineNames = [ ... userSockets . keys ( ) ] ;
const eloMap = onlineNames . length > 0
? Object . fromEntries (
db . prepare ( ` SELECT username, elo FROM users WHERE username IN ( ${ onlineNames . map ( ( ) => '?' ) . join ( ',' ) } ) ` )
. all ( ... onlineNames )
. map ( r => [ r . username , r . elo ] )
)
: { } ;
const online = onlineNames . map ( name => {
const elo = eloMap [ name ] ? ? 1000 ;
return { name , elo , rank : eloRank ( elo ) } ;
2026-03-30 18:31:46 +02:00
} ) ;
2026-03-30 13:32:55 +02:00
wss . clients . forEach ( ws => {
if ( ws . readyState !== WebSocket . OPEN || ! ws . userId ) return ;
const incoming = [ ... pendingChallenges . entries ( ) ]
. filter ( ( [ , target ] ) => target === ws . userId )
. map ( ( [ from ] ) => from ) ;
send ( ws , 'lobby-update' , { online , incoming } ) ;
} ) ;
}
function getRoomForUser ( userId ) {
for ( const room of rooms . values ( ) ) {
if ( room . players . some ( p => p . userId === userId ) ) return room ;
}
return null ;
}
function startRound ( room ) {
room . state = 'active' ;
room . current = STRATAGEMS [ Math . floor ( Math . random ( ) * STRATAGEMS . length ) ] ;
room . players . forEach ( p => { p . progress = 0 ; } ) ;
broadcastToRoom ( room , 'round-start' , { stratagem : room . current } ) ;
}
function resolveRound ( room , winnerId ) {
const loser = room . players . find ( p => p . userId !== winnerId ) ;
room . matchScores [ winnerId ] ++ ;
const matchScores = { ... room . matchScores } ;
2026-03-30 18:31:46 +02:00
// Track round history for post-match screen
if ( ! room . roundHistory ) room . roundHistory = [ ] ;
room . roundHistory . push ( { round : room . roundHistory . length + 1 , winner : winnerId } ) ;
2026-03-30 13:32:55 +02:00
broadcastToRoom ( room , 'round-complete' , { winner : winnerId , matchScores } ) ;
if ( room . matchScores [ winnerId ] >= 5 ) {
2026-03-30 18:31:46 +02:00
// Calculate ELO delta
const wRow = db . prepare ( 'SELECT elo FROM users WHERE username = ?' ) . get ( winnerId ) ;
const lRow = db . prepare ( 'SELECT elo FROM users WHERE username = ?' ) . get ( loser . userId ) ;
const eloResult = calcElo ( wRow ? . elo ? ? 1000 , lRow ? . elo ? ? 1000 ) ;
db . prepare ( 'UPDATE users SET elo = ? WHERE username = ?' ) . run ( eloResult . winner , winnerId ) ;
db . prepare ( 'UPDATE users SET elo = ? WHERE username = ?' ) . run ( eloResult . loser , loser . userId ) ;
2026-03-30 13:32:55 +02:00
db . prepare ( `
INSERT INTO matches (winner, loser, winner_rounds, loser_rounds, created_at)
VALUES (?, ?, ?, ?, ?)
` ) . run ( winnerId , loser . userId , room . matchScores [ winnerId ] , room . matchScores [ loser . userId ] , new Date ( ) . toISOString ( ) ) ;
2026-03-30 18:31:46 +02:00
2026-03-30 13:32:55 +02:00
invalidateLB ( ) ;
2026-03-30 18:31:46 +02:00
broadcastToRoom ( room , 'match-end' , {
winner : winnerId ,
matchScores ,
roundHistory : room . roundHistory ,
eloChanges : {
[ winnerId ] : { old : wRow ? . elo ? ? 1000 , new : eloResult . winner , delta : + eloResult . delta } ,
[ loser . userId ] : { old : lRow ? . elo ? ? 1000 , new : eloResult . loser , delta : - eloResult . delta } ,
} ,
} ) ;
2026-03-30 13:32:55 +02:00
rooms . delete ( room . roomId ) ;
} else {
room . state = 'waiting' ;
room . readyPlayers . clear ( ) ;
}
}
function broadcastToRoom ( room , type , payload ) {
room . players . forEach ( ( { userId } ) => {
const ws = userSockets . get ( userId ) ;
if ( ws ) send ( ws , type , payload ) ;
} ) ;
}
function handleMessage ( ws , type , payload ) {
const userId = ws . userId ;
switch ( type ) {
case 'challenge-user' : {
const { targetUser } = payload ;
if ( ! userSockets . has ( targetUser ) || targetUser === userId ) return ;
pendingChallenges . set ( userId , targetUser ) ;
2026-03-30 18:31:46 +02:00
const challengerElo = db . prepare ( 'SELECT elo FROM users WHERE username = ?' ) . get ( userId ) ? . elo ? ? 1000 ;
send ( userSockets . get ( targetUser ) , 'challenge-received' , { from : userId , elo : challengerElo } ) ;
2026-03-30 13:32:55 +02:00
broadcastLobbyUpdate ( ) ;
break ;
}
case 'accept-challenge' : {
const { challengerId } = payload ;
if ( ! userSockets . has ( challengerId ) || pendingChallenges . get ( challengerId ) !== userId ) return ;
pendingChallenges . delete ( challengerId ) ;
const roomId = crypto . randomUUID ( ) ;
const room = {
roomId ,
players : [ { userId : challengerId , progress : 0 } , { userId , progress : 0 } ] ,
state : 'waiting' ,
current : null ,
matchScores : { [ challengerId ] : 0 , [ userId ] : 0 } ,
readyPlayers : new Set ( ) ,
} ;
rooms . set ( roomId , room ) ;
send ( userSockets . get ( challengerId ) , 'room-joined' , { roomId , opponent : userId , matchScores : room . matchScores } ) ;
send ( ws , 'room-joined' , { roomId , opponent : challengerId , matchScores : room . matchScores } ) ;
broadcastLobbyUpdate ( ) ;
break ;
}
case 'decline-challenge' : {
const { challengerId } = payload ;
pendingChallenges . delete ( challengerId ) ;
const cWs = userSockets . get ( challengerId ) ;
if ( cWs ) send ( cWs , 'challenge-declined' , { by : userId } ) ;
broadcastLobbyUpdate ( ) ;
break ;
}
case 'player-ready' : {
const room = getRoomForUser ( userId ) ;
if ( ! room || room . state !== 'waiting' ) return ;
room . readyPlayers . add ( userId ) ;
if ( room . readyPlayers . size >= 2 ) startRound ( room ) ;
break ;
}
case 'input-arrow' : {
const room = getRoomForUser ( userId ) ;
if ( ! room || room . state !== 'active' ) return ;
const { direction } = payload ;
if ( ! [ 'up' , 'down' , 'left' , 'right' ] . includes ( direction ) ) return ;
const player = room . players . find ( p => p . userId === userId ) ;
const expected = room . current . sequence [ player . progress ] ;
if ( direction === expected ) {
player . progress ++ ;
broadcastToRoom ( room , 'input-result' , { userId , correct : true , progress : player . progress } ) ;
if ( player . progress === room . current . sequence . length ) {
room . state = 'round-resolving' ; // lock before any async work
resolveRound ( room , userId ) ;
}
} else {
player . progress = 0 ;
broadcastToRoom ( room , 'input-result' , { userId , correct : false , progress : 0 } ) ;
}
break ;
}
case 'leave-room' : {
const room = getRoomForUser ( userId ) ;
if ( ! room ) return ;
const opponent = room . players . find ( p => p . userId !== userId ) ;
if ( opponent ) {
const oWs = userSockets . get ( opponent . userId ) ;
if ( oWs ) send ( oWs , 'opponent-left' , { } ) ;
}
rooms . delete ( room . roomId ) ;
break ;
}
}
}
wss . on ( 'connection' , ( ws , req ) => {
// Re-use session middleware to authenticate the WS upgrade request
sessionMiddleware ( req , { } , ( ) => {
if ( ! req . session ? . user ) { ws . close ( 1008 , 'Unauthorized' ) ; return ; }
const userId = req . session . user ;
// Close any stale socket for this user
const stale = userSockets . get ( userId ) ;
if ( stale && stale !== ws ) stale . terminate ( ) ;
userSockets . set ( userId , ws ) ;
ws . userId = userId ;
ws . isAlive = true ;
broadcastLobbyUpdate ( ) ;
ws . on ( 'message' , ( raw ) => {
try {
const { type , payload } = JSON . parse ( raw . toString ( ) ) ;
handleMessage ( ws , type , payload || { } ) ;
} catch { /* ignore malformed */ }
} ) ;
ws . on ( 'pong' , ( ) => { ws . isAlive = true ; } ) ;
ws . on ( 'close' , ( ) => {
userSockets . delete ( userId ) ;
pendingChallenges . delete ( userId ) ;
// Notify opponent if in a room
const room = getRoomForUser ( userId ) ;
if ( room ) {
const opponent = room . players . find ( p => p . userId !== userId ) ;
if ( opponent ) {
const oWs = userSockets . get ( opponent . userId ) ;
if ( oWs ) send ( oWs , 'opponent-left' , { } ) ;
}
rooms . delete ( room . roomId ) ;
}
broadcastLobbyUpdate ( ) ;
} ) ;
} ) ;
} ) ;
// Heartbeat – terminates stale connections every 30s
const heartbeat = setInterval ( ( ) => {
wss . clients . forEach ( ws => {
if ( ! ws . isAlive ) { ws . terminate ( ) ; return ; }
ws . isAlive = false ;
ws . ping ( ) ;
} ) ;
} , 30_000 ) ;
wss . on ( 'close' , ( ) => clearInterval ( heartbeat ) ) ;
}
main ( ) . catch ( err => { console . error ( err ) ; process . exit ( 1 ) ; } ) ;
process . on ( 'SIGTERM' , ( ) => { db . close ( ) ; process . exit ( 0 ) ; } ) ;
process . on ( 'SIGINT' , ( ) => { db . close ( ) ; process . exit ( 0 ) ; } ) ;