uploaded
This commit is contained in:
9
server/admin.js
Normal file
9
server/admin.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const express = require("express");
|
||||
|
||||
const adminApp = express.Router();
|
||||
adminApp.get("/admin/health", (_req, res) => {
|
||||
res.json({ ok: true, admin: true });
|
||||
});
|
||||
|
||||
// Keep this as valid middleware/router so app.use(adminApp) works.
|
||||
module.exports = adminApp;
|
||||
92
server/config.js
Normal file
92
server/config.js
Normal file
@@ -0,0 +1,92 @@
|
||||
const TIERS = ["HT1", "LT1", "HT2", "LT2", "HT3", "LT3", "HT4", "LT4", "HT5", "LT5"];
|
||||
|
||||
const TIER_POINTS = {
|
||||
HT1: 60,
|
||||
LT1: 45,
|
||||
HT2: 30,
|
||||
LT2: 20,
|
||||
HT3: 10,
|
||||
LT3: 6,
|
||||
HT4: 4,
|
||||
LT4: 3,
|
||||
HT5: 2,
|
||||
LT5: 1,
|
||||
};
|
||||
|
||||
const GAMEMODES = [
|
||||
"vanilla",
|
||||
"mace",
|
||||
"axe",
|
||||
"sword",
|
||||
"smp",
|
||||
"diamondsmp",
|
||||
"uhc",
|
||||
"pot",
|
||||
"nethop",
|
||||
"cart",
|
||||
];
|
||||
|
||||
const GAMEMODE_LABELS = {
|
||||
vanilla: "Vanilla",
|
||||
mace: "Mace",
|
||||
axe: "Axe",
|
||||
sword: "Sword",
|
||||
smp: "SMP",
|
||||
diamondsmp: "Diamond SMP",
|
||||
uhc: "UHC",
|
||||
pot: "Pot",
|
||||
nethop: "Neth OP",
|
||||
cart: "Cart",
|
||||
};
|
||||
|
||||
const USERNAME_REGEX = /^[A-Za-z0-9_]{3,16}$/;
|
||||
const REGIONS = ["NA", "EU", "AS", "AU"];
|
||||
|
||||
function avatarUrl(username) {
|
||||
return `https://render.crafty.gg/3d/bust/${encodeURIComponent(username)}`;
|
||||
}
|
||||
|
||||
function isValidTier(tier) {
|
||||
return TIERS.includes(String(tier || "").toUpperCase());
|
||||
}
|
||||
|
||||
function isValidGamemode(gamemode) {
|
||||
return GAMEMODES.includes(String(gamemode || "").toLowerCase());
|
||||
}
|
||||
|
||||
function normalizeTier(tier) {
|
||||
return String(tier || "").toUpperCase();
|
||||
}
|
||||
|
||||
function normalizeGamemode(gamemode) {
|
||||
return String(gamemode || "").toLowerCase();
|
||||
}
|
||||
|
||||
function tierToPoints(tier) {
|
||||
return TIER_POINTS[tier] || 0;
|
||||
}
|
||||
|
||||
function isValidRegion(region) {
|
||||
return REGIONS.includes(String(region || "").toUpperCase());
|
||||
}
|
||||
|
||||
function normalizeRegion(region) {
|
||||
return String(region || "").toUpperCase();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TIERS,
|
||||
TIER_POINTS,
|
||||
GAMEMODES,
|
||||
GAMEMODE_LABELS,
|
||||
USERNAME_REGEX,
|
||||
REGIONS,
|
||||
avatarUrl,
|
||||
isValidTier,
|
||||
isValidGamemode,
|
||||
isValidRegion,
|
||||
normalizeTier,
|
||||
normalizeGamemode,
|
||||
normalizeRegion,
|
||||
tierToPoints,
|
||||
};
|
||||
126
server/db.js
Normal file
126
server/db.js
Normal file
@@ -0,0 +1,126 @@
|
||||
const mysql = require("mysql2/promise");
|
||||
const dotenv = require("dotenv");
|
||||
const path = require("path");
|
||||
|
||||
// Load .env from project root (two levels up from bot.js, one level up from this file)
|
||||
dotenv.config({ path: path.join(__dirname, "..", ".env") });
|
||||
|
||||
console.log({
|
||||
user: process.env.DB_USER,
|
||||
db: process.env.DB_NAME,
|
||||
envPath: path.join(__dirname, "..", ".env")
|
||||
});
|
||||
|
||||
const dbConfig = {
|
||||
host: process.env.DB_HOST || "127.0.0.1",
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
user: process.env.DB_USER || "root",
|
||||
password: process.env.DB_PASSWORD || "",
|
||||
};
|
||||
|
||||
const dbName = process.env.DB_NAME || "eaglertiers";
|
||||
|
||||
const pool = mysql.createPool({
|
||||
...dbConfig,
|
||||
database: dbName,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
// Test the database connection immediately
|
||||
(async () => {
|
||||
try {
|
||||
const conn = await pool.getConnection();
|
||||
console.log("✅ Database connected successfully");
|
||||
conn.release();
|
||||
} catch (err) {
|
||||
console.error("❌ Database connection failed:", err.message);
|
||||
// You may want to exit the process if the database is critical
|
||||
// process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
async function ensureDatabase() {
|
||||
const adminPool = mysql.createPool({
|
||||
...dbConfig,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 2,
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
const schemaPool = mysql.createPool({
|
||||
...dbConfig,
|
||||
database: dbName,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 2,
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
await adminPool.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\``);
|
||||
|
||||
await schemaPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS players (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(32) NOT NULL UNIQUE,
|
||||
region ENUM('NA','EU','AS','AU') NOT NULL DEFAULT 'NA',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
try {
|
||||
await schemaPool.query(
|
||||
"ALTER TABLE players ADD COLUMN IF NOT EXISTS region ENUM('NA','EU','AS','AU') NOT NULL DEFAULT 'NA' AFTER username"
|
||||
);
|
||||
} catch (error) {
|
||||
if (!String(error?.message || "").includes("Duplicate column name")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await schemaPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS player_ranks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
player_id INT NOT NULL,
|
||||
gamemode VARCHAR(32) NOT NULL,
|
||||
tier ENUM('HT1','LT1','HT2','LT2','HT3','LT3','HT4','LT4','HT5','LT5') NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_player_gamemode (player_id, gamemode),
|
||||
CONSTRAINT fk_player_ranks_player
|
||||
FOREIGN KEY (player_id)
|
||||
REFERENCES players(id)
|
||||
ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
try {
|
||||
await schemaPool.query("CREATE INDEX idx_player_ranks_gamemode ON player_ranks (gamemode)");
|
||||
} catch (error) {
|
||||
if (!String(error?.message || "").includes("Duplicate key name")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await schemaPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS queue_test_tickets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
discord_user_id VARCHAR(32) NOT NULL,
|
||||
tester_discord_id VARCHAR(32) NOT NULL,
|
||||
gamemode VARCHAR(32) NOT NULL,
|
||||
ticket_channel_id VARCHAR(32) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_queue_tickets_user_mode_time (discord_user_id, gamemode, created_at)
|
||||
)
|
||||
`);
|
||||
|
||||
} finally {
|
||||
await schemaPool.end();
|
||||
await adminPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
pool,
|
||||
ensureDatabase,
|
||||
};
|
||||
24
server/discord/.env
Normal file
24
server/discord/.env
Normal file
@@ -0,0 +1,24 @@
|
||||
PORT=3000
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=eaglertiers
|
||||
DB_PASSWORD=eagler_local_dev_2026
|
||||
DB_NAME=eaglertiers
|
||||
|
||||
DISCORD_BOT_TOKEN=MTQ0MzA0NTM1MTAwMTk0ODE4MQ.G6qDT-.V-Vlao1Qurq8J1LxWEKcTcBSDRYKIMX9zEsEto
|
||||
DISCORD_CLIENT_ID=1443045351001948181
|
||||
DISCORD_GUILD_ID=1348443851366334505
|
||||
|
||||
TRUST_PROXY=false
|
||||
ENFORCE_HTTPS=false
|
||||
CORS_ALLOWED_ORIGINS=https://eaglertiers.com/
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
RATE_LIMIT_IP_MAX=60
|
||||
RATE_LIMIT_USER_MAX=60
|
||||
API_WRITE_KEYS=
|
||||
|
||||
ADMIN_API_KEY=pUhv9WZhbKQogLzHE639/LRF85yiTucfxQdPgGjjLJE=
|
||||
JWWT_SECRET=d3hr1fFezrcziGUAcbkLNdYtA64L/67H4ltk6c/FxGI=
|
||||
BCRYPT_ROUNDS=10
|
||||
1473
server/discord/bot.js
Normal file
1473
server/discord/bot.js
Normal file
File diff suppressed because it is too large
Load Diff
52
server/discord/queue-config.json
Normal file
52
server/discord/queue-config.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"vanilla": {
|
||||
"queueChannelId": "1443019065198379039",
|
||||
"ticketCategoryId": "1443024291234058330",
|
||||
"pingRoleId": "1444589909917831241"
|
||||
},
|
||||
"mace": {
|
||||
"queueChannelId": "1443019121888591982",
|
||||
"ticketCategoryId": "1443025126521045203",
|
||||
"pingRoleId": "1444590006424567889"
|
||||
},
|
||||
"axe": {
|
||||
"queueChannelId": "1443019206856675358",
|
||||
"ticketCategoryId": "1443025101917262056",
|
||||
"pingRoleId": "1444590299820064829"
|
||||
},
|
||||
"sword": {
|
||||
"queueChannelId": "1443019181489655948",
|
||||
"ticketCategoryId": "1443025062109380741",
|
||||
"pingRoleId": "1444590318757482627"
|
||||
},
|
||||
"smp": {
|
||||
"queueChannelId": "1443019367666155530",
|
||||
"ticketCategoryId": "1443024746978607358",
|
||||
"pingRoleId": "1444590035612602572"
|
||||
},
|
||||
"diamondsmp": {
|
||||
"queueChannelId": "1443019153043886140",
|
||||
"ticketCategoryId": "1443036836900634699",
|
||||
"pingRoleId": "1444590033280696401"
|
||||
},
|
||||
"uhc": {
|
||||
"queueChannelId": "1443019252813529128",
|
||||
"ticketCategoryId": "1443024358112100454",
|
||||
"pingRoleId": "1444590084006613134"
|
||||
},
|
||||
"pot": {
|
||||
"queueChannelId": "1443019296010670200",
|
||||
"ticketCategoryId": "1443024399044186123",
|
||||
"pingRoleId": "1444590142017769583"
|
||||
},
|
||||
"nethop": {
|
||||
"queueChannelId": "1443019232987316391",
|
||||
"ticketCategoryId": "1443024428840652891",
|
||||
"pingRoleId": "1444590101341540464"
|
||||
},
|
||||
"cart": {
|
||||
"queueChannelId": "1448948838521114635",
|
||||
"ticketCategoryId": "1448949942440824945",
|
||||
"pingRoleId": "1448946261834203188"
|
||||
}
|
||||
}
|
||||
122
server/discord/role-map.json
Normal file
122
server/discord/role-map.json
Normal file
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"vanilla": {
|
||||
"HT1": "1443099326636359690",
|
||||
"LT1": "1443108701912436870",
|
||||
"HT2": "1443102932555006013",
|
||||
"LT2": "1443107817530851348",
|
||||
"HT3": "1443102958911881256",
|
||||
"LT3": "1443106574368247870",
|
||||
"HT4": "1443103998050832384",
|
||||
"LT4": "1443105840369373184",
|
||||
"HT5": "1443104855106392105",
|
||||
"LT5": "1443105376521158688"
|
||||
},
|
||||
"mace": {
|
||||
"HT1": "1443099999167582223",
|
||||
"LT1": "1443108709440950422",
|
||||
"HT2": "1443102953186791454",
|
||||
"LT2": "1443107803295252502",
|
||||
"HT3": "1443103927678799914",
|
||||
"LT3": "1443106590654988505",
|
||||
"HT4": "1443104412485812347",
|
||||
"LT4": "1443105826075050076",
|
||||
"HT5": "1443104884126781480",
|
||||
"LT5": "1443105411459977236"
|
||||
},
|
||||
"axe": {
|
||||
"HT1": "1443099964719763527",
|
||||
"LT1": "1443108706127581235",
|
||||
"HT2": "1443102950397448192",
|
||||
"LT2": "1443107819581608007",
|
||||
"HT3": "1443103925720055859",
|
||||
"LT3": "1443106550246932540",
|
||||
"HT4": "1443104410065571880",
|
||||
"LT4": "1443105844953878681",
|
||||
"HT5": "1443104881056415774",
|
||||
"LT5": "1443105407307485195"
|
||||
},
|
||||
"sword": {
|
||||
"HT1": "1443099917575782411",
|
||||
"LT1": "1443108711219597312",
|
||||
"HT2": "1443102947402580112",
|
||||
"LT2": "1443107807783157811",
|
||||
"HT3": "1443103923337429032",
|
||||
"LT3": "1443106585298600027",
|
||||
"HT4": "1443104407591063562",
|
||||
"LT4": "1443105835554312282",
|
||||
"HT5": "1443104878242168873",
|
||||
"LT5": "1443105403641663749"
|
||||
},
|
||||
"smp": {
|
||||
"HT1": "1443099794938658929",
|
||||
"LT1": "1443108708199567360",
|
||||
"HT2": "1443102944235880635",
|
||||
"LT2": "1443107806302572654",
|
||||
"HT3": "1443103921022308373",
|
||||
"LT3": "1443106587098218567",
|
||||
"HT4": "1443104399898443826",
|
||||
"LT4": "1443105828709208197",
|
||||
"HT5": "1443104875645763667",
|
||||
"LT5": "1443105400806440960"
|
||||
},
|
||||
"diamondsmp": {
|
||||
"HT1": "1443100000274878495",
|
||||
"LT1": "1443023475592658954",
|
||||
"HT2": "1443102955732467815",
|
||||
"LT2": "1443107810366980187",
|
||||
"HT3": "1443103931117994026",
|
||||
"LT3": "1443106595004485632",
|
||||
"HT4": "1443104427652415508",
|
||||
"LT4": "1443105842479239230",
|
||||
"HT5": "1443104887859707904",
|
||||
"LT5": "1443105413678633044"
|
||||
},
|
||||
"uhc": {
|
||||
"HT1": "1443099402498605086",
|
||||
"LT1": "1443108713287258222",
|
||||
"HT2": "1443102936036282408",
|
||||
"LT2": "1443107813797920930",
|
||||
"HT3": "1443103911996162128",
|
||||
"LT3": "1443106593699921950",
|
||||
"HT4": "1443104391254245507",
|
||||
"LT4": "1443105831003619402",
|
||||
"HT5": "1443104858369687603",
|
||||
"LT5": "1443105388894617753"
|
||||
},
|
||||
"pot": {
|
||||
"HT1": "1443099487383064606",
|
||||
"LT1": "1443108703606935562",
|
||||
"HT2": "1443102938326106183",
|
||||
"LT2": "1443107840339349606",
|
||||
"HT3": "1443103916177883249",
|
||||
"LT3": "1443106589190914180",
|
||||
"HT4": "1443104394290659400",
|
||||
"LT4": "1443105837769035777",
|
||||
"HT5": "1443104860659519488",
|
||||
"LT5": "1443105393885708358"
|
||||
},
|
||||
"nethop": {
|
||||
"HT1": "1443099704132112384",
|
||||
"LT1": "1443108734909026415",
|
||||
"HT2": "1443102941144809484",
|
||||
"LT2": "1443107836514009098",
|
||||
"HT3": "1443103918685945877",
|
||||
"LT3": "1443106629011771475",
|
||||
"HT4": "1443104397004374066",
|
||||
"LT4": "1443105834694348914",
|
||||
"HT5": "1443104863541137530",
|
||||
"LT5": "1443105396704149584"
|
||||
},
|
||||
"cart": {
|
||||
"HT1": "1448946908822503444",
|
||||
"LT1": "1448946883048509520",
|
||||
"HT2": "1448946849191952406",
|
||||
"LT2": "1448946817264779479",
|
||||
"HT3": "1448946787921559653",
|
||||
"LT3": "1448946754073661522",
|
||||
"HT4": "1448946688743051388",
|
||||
"LT4": "1448946720376357015",
|
||||
"HT5": "1448946574137888908",
|
||||
"LT5": "1448946653263298652"
|
||||
}
|
||||
}
|
||||
1
server/discord/star.sh
Normal file
1
server/discord/star.sh
Normal file
@@ -0,0 +1 @@
|
||||
node bot.js
|
||||
186
server/install.sh
Normal file
186
server/install.sh
Normal file
@@ -0,0 +1,186 @@
|
||||
#!/bin/bash
|
||||
|
||||
# EaglerTiers System Services Installation Script
|
||||
# For Debian 13
|
||||
# This script sets up the API and Discord bot as systemd services
|
||||
|
||||
set -e
|
||||
|
||||
echo "╔════════════════════════════════════════════════════════════╗"
|
||||
echo "║ EaglerTiers - System Services Setup (Debian 13) ║"
|
||||
echo "╚════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# Check if running as root
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "✗ This script must be run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Update package manager
|
||||
echo "✓ Updating package manager..."
|
||||
apt-get update
|
||||
echo ""
|
||||
|
||||
# Check and install Node.js if needed
|
||||
echo "✓ Checking Node.js installation..."
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo " Node.js not found, installing from NodeSource repository..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y nodejs
|
||||
echo " Node.js installed"
|
||||
else
|
||||
NODE_VERSION=$(node -v)
|
||||
echo " Found Node.js: $NODE_VERSION"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check and install MariaDB if needed
|
||||
echo "✓ Checking MariaDB installation..."
|
||||
if ! command -v mysql &> /dev/null; then
|
||||
echo " MariaDB client not found, installing MariaDB server and client..."
|
||||
apt-get install -y mariadb-server mariadb-client
|
||||
systemctl start mariadb
|
||||
systemctl enable mariadb
|
||||
echo " MariaDB installed and started"
|
||||
else
|
||||
echo " Found MariaDB/MySQL"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Install other required dependencies
|
||||
echo "✓ Installing system dependencies..."
|
||||
apt-get install -y build-essential git
|
||||
echo ""
|
||||
|
||||
echo "✓ This script will:"
|
||||
echo " - Create/update systemd service files"
|
||||
echo " - Create the eaglertiers user (if needed)"
|
||||
echo " - Set up proper permissions"
|
||||
echo " - Enable auto-start on boot"
|
||||
echo ""
|
||||
read -p "Continue? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Create eaglertiers user if it doesn't exist
|
||||
echo "✓ Setting up eaglertiers user..."
|
||||
if ! id "eaglertiers" &>/dev/null; then
|
||||
useradd -r -s /bin/bash -d /opt/eaglertiers -m eaglertiers
|
||||
echo " Created eaglertiers user"
|
||||
else
|
||||
echo " eaglertiers user already exists"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Find project root (where package.json is located)
|
||||
echo "✓ Finding project directory..."
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
|
||||
# If script is in a subdirectory, go up to find package.json
|
||||
PROJECT_ROOT="$SCRIPT_DIR"
|
||||
if [ ! -f "$PROJECT_ROOT/package.json" ]; then
|
||||
if [ -f "$PROJECT_ROOT/../package.json" ]; then
|
||||
PROJECT_ROOT="$( cd "$PROJECT_ROOT/.." && pwd )"
|
||||
elif [ -f "$PROJECT_ROOT/../../package.json" ]; then
|
||||
PROJECT_ROOT="$( cd "$PROJECT_ROOT/../.." && pwd )"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "$PROJECT_ROOT/package.json" ]; then
|
||||
echo "✗ Could not find package.json in project root"
|
||||
echo " Please run this script from the eaglertiers project directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " Found project at: $PROJECT_ROOT"
|
||||
echo ""
|
||||
|
||||
# Copy project to /opt/eaglertiers if not already there
|
||||
echo "✓ Setting up project directory..."
|
||||
if [ ! -d "/opt/eaglertiers" ]; then
|
||||
mkdir -p /opt/eaglertiers
|
||||
echo " Created /opt/eaglertiers"
|
||||
fi
|
||||
|
||||
if [ "$PROJECT_ROOT" != "/opt/eaglertiers" ]; then
|
||||
echo " Copying project files to /opt/eaglertiers..."
|
||||
cp -r "$PROJECT_ROOT"/* /opt/eaglertiers/ 2>/dev/null || true
|
||||
cp -r "$PROJECT_ROOT"/.[^.]* /opt/eaglertiers/ 2>/dev/null || true
|
||||
chown -R eaglertiers:eaglertiers /opt/eaglertiers
|
||||
chmod 755 /opt/eaglertiers
|
||||
echo " Files copied and permissions set"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Install npm dependencies if needed
|
||||
echo "✓ Installing npm dependencies..."
|
||||
cd /opt/eaglertiers
|
||||
if [ ! -d "node_modules" ]; then
|
||||
sudo -u eaglertiers npm install --production
|
||||
echo " Dependencies installed"
|
||||
else
|
||||
echo " Dependencies already installed"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Seed database
|
||||
echo "✓ Database setup..."
|
||||
read -p "Seed database with sample data? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
sudo -u eaglertiers ALLOW_SEED=YES_WIPE_AND_SEED npm run seed
|
||||
echo " Database seeded"
|
||||
else
|
||||
echo " Skipping database seed"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Install systemd service files
|
||||
echo "✓ Installing systemd service files..."
|
||||
cp "$SCRIPT_DIR/eaglertiers-api.service" /etc/systemd/system/
|
||||
cp "$SCRIPT_DIR/eaglertiers-bot.service" /etc/systemd/system/
|
||||
chmod 644 /etc/systemd/system/eaglertiers-api.service
|
||||
chmod 644 /etc/systemd/system/eaglertiers-bot.service
|
||||
systemctl daemon-reload
|
||||
echo " Service files installed and loaded"
|
||||
echo ""
|
||||
|
||||
# Enable services
|
||||
echo "✓ Enabling services..."
|
||||
systemctl enable eaglertiers-api.service
|
||||
systemctl enable eaglertiers-bot.service
|
||||
echo " Services enabled (will start on boot)"
|
||||
echo ""
|
||||
|
||||
echo "╔════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Installation Complete! ║"
|
||||
echo "╚════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "Service Management Commands:"
|
||||
echo ""
|
||||
echo " API Server:"
|
||||
echo " sudo systemctl start eaglertiers-api"
|
||||
echo " sudo systemctl stop eaglertiers-api"
|
||||
echo " sudo systemctl restart eaglertiers-api"
|
||||
echo " sudo systemctl status eaglertiers-api"
|
||||
echo " sudo journalctl -u eaglertiers-api -f # View logs"
|
||||
echo ""
|
||||
echo " Discord Bot:"
|
||||
echo " sudo systemctl start eaglertiers-bot"
|
||||
echo " sudo systemctl stop eaglertiers-bot"
|
||||
echo " sudo systemctl restart eaglertiers-bot"
|
||||
echo " sudo systemctl status eaglertiers-bot"
|
||||
echo " sudo journalctl -u eaglertiers-bot -f # View logs"
|
||||
echo ""
|
||||
echo " Both Together:"
|
||||
echo " sudo systemctl restart eaglertiers-api eaglertiers-bot"
|
||||
echo " sudo systemctl stop eaglertiers-api eaglertiers-bot"
|
||||
echo " sudo systemctl status eaglertiers-api eaglertiers-bot"
|
||||
echo ""
|
||||
echo " View All Logs:"
|
||||
echo " sudo journalctl -u eaglertiers-api -u eaglertiers-bot -f"
|
||||
echo ""
|
||||
66
server/middleware/auth.js
Normal file
66
server/middleware/auth.js
Normal file
@@ -0,0 +1,66 @@
|
||||
function requireAdminAuth(req, res, next) {
|
||||
const adminKey = process.env.ADMIN_API_KEY;
|
||||
|
||||
if (!adminKey) {
|
||||
console.error('ADMIN_API_KEY is not configured');
|
||||
return res.status(500).json({
|
||||
error: 'Admin API key not configured',
|
||||
});
|
||||
}
|
||||
|
||||
// Get the API key from Authorization header or query param
|
||||
const authHeader = req.get('Authorization') || '';
|
||||
const queryKey = req.query.key;
|
||||
const providedKey = authHeader.replace(/^Bearer\s+/i, '') || queryKey;
|
||||
|
||||
if (!providedKey) {
|
||||
return res.status(401).json({
|
||||
error: 'Missing API key',
|
||||
message: 'Authorization header with Bearer token or ?key parameter is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Never expose the actual key in logs
|
||||
if (providedKey !== adminKey) {
|
||||
console.warn('Failed admin authentication attempt from IP:', req.ip);
|
||||
return res.status(403).json({
|
||||
error: 'Invalid API key',
|
||||
message: 'The provided API key is invalid',
|
||||
});
|
||||
}
|
||||
|
||||
// Mark request as authenticated
|
||||
req.isAdmin = true;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional admin auth middleware
|
||||
* Allows requests to proceed but marks if authenticated
|
||||
*/
|
||||
function optionalAdminAuth(req, res, next) {
|
||||
const adminKey = process.env.ADMIN_API_KEY;
|
||||
|
||||
if (!adminKey) {
|
||||
req.isAdmin = false;
|
||||
return next();
|
||||
}
|
||||
|
||||
const authHeader = req.get('Authorization') || '';
|
||||
const queryKey = req.query.key;
|
||||
const providedKey = authHeader.replace(/^Bearer\s+/i, '') || queryKey;
|
||||
|
||||
if (providedKey && providedKey === adminKey) {
|
||||
req.isAdmin = true;
|
||||
} else {
|
||||
req.isAdmin = false;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
requireAdminAuth,
|
||||
optionalAdminAuth,
|
||||
};
|
||||
|
||||
111
server/middleware/rate-limit.js
Normal file
111
server/middleware/rate-limit.js
Normal file
@@ -0,0 +1,111 @@
|
||||
const crypto = require("crypto");
|
||||
const { sendError } = require("../utils/http");
|
||||
|
||||
function normalizeIp(req) {
|
||||
return String(req.socket?.remoteAddress || req.ip || "unknown").trim();
|
||||
}
|
||||
|
||||
function findUserIdentifier(req) {
|
||||
const candidates = [
|
||||
req.params?.username,
|
||||
req.params?.gamemode,
|
||||
req.body?.username,
|
||||
req.body?.ingame_name,
|
||||
req.query?.q,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = String(candidate).trim().toLowerCase();
|
||||
if (normalized) {
|
||||
return normalized.slice(0, 64);
|
||||
}
|
||||
}
|
||||
|
||||
return "anonymous";
|
||||
}
|
||||
|
||||
function buildBucketKey(raw) {
|
||||
return crypto.createHash("sha256").update(String(raw)).digest("hex");
|
||||
}
|
||||
|
||||
function createRateLimiter({ name, windowMs, max, keyFn }) {
|
||||
const buckets = new Map();
|
||||
|
||||
function cleanup(now) {
|
||||
for (const [key, bucket] of buckets.entries()) {
|
||||
if (bucket.resetAt <= now) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return function rateLimitMiddleware(req, res, next) {
|
||||
const now = Date.now();
|
||||
const rawKey = keyFn(req);
|
||||
const key = buildBucketKey(`${name}:${rawKey}`);
|
||||
const current = buckets.get(key);
|
||||
|
||||
if (!current || current.resetAt <= now) {
|
||||
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||
|
||||
if (buckets.size > 5000) {
|
||||
cleanup(now);
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
if (current.count >= max) {
|
||||
const retryAfterSeconds = Math.max(Math.ceil((current.resetAt - now) / 1000), 1);
|
||||
res.setHeader("Retry-After", String(retryAfterSeconds));
|
||||
res.setHeader("X-RateLimit-Limit", String(max));
|
||||
res.setHeader("X-RateLimit-Remaining", "0");
|
||||
res.setHeader("X-RateLimit-Reset", String(Math.ceil(current.resetAt / 1000)));
|
||||
|
||||
return sendError(
|
||||
res,
|
||||
429,
|
||||
"RATE_LIMITED",
|
||||
"Too many requests. Please retry later.",
|
||||
{
|
||||
limiter: name,
|
||||
retryAfterSeconds,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
current.count += 1;
|
||||
const remaining = Math.max(max - current.count, 0);
|
||||
res.setHeader("X-RateLimit-Limit", String(max));
|
||||
res.setHeader("X-RateLimit-Remaining", String(remaining));
|
||||
res.setHeader("X-RateLimit-Reset", String(Math.ceil(current.resetAt / 1000)));
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
function createApiRateLimiters({ ipMax = 60, userMax = 60, windowMs = 60 * 1000 }) {
|
||||
const byIp = createRateLimiter({
|
||||
name: "ip",
|
||||
windowMs,
|
||||
max: ipMax,
|
||||
keyFn: (req) => normalizeIp(req),
|
||||
});
|
||||
|
||||
const byUser = createRateLimiter({
|
||||
name: "user",
|
||||
windowMs,
|
||||
max: userMax,
|
||||
keyFn: (req) => `${normalizeIp(req)}:${findUserIdentifier(req)}`,
|
||||
});
|
||||
|
||||
return [byIp, byUser];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createApiRateLimiters,
|
||||
};
|
||||
11
server/middleware/rateLimiter.js
Normal file
11
server/middleware/rateLimiter.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const { createApiRateLimiters } = require("./rate-limit");
|
||||
|
||||
function apiRateLimiter(options = {}) {
|
||||
const [byIp] = createApiRateLimiters(options);
|
||||
return byIp;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apiRateLimiter,
|
||||
createApiRateLimiters,
|
||||
};
|
||||
156
server/middleware/security.js
Normal file
156
server/middleware/security.js
Normal file
@@ -0,0 +1,156 @@
|
||||
const crypto = require("crypto");
|
||||
const cors = require("cors");
|
||||
const { sendError } = require("../utils/http");
|
||||
|
||||
const WRITE_KEY_REGEX = /^[A-Za-z0-9_-]{16,128}$/;
|
||||
let warnedAboutOpenWriteAccess = false;
|
||||
|
||||
function parseAllowedOrigins(value) {
|
||||
const legacy = process.env.FRONTEND_ORIGIN || "";
|
||||
const source = value || legacy;
|
||||
|
||||
if (!source) {
|
||||
return ["http://localhost:3000", "http://127.0.0.1:3000"];
|
||||
}
|
||||
|
||||
return source
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function createCorsMiddleware() {
|
||||
const allowedOrigins = new Set(parseAllowedOrigins(process.env.CORS_ALLOWED_ORIGINS));
|
||||
|
||||
return cors({
|
||||
origin(origin, callback) {
|
||||
// Allow same-origin/browser requests with no Origin header.
|
||||
if (!origin) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowedOrigins.has(origin)) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(new Error("CORS origin blocked"));
|
||||
},
|
||||
credentials: false,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "X-API-Key"],
|
||||
maxAge: 600,
|
||||
});
|
||||
}
|
||||
|
||||
function securityHeaders(req, res, next) {
|
||||
const trustProxy = String(process.env.TRUST_PROXY || "false").toLowerCase() === "true";
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
[
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"img-src 'self' data: https://render.crafty.gg",
|
||||
"font-src 'self' https://fonts.gstatic.com",
|
||||
"connect-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"upgrade-insecure-requests",
|
||||
].join("; ")
|
||||
);
|
||||
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("X-Frame-Options", "DENY");
|
||||
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||
res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
||||
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||
|
||||
if (req.secure || (trustProxy && String(req.get("x-forwarded-proto") || "").toLowerCase() === "https")) {
|
||||
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
function enforceHttps(req, res, next) {
|
||||
const enforce = String(process.env.ENFORCE_HTTPS || "false").toLowerCase() === "true";
|
||||
const trustProxy = String(process.env.TRUST_PROXY || "false").toLowerCase() === "true";
|
||||
|
||||
if (!enforce) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const isSecure =
|
||||
req.secure || (trustProxy && String(req.get("x-forwarded-proto") || "").toLowerCase() === "https");
|
||||
if (isSecure) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const host = req.get("host");
|
||||
if (!host) {
|
||||
return sendError(res, 400, "HTTPS_REQUIRED", "HTTPS is required.");
|
||||
}
|
||||
|
||||
return res.redirect(301, `https://${host}${req.originalUrl}`);
|
||||
}
|
||||
|
||||
function getWriteKeys() {
|
||||
const raw = process.env.API_WRITE_KEYS || process.env.API_WRITE_KEY || "";
|
||||
const keys = raw
|
||||
.split(",")
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean)
|
||||
.filter((key) => WRITE_KEY_REGEX.test(key));
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function safeEqual(a, b) {
|
||||
const left = Buffer.from(a);
|
||||
const right = Buffer.from(b);
|
||||
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return crypto.timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
function requireWriteApiKey(req, res, next) {
|
||||
const writeKeys = getWriteKeys();
|
||||
|
||||
if (writeKeys.length === 0) {
|
||||
if (!warnedAboutOpenWriteAccess) {
|
||||
warnedAboutOpenWriteAccess = true;
|
||||
console.warn("Write API key protection is disabled. Set API_WRITE_KEYS to enforce write authentication.");
|
||||
}
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = String(req.get("x-api-key") || "").trim();
|
||||
if (!WRITE_KEY_REGEX.test(apiKey)) {
|
||||
return sendError(res, 401, "AUTH_REQUIRED", "Missing or invalid API key.");
|
||||
}
|
||||
|
||||
const matched = writeKeys.some((key) => safeEqual(key, apiKey));
|
||||
if (!matched) {
|
||||
return sendError(res, 401, "AUTH_REQUIRED", "Missing or invalid API key.");
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createCorsMiddleware,
|
||||
securityHeaders,
|
||||
enforceHttps,
|
||||
requireWriteApiKey,
|
||||
};
|
||||
15
server/middleware/securityHeaders.js
Normal file
15
server/middleware/securityHeaders.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const { securityHeaders, enforceHttps, createCorsMiddleware, requireWriteApiKey } = require("./security");
|
||||
|
||||
function configureSecurityHeaders(app, _isDevelopment = false) {
|
||||
if (!app || typeof app.use !== "function") return;
|
||||
app.use(securityHeaders);
|
||||
app.use(enforceHttps);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configureSecurityHeaders,
|
||||
securityHeaders,
|
||||
enforceHttps,
|
||||
createCorsMiddleware,
|
||||
requireWriteApiKey,
|
||||
};
|
||||
137
server/middleware/validation.js
Normal file
137
server/middleware/validation.js
Normal file
@@ -0,0 +1,137 @@
|
||||
class ValidationError extends Error {
|
||||
constructor(message, details = []) {
|
||||
super(message);
|
||||
this.name = "ValidationError";
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeString(input, rule = {}) {
|
||||
let value = String(input);
|
||||
|
||||
if (rule.trim !== false) {
|
||||
value = value.trim();
|
||||
}
|
||||
|
||||
if (rule.toLowerCase) {
|
||||
value = value.toLowerCase();
|
||||
}
|
||||
|
||||
if (rule.toUpperCase) {
|
||||
value = value.toUpperCase();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateField(value, key, rule, errors) {
|
||||
if (value == null) {
|
||||
if (rule.required) {
|
||||
errors.push({ field: key, issue: "required" });
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (rule.type === "string") {
|
||||
const normalized = normalizeString(value, rule);
|
||||
|
||||
if (rule.minLength != null && normalized.length < rule.minLength) {
|
||||
errors.push({ field: key, issue: "minLength", expected: rule.minLength });
|
||||
}
|
||||
|
||||
if (rule.maxLength != null && normalized.length > rule.maxLength) {
|
||||
errors.push({ field: key, issue: "maxLength", expected: rule.maxLength });
|
||||
}
|
||||
|
||||
if (rule.pattern && !rule.pattern.test(normalized)) {
|
||||
errors.push({ field: key, issue: "pattern" });
|
||||
}
|
||||
|
||||
if (rule.enum && !rule.enum.includes(normalized)) {
|
||||
errors.push({ field: key, issue: "enum", expected: rule.enum });
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (rule.type === "int") {
|
||||
const asString = String(value).trim();
|
||||
if (!/^-?\d+$/.test(asString)) {
|
||||
errors.push({ field: key, issue: "type", expected: "int" });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(asString, 10);
|
||||
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
errors.push({ field: key, issue: "type", expected: "safe-int" });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (rule.min != null && parsed < rule.min) {
|
||||
errors.push({ field: key, issue: "min", expected: rule.min });
|
||||
}
|
||||
|
||||
if (rule.max != null && parsed > rule.max) {
|
||||
errors.push({ field: key, issue: "max", expected: rule.max });
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
errors.push({ field: key, issue: "unsupported-rule" });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validateObject(payload, schema, locationName) {
|
||||
const target = payload == null ? {} : payload;
|
||||
const errors = [];
|
||||
|
||||
if (!isObject(target)) {
|
||||
throw new ValidationError(`Invalid ${locationName}. Expected an object.`, [
|
||||
{ field: locationName, issue: "type", expected: "object" },
|
||||
]);
|
||||
}
|
||||
|
||||
const output = {};
|
||||
const fields = schema.fields || {};
|
||||
const allowUnknown = schema.allowUnknown === true;
|
||||
|
||||
if (!allowUnknown) {
|
||||
for (const key of Object.keys(target)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(fields, key)) {
|
||||
errors.push({ field: key, issue: "unknown" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, rule] of Object.entries(fields)) {
|
||||
const validated = validateField(target[key], key, rule, errors);
|
||||
if (validated !== undefined) {
|
||||
output[key] = validated;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new ValidationError(`Invalid ${locationName}.`, errors);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function validateRequest(req, spec = {}) {
|
||||
return {
|
||||
params: spec.params ? validateObject(req.params, spec.params, "path parameters") : {},
|
||||
query: spec.query ? validateObject(req.query, spec.query, "query parameters") : {},
|
||||
body: spec.body ? validateObject(req.body, spec.body, "request body") : {},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ValidationError,
|
||||
validateRequest,
|
||||
};
|
||||
7
server/routes/admin.js
Normal file
7
server/routes/admin.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({ ok: true, admin: true, message: "Admin route placeholder active" });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
604
server/routes/players.js
Normal file
604
server/routes/players.js
Normal file
@@ -0,0 +1,604 @@
|
||||
const express = require("express");
|
||||
const { pool } = require("../db");
|
||||
const {
|
||||
GAMEMODES,
|
||||
USERNAME_REGEX,
|
||||
REGIONS,
|
||||
avatarUrl,
|
||||
isValidTier,
|
||||
isValidGamemode,
|
||||
isValidRegion,
|
||||
normalizeGamemode,
|
||||
normalizeRegion,
|
||||
normalizeTier,
|
||||
tierToPoints,
|
||||
} = require("../config");
|
||||
const { ValidationError, validateRequest } = require("../middleware/validation");
|
||||
const { requireWriteApiKey } = require("../middleware/security");
|
||||
const { sendError } = require("../utils/http");
|
||||
|
||||
const router = express.Router();
|
||||
const PER_PAGE = 20;
|
||||
|
||||
const TIER_POINTS_SQL = `
|
||||
CASE pr.tier
|
||||
WHEN 'HT1' THEN 60
|
||||
WHEN 'LT1' THEN 45
|
||||
WHEN 'HT2' THEN 30
|
||||
WHEN 'LT2' THEN 20
|
||||
WHEN 'HT3' THEN 10
|
||||
WHEN 'LT3' THEN 6
|
||||
WHEN 'HT4' THEN 4
|
||||
WHEN 'LT4' THEN 3
|
||||
WHEN 'HT5' THEN 2
|
||||
WHEN 'LT5' THEN 1
|
||||
ELSE 0
|
||||
END`;
|
||||
|
||||
const schemas = {
|
||||
playersQuery: {
|
||||
query: {
|
||||
fields: {
|
||||
page: { type: "int", min: 1, max: 50000 },
|
||||
},
|
||||
allowUnknown: false,
|
||||
},
|
||||
},
|
||||
usernameParam: {
|
||||
params: {
|
||||
fields: {
|
||||
username: {
|
||||
type: "string",
|
||||
required: true,
|
||||
minLength: 3,
|
||||
maxLength: 16,
|
||||
pattern: USERNAME_REGEX,
|
||||
trim: true,
|
||||
},
|
||||
},
|
||||
allowUnknown: false,
|
||||
},
|
||||
},
|
||||
createPlayerBody: {
|
||||
body: {
|
||||
fields: {
|
||||
username: {
|
||||
type: "string",
|
||||
required: true,
|
||||
minLength: 3,
|
||||
maxLength: 16,
|
||||
pattern: USERNAME_REGEX,
|
||||
trim: true,
|
||||
},
|
||||
region: {
|
||||
type: "string",
|
||||
required: false,
|
||||
minLength: 2,
|
||||
maxLength: 2,
|
||||
toUpperCase: true,
|
||||
},
|
||||
},
|
||||
allowUnknown: false,
|
||||
},
|
||||
},
|
||||
updateRegionBody: {
|
||||
body: {
|
||||
fields: {
|
||||
region: {
|
||||
type: "string",
|
||||
required: true,
|
||||
minLength: 2,
|
||||
maxLength: 2,
|
||||
toUpperCase: true,
|
||||
},
|
||||
},
|
||||
allowUnknown: false,
|
||||
},
|
||||
},
|
||||
updateRankBody: {
|
||||
body: {
|
||||
fields: {
|
||||
gamemode: {
|
||||
type: "string",
|
||||
required: true,
|
||||
minLength: 3,
|
||||
maxLength: 32,
|
||||
toLowerCase: true,
|
||||
},
|
||||
tier: {
|
||||
type: "string",
|
||||
required: true,
|
||||
minLength: 3,
|
||||
maxLength: 3,
|
||||
toUpperCase: true,
|
||||
},
|
||||
region: {
|
||||
type: "string",
|
||||
required: false,
|
||||
minLength: 2,
|
||||
maxLength: 2,
|
||||
toUpperCase: true,
|
||||
},
|
||||
},
|
||||
allowUnknown: false,
|
||||
},
|
||||
},
|
||||
removeRankParams: {
|
||||
params: {
|
||||
fields: {
|
||||
username: {
|
||||
type: "string",
|
||||
required: true,
|
||||
minLength: 3,
|
||||
maxLength: 16,
|
||||
pattern: USERNAME_REGEX,
|
||||
trim: true,
|
||||
},
|
||||
gamemode: {
|
||||
type: "string",
|
||||
required: true,
|
||||
minLength: 3,
|
||||
maxLength: 32,
|
||||
toLowerCase: true,
|
||||
},
|
||||
},
|
||||
allowUnknown: false,
|
||||
},
|
||||
},
|
||||
searchQuery: {
|
||||
query: {
|
||||
fields: {
|
||||
q: {
|
||||
type: "string",
|
||||
required: false,
|
||||
minLength: 1,
|
||||
maxLength: 16,
|
||||
pattern: /^[A-Za-z0-9_]+$/,
|
||||
trim: true,
|
||||
},
|
||||
},
|
||||
allowUnknown: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function validateOrReply(req, res, spec) {
|
||||
try {
|
||||
return validateRequest(req, spec);
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
sendError(res, 400, "VALIDATION_ERROR", error.message, error.details);
|
||||
return null;
|
||||
}
|
||||
|
||||
sendError(res, 400, "VALIDATION_ERROR", "Invalid request payload.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function emptyTierMap() {
|
||||
return Object.fromEntries(GAMEMODES.map((mode) => [mode, null]));
|
||||
}
|
||||
|
||||
function hydratePlayersFromRows(playerRows, rankRows, startPosition = 0) {
|
||||
const byId = new Map();
|
||||
|
||||
for (let i = 0; i < playerRows.length; i += 1) {
|
||||
const row = playerRows[i];
|
||||
byId.set(row.id, {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
region: row.region,
|
||||
avatarUrl: avatarUrl(row.username),
|
||||
createdAt: row.created_at,
|
||||
tiers: emptyTierMap(),
|
||||
totalPoints: Number(row.total_points || 0),
|
||||
position: startPosition + i + 1,
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of rankRows) {
|
||||
const target = byId.get(row.player_id);
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
target.tiers[row.gamemode] = row.tier;
|
||||
}
|
||||
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
async function fetchRanksForPlayerIds(playerIds) {
|
||||
if (!playerIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [rankRows] = await pool.query(
|
||||
"SELECT player_id, gamemode, tier FROM player_ranks WHERE player_id IN (?)",
|
||||
[playerIds]
|
||||
);
|
||||
|
||||
return rankRows;
|
||||
}
|
||||
|
||||
router.get("/players", async (req, res) => {
|
||||
const validated = validateOrReply(req, res, schemas.playersQuery);
|
||||
if (!validated) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const page = validated.query.page || 1;
|
||||
|
||||
const [orderedPlayers] = await pool.query(
|
||||
`SELECT
|
||||
p.id,
|
||||
p.username,
|
||||
p.region,
|
||||
p.created_at,
|
||||
COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points
|
||||
FROM players p
|
||||
LEFT JOIN player_ranks pr ON pr.player_id = p.id
|
||||
GROUP BY p.id, p.username, p.region, p.created_at
|
||||
ORDER BY total_points DESC, p.username ASC`
|
||||
);
|
||||
|
||||
const total = orderedPlayers.length;
|
||||
const totalPages = Math.max(Math.ceil(total / PER_PAGE), 1);
|
||||
const start = (page - 1) * PER_PAGE;
|
||||
const pageRows = orderedPlayers.slice(start, start + PER_PAGE);
|
||||
const rankRows = await fetchRanksForPlayerIds(pageRows.map((row) => row.id));
|
||||
const data = hydratePlayersFromRows(pageRows, rankRows, start);
|
||||
|
||||
return res.json({
|
||||
data,
|
||||
pagination: {
|
||||
page,
|
||||
perPage: PER_PAGE,
|
||||
total,
|
||||
totalPages,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("GET /api/players failed:", error?.message || error);
|
||||
return sendError(res, 500, "PLAYERS_FETCH_FAILED", "Failed to fetch players");
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/players/all", async (req, res) => {
|
||||
try {
|
||||
const [orderedPlayers] = await pool.query(
|
||||
`SELECT
|
||||
p.id,
|
||||
p.username,
|
||||
p.region,
|
||||
p.created_at,
|
||||
COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points
|
||||
FROM players p
|
||||
LEFT JOIN player_ranks pr ON pr.player_id = p.id
|
||||
GROUP BY p.id, p.username, p.region, p.created_at
|
||||
ORDER BY total_points DESC, p.username ASC`
|
||||
);
|
||||
|
||||
const rankRows = await fetchRanksForPlayerIds(orderedPlayers.map((row) => row.id));
|
||||
const data = hydratePlayersFromRows(orderedPlayers, rankRows, 0);
|
||||
|
||||
return res.json({ data });
|
||||
} catch (error) {
|
||||
console.error("GET /api/players/all failed:", error?.message || error);
|
||||
return sendError(res, 500, "PLAYERS_FETCH_FAILED", "Failed to fetch all players");
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/players/:username", async (req, res) => {
|
||||
const validated = validateOrReply(req, res, schemas.usernameParam);
|
||||
if (!validated) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const username = validated.params.username;
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT
|
||||
p.id,
|
||||
p.username,
|
||||
p.region,
|
||||
p.created_at,
|
||||
COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points
|
||||
FROM players p
|
||||
LEFT JOIN player_ranks pr ON pr.player_id = p.id
|
||||
WHERE LOWER(p.username) = LOWER(?)
|
||||
GROUP BY p.id, p.username, p.region, p.created_at
|
||||
LIMIT 1`,
|
||||
[username]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return sendError(res, 404, "PLAYER_NOT_FOUND", "Player not found");
|
||||
}
|
||||
|
||||
const player = rows[0];
|
||||
const [rankRows] = await pool.query(
|
||||
"SELECT player_id, gamemode, tier FROM player_ranks WHERE player_id = ?",
|
||||
[player.id]
|
||||
);
|
||||
|
||||
const [positionRows] = await pool.query(
|
||||
`SELECT 1 + COUNT(*) AS position
|
||||
FROM (
|
||||
SELECT p.id, p.username, COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points
|
||||
FROM players p
|
||||
LEFT JOIN player_ranks pr ON pr.player_id = p.id
|
||||
GROUP BY p.id, p.username
|
||||
) ranked
|
||||
WHERE ranked.total_points > ?
|
||||
OR (ranked.total_points = ? AND ranked.username < ?)`,
|
||||
[player.total_points, player.total_points, player.username]
|
||||
);
|
||||
|
||||
const tiers = emptyTierMap();
|
||||
for (const row of rankRows) {
|
||||
tiers[row.gamemode] = row.tier;
|
||||
}
|
||||
|
||||
return res.json({
|
||||
id: player.id,
|
||||
username: player.username,
|
||||
region: player.region,
|
||||
avatarUrl: avatarUrl(player.username),
|
||||
createdAt: player.created_at,
|
||||
tiers,
|
||||
totalPoints: Number(player.total_points || 0),
|
||||
position: Number(positionRows[0]?.position || 1),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("GET /api/players/:username failed:", error?.message || error);
|
||||
return sendError(res, 500, "PLAYER_FETCH_FAILED", "Failed to fetch player profile");
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/player", requireWriteApiKey, async (req, res) => {
|
||||
const validated = validateOrReply(req, res, schemas.createPlayerBody);
|
||||
if (!validated) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const username = validated.body.username;
|
||||
const region = normalizeRegion(validated.body.region || "NA");
|
||||
|
||||
if (!isValidRegion(region)) {
|
||||
return sendError(res, 400, "VALIDATION_ERROR", `Invalid region. Use one of: ${REGIONS.join(", ")}`);
|
||||
}
|
||||
|
||||
const [insertResult] = await pool.query(
|
||||
"INSERT INTO players (username, region) VALUES (?, ?)",
|
||||
[username, region]
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
message: "Player created",
|
||||
id: insertResult.insertId,
|
||||
username,
|
||||
region,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error && error.code === "ER_DUP_ENTRY") {
|
||||
return sendError(res, 409, "USERNAME_EXISTS", "Username already exists");
|
||||
}
|
||||
|
||||
console.error("POST /api/player failed:", error?.message || error);
|
||||
return sendError(res, 500, "PLAYER_CREATE_FAILED", "Failed to create player");
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/player/:username/region", requireWriteApiKey, async (req, res) => {
|
||||
const paramValidated = validateOrReply(req, res, schemas.usernameParam);
|
||||
if (!paramValidated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bodyValidated = validateOrReply(req, res, schemas.updateRegionBody);
|
||||
if (!bodyValidated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const username = paramValidated.params.username;
|
||||
const region = normalizeRegion(bodyValidated.body.region);
|
||||
|
||||
if (!isValidRegion(region)) {
|
||||
return sendError(res, 400, "VALIDATION_ERROR", `Invalid region. Use one of: ${REGIONS.join(", ")}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const [result] = await pool.query(
|
||||
"UPDATE players SET region = ? WHERE LOWER(username) = LOWER(?)",
|
||||
[region, username]
|
||||
);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return sendError(res, 404, "PLAYER_NOT_FOUND", "Player not found");
|
||||
}
|
||||
|
||||
return res.json({ message: "Region updated", username, region });
|
||||
} catch (error) {
|
||||
console.error("PUT /api/player/:username/region failed:", error?.message || error);
|
||||
return sendError(res, 500, "REGION_UPDATE_FAILED", "Failed to update region");
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/player/:username", requireWriteApiKey, async (req, res) => {
|
||||
const validated = validateOrReply(req, res, schemas.usernameParam);
|
||||
if (!validated) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const username = validated.params.username;
|
||||
const [result] = await pool.query("DELETE FROM players WHERE LOWER(username) = LOWER(?)", [username]);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return sendError(res, 404, "PLAYER_NOT_FOUND", "Player not found");
|
||||
}
|
||||
|
||||
return res.json({ message: "Player removed", username });
|
||||
} catch (error) {
|
||||
console.error("DELETE /api/player/:username failed:", error?.message || error);
|
||||
return sendError(res, 500, "PLAYER_DELETE_FAILED", "Failed to remove player");
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/player/:username/rank", requireWriteApiKey, async (req, res) => {
|
||||
const paramValidated = validateOrReply(req, res, schemas.usernameParam);
|
||||
if (!paramValidated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bodyValidated = validateOrReply(req, res, schemas.updateRankBody);
|
||||
if (!bodyValidated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const username = paramValidated.params.username;
|
||||
const gamemode = normalizeGamemode(bodyValidated.body.gamemode);
|
||||
const tier = normalizeTier(bodyValidated.body.tier);
|
||||
const requestedRegion = bodyValidated.body.region;
|
||||
const region = requestedRegion == null ? null : normalizeRegion(requestedRegion);
|
||||
|
||||
if (!isValidGamemode(gamemode)) {
|
||||
return sendError(res, 400, "VALIDATION_ERROR", "Invalid gamemode");
|
||||
}
|
||||
|
||||
if (!isValidTier(tier)) {
|
||||
return sendError(res, 400, "VALIDATION_ERROR", "Invalid tier");
|
||||
}
|
||||
|
||||
if (region !== null && !isValidRegion(region)) {
|
||||
return sendError(res, 400, "VALIDATION_ERROR", `Invalid region. Use one of: ${REGIONS.join(", ")}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const [playerRows] = await pool.query(
|
||||
"SELECT id, username, region FROM players WHERE LOWER(username) = LOWER(?) LIMIT 1",
|
||||
[username]
|
||||
);
|
||||
|
||||
if (playerRows.length === 0) {
|
||||
return sendError(res, 404, "PLAYER_NOT_FOUND", "Player not found");
|
||||
}
|
||||
|
||||
const player = playerRows[0];
|
||||
|
||||
if (region !== null && player.region !== region) {
|
||||
await pool.query("UPDATE players SET region = ? WHERE id = ?", [region, player.id]);
|
||||
player.region = region;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO player_ranks (player_id, gamemode, tier)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE tier = VALUES(tier), updated_at = CURRENT_TIMESTAMP`,
|
||||
[player.id, gamemode, tier]
|
||||
);
|
||||
|
||||
return res.json({
|
||||
message: "Rank updated",
|
||||
username: player.username,
|
||||
region: player.region,
|
||||
gamemode,
|
||||
tier,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("PUT /api/player/:username/rank failed:", error?.message || error);
|
||||
return sendError(res, 500, "RANK_UPDATE_FAILED", "Failed to update rank");
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/player/:username/rank/:gamemode", requireWriteApiKey, async (req, res) => {
|
||||
const validated = validateOrReply(req, res, schemas.removeRankParams);
|
||||
if (!validated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const username = validated.params.username;
|
||||
const gamemode = normalizeGamemode(validated.params.gamemode);
|
||||
|
||||
if (!isValidGamemode(gamemode)) {
|
||||
return sendError(res, 400, "VALIDATION_ERROR", "Invalid gamemode");
|
||||
}
|
||||
|
||||
try {
|
||||
const [playerRows] = await pool.query(
|
||||
"SELECT id, username FROM players WHERE LOWER(username) = LOWER(?) LIMIT 1",
|
||||
[username]
|
||||
);
|
||||
|
||||
if (playerRows.length === 0) {
|
||||
return sendError(res, 404, "PLAYER_NOT_FOUND", "Player not found");
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
"DELETE FROM player_ranks WHERE player_id = ? AND gamemode = ?",
|
||||
[playerRows[0].id, gamemode]
|
||||
);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return sendError(res, 404, "RANK_NOT_FOUND", "No rank found for this gamemode");
|
||||
}
|
||||
|
||||
return res.json({ message: "Rank removed", username: playerRows[0].username, gamemode });
|
||||
} catch (error) {
|
||||
console.error("DELETE /api/player/:username/rank/:gamemode failed:", error?.message || error);
|
||||
return sendError(res, 500, "RANK_DELETE_FAILED", "Failed to remove rank");
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/search", async (req, res) => {
|
||||
const validated = validateOrReply(req, res, schemas.searchQuery);
|
||||
if (!validated) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const q = validated.query.q || "";
|
||||
|
||||
if (!q) {
|
||||
return res.json({ data: [] });
|
||||
}
|
||||
|
||||
const [matches] = await pool.query(
|
||||
`SELECT
|
||||
p.id,
|
||||
p.username,
|
||||
p.region,
|
||||
p.created_at,
|
||||
COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points
|
||||
FROM players p
|
||||
LEFT JOIN player_ranks pr ON pr.player_id = p.id
|
||||
WHERE p.username LIKE ?
|
||||
GROUP BY p.id, p.username, p.region, p.created_at
|
||||
ORDER BY total_points DESC, p.username ASC
|
||||
LIMIT 10`,
|
||||
[`%${q}%`]
|
||||
);
|
||||
|
||||
if (matches.length === 0) {
|
||||
return res.json({ data: [] });
|
||||
}
|
||||
|
||||
const rankRows = await fetchRanksForPlayerIds(matches.map((m) => m.id));
|
||||
const hydrated = hydratePlayersFromRows(matches, rankRows, 0).map((row) => {
|
||||
const next = { ...row };
|
||||
delete next.position;
|
||||
return next;
|
||||
});
|
||||
|
||||
return res.json({ data: hydrated });
|
||||
} catch (error) {
|
||||
console.error("GET /api/search failed:", error?.message || error);
|
||||
return sendError(res, 500, "SEARCH_FAILED", "Failed to search players");
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
176
server/routes/rankings.js
Normal file
176
server/routes/rankings.js
Normal file
@@ -0,0 +1,176 @@
|
||||
const express = require("express");
|
||||
const { pool } = require("../db");
|
||||
const { GAMEMODES, avatarUrl, normalizeGamemode } = require("../config");
|
||||
const { ValidationError, validateRequest } = require("../middleware/validation");
|
||||
const { sendError } = require("../utils/http");
|
||||
|
||||
const router = express.Router();
|
||||
const PER_PAGE = 20;
|
||||
|
||||
const TIER_POINTS_SQL = `
|
||||
CASE pr.tier
|
||||
WHEN 'HT1' THEN 60
|
||||
WHEN 'LT1' THEN 45
|
||||
WHEN 'HT2' THEN 30
|
||||
WHEN 'LT2' THEN 20
|
||||
WHEN 'HT3' THEN 10
|
||||
WHEN 'LT3' THEN 6
|
||||
WHEN 'HT4' THEN 4
|
||||
WHEN 'LT4' THEN 3
|
||||
WHEN 'HT5' THEN 2
|
||||
WHEN 'LT5' THEN 1
|
||||
ELSE 0
|
||||
END`;
|
||||
|
||||
const GM_POINTS_SQL = `
|
||||
CASE gm.tier
|
||||
WHEN 'HT1' THEN 60
|
||||
WHEN 'LT1' THEN 45
|
||||
WHEN 'HT2' THEN 30
|
||||
WHEN 'LT2' THEN 20
|
||||
WHEN 'HT3' THEN 10
|
||||
WHEN 'LT3' THEN 6
|
||||
WHEN 'HT4' THEN 4
|
||||
WHEN 'LT4' THEN 3
|
||||
WHEN 'HT5' THEN 2
|
||||
WHEN 'LT5' THEN 1
|
||||
ELSE 0
|
||||
END`;
|
||||
|
||||
const schema = {
|
||||
params: {
|
||||
fields: {
|
||||
gamemode: {
|
||||
type: "string",
|
||||
required: true,
|
||||
minLength: 3,
|
||||
maxLength: 32,
|
||||
toLowerCase: true,
|
||||
},
|
||||
},
|
||||
allowUnknown: false,
|
||||
},
|
||||
query: {
|
||||
fields: {
|
||||
page: { type: "int", min: 1, max: 50000 },
|
||||
perPage: { type: "int", min: 1, max: 500 },
|
||||
},
|
||||
allowUnknown: false,
|
||||
},
|
||||
};
|
||||
|
||||
function validateOrReply(req, res) {
|
||||
try {
|
||||
return validateRequest(req, schema);
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
sendError(res, 400, "VALIDATION_ERROR", error.message, error.details);
|
||||
return null;
|
||||
}
|
||||
|
||||
sendError(res, 400, "VALIDATION_ERROR", "Invalid request payload.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDefaultTiers() {
|
||||
return Object.fromEntries(GAMEMODES.map((mode) => [mode, null]));
|
||||
}
|
||||
|
||||
router.get("/rankings/:gamemode", async (req, res) => {
|
||||
const validated = validateOrReply(req, res);
|
||||
if (!validated) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const gamemode = normalizeGamemode(validated.params.gamemode);
|
||||
const page = validated.query.page || 1;
|
||||
const perPage = validated.query.perPage || PER_PAGE;
|
||||
|
||||
if (!GAMEMODES.includes(gamemode)) {
|
||||
return sendError(res, 400, "VALIDATION_ERROR", "Invalid gamemode");
|
||||
}
|
||||
|
||||
const [countRows] = await pool.query(
|
||||
"SELECT COUNT(*) AS total FROM player_ranks WHERE gamemode = ?",
|
||||
[gamemode]
|
||||
);
|
||||
|
||||
const total = Number(countRows[0]?.total || 0);
|
||||
const totalPages = Math.max(Math.ceil(total / perPage), 1);
|
||||
const start = (page - 1) * perPage;
|
||||
|
||||
if (total === 0) {
|
||||
return res.json({
|
||||
gamemode,
|
||||
data: [],
|
||||
pagination: { page, perPage, total, totalPages },
|
||||
});
|
||||
}
|
||||
|
||||
const [pageRows] = await pool.query(
|
||||
`SELECT
|
||||
p.id,
|
||||
p.username,
|
||||
p.region,
|
||||
gm.tier AS gm_tier,
|
||||
COALESCE(SUM(${TIER_POINTS_SQL}), 0) AS total_points,
|
||||
${GM_POINTS_SQL} AS gamemode_points
|
||||
FROM players p
|
||||
JOIN player_ranks gm ON gm.player_id = p.id AND gm.gamemode = ?
|
||||
LEFT JOIN player_ranks pr ON pr.player_id = p.id
|
||||
GROUP BY p.id, p.username, p.region, gm.tier
|
||||
ORDER BY gamemode_points DESC, total_points DESC, p.username ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[gamemode, perPage, start]
|
||||
);
|
||||
|
||||
const playerIds = pageRows.map((row) => row.id);
|
||||
const [allTierRows] = playerIds.length
|
||||
? await pool.query(
|
||||
"SELECT player_id, gamemode, tier FROM player_ranks WHERE player_id IN (?)",
|
||||
[playerIds]
|
||||
)
|
||||
: [[]];
|
||||
|
||||
const byId = new Map();
|
||||
for (let i = 0; i < pageRows.length; i += 1) {
|
||||
const row = pageRows[i];
|
||||
byId.set(row.id, {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
region: row.region,
|
||||
avatarUrl: avatarUrl(row.username),
|
||||
tiers: buildDefaultTiers(),
|
||||
totalPoints: Number(row.total_points || 0),
|
||||
gamemodePoints: Number(row.gamemode_points || 0),
|
||||
position: start + i + 1,
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of allTierRows) {
|
||||
const target = byId.get(row.player_id);
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
target.tiers[row.gamemode] = row.tier;
|
||||
}
|
||||
|
||||
return res.json({
|
||||
gamemode,
|
||||
data: Array.from(byId.values()),
|
||||
pagination: {
|
||||
page,
|
||||
perPage,
|
||||
total,
|
||||
totalPages,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("GET /api/rankings/:gamemode failed:", error?.message || error);
|
||||
return sendError(res, 500, "RANKINGS_FETCH_FAILED", "Failed to fetch rankings");
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
24
server/schema.sql
Normal file
24
server/schema.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
CREATE DATABASE IF NOT EXISTS eaglertiers;
|
||||
USE eaglertiers;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS players (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(32) NOT NULL UNIQUE,
|
||||
region ENUM('NA','EU','AS','AU') NOT NULL DEFAULT 'NA',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS player_ranks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
player_id INT NOT NULL,
|
||||
gamemode VARCHAR(32) NOT NULL,
|
||||
tier ENUM('HT1','LT1','HT2','LT2','HT3','LT3','HT4','LT4','HT5','LT5') NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_player_gamemode (player_id, gamemode),
|
||||
CONSTRAINT fk_player_ranks_player
|
||||
FOREIGN KEY (player_id)
|
||||
REFERENCES players(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_player_ranks_gamemode ON player_ranks (gamemode);
|
||||
44
server/seed.js
Normal file
44
server/seed.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const dotenv = require("dotenv");
|
||||
const { pool, ensureDatabase } = require("./db");
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const SEED_CONFIRMATION = "YES_WIPE_AND_SEED";
|
||||
|
||||
if (process.env.ALLOW_SEED !== SEED_CONFIRMATION) {
|
||||
console.error(
|
||||
`Seed blocked. To run intentionally, set ALLOW_SEED=${SEED_CONFIRMATION} for this command.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const samplePlayers = [
|
||||
"Starified",
|
||||
"ProtectionZ",
|
||||
"DopeyDude",
|
||||
"Morocco",
|
||||
"chubbsge",
|
||||
"Madhansh",
|
||||
"moorona",
|
||||
"Nepa",
|
||||
"Krilbex",
|
||||
"Genvrousbooch",
|
||||
"Rocketer",
|
||||
"LeosinEdits",
|
||||
];
|
||||
|
||||
async function seed() {
|
||||
await ensureDatabase();
|
||||
|
||||
for (const username of samplePlayers) {
|
||||
await pool.query("INSERT IGNORE INTO players (username) VALUES (?)", [username]);
|
||||
}
|
||||
|
||||
console.log("Seed complete (players only, no rank assignment).");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
seed().catch((error) => {
|
||||
console.error("Seed failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
121
server/server.js
Normal file
121
server/server.js
Normal file
@@ -0,0 +1,121 @@
|
||||
const express = require("express");
|
||||
const path = require("path");
|
||||
const cors = require("cors");
|
||||
const compression = require("compression");
|
||||
const dotenv = require("dotenv");
|
||||
const { pool, ensureDatabase } = require("./db");
|
||||
const playersRoutes = require("./routes/players");
|
||||
const rankingsRoutes = require("./routes/rankings");
|
||||
const { sendError } = require("./utils/http");
|
||||
const { createApiRateLimiters } = require("./middleware/rate-limit");
|
||||
const {
|
||||
createCorsMiddleware,
|
||||
securityHeaders,
|
||||
enforceHttps,
|
||||
} = require("./middleware/security");
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||
|
||||
const trustProxy = String(process.env.TRUST_PROXY || "false").toLowerCase() === "true";
|
||||
|
||||
|
||||
app.set("trust proxy", trustProxy);
|
||||
app.set("etag", "strong");
|
||||
|
||||
app.use(createCorsMiddleware());
|
||||
app.use(securityHeaders);
|
||||
app.use(enforceHttps);
|
||||
|
||||
app.use(
|
||||
compression({
|
||||
threshold: 1024,
|
||||
level: 6,
|
||||
})
|
||||
);
|
||||
|
||||
app.use(
|
||||
express.json({
|
||||
limit: "32kb",
|
||||
strict: true,
|
||||
type: "application/json",
|
||||
})
|
||||
);
|
||||
|
||||
const [ipRateLimit, userRateLimit] = createApiRateLimiters({
|
||||
ipMax: Number(process.env.RATE_LIMIT_IP_MAX || 60),
|
||||
userMax: Number(process.env.RATE_LIMIT_USER_MAX || 60),
|
||||
windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS || 60 * 1000),
|
||||
});
|
||||
|
||||
app.use("/api", ipRateLimit, userRateLimit);
|
||||
app.use("/api", (_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(
|
||||
express.static(path.join(__dirname, "..", "public"), {
|
||||
etag: true,
|
||||
lastModified: true,
|
||||
maxAge: "1h",
|
||||
setHeaders(res, filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
if (ext === ".html") {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
return;
|
||||
}
|
||||
|
||||
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
|
||||
res.setHeader("Cache-Control", "public, max-age=604800, immutable");
|
||||
} else {
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
app.use("/api", playersRoutes);
|
||||
app.use("/api", rankingsRoutes);
|
||||
|
||||
app.get("/api/health", async (_req, res) => {
|
||||
try {
|
||||
await pool.query("SELECT 1 AS ok");
|
||||
return res.json({ ok: true });
|
||||
} catch (_error) {
|
||||
return sendError(res, 500, "DB_UNAVAILABLE", "Database connection failed");
|
||||
}
|
||||
});
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
if (err && /CORS origin blocked/i.test(String(err.message || ""))) {
|
||||
return sendError(res, 403, "CORS_BLOCKED", "Origin is not allowed.");
|
||||
}
|
||||
|
||||
const isProd = String(process.env.NODE_ENV || "").toLowerCase() === "production";
|
||||
if (!isProd) {
|
||||
console.error("Unhandled error:", err);
|
||||
} else {
|
||||
console.error("Unhandled error:", err?.name || "UnknownError");
|
||||
}
|
||||
|
||||
return sendError(res, 500, "INTERNAL_ERROR", "Internal server error");
|
||||
});
|
||||
|
||||
async function startServer() {
|
||||
await ensureDatabase();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`EaglerTiers API running on http://localhost:${PORT}`);
|
||||
console.log(`Environment: ${isDevelopment ? 'development' : 'production'}`);
|
||||
});
|
||||
}
|
||||
|
||||
startServer().catch((error) => {
|
||||
console.error("Failed to start API:", error?.message || error);
|
||||
process.exit(1);
|
||||
});
|
||||
16
server/utils/http.js
Normal file
16
server/utils/http.js
Normal file
@@ -0,0 +1,16 @@
|
||||
function sendError(res, status, code, message, details = null) {
|
||||
const payload = {
|
||||
error: message,
|
||||
code,
|
||||
};
|
||||
|
||||
if (details) {
|
||||
payload.details = details;
|
||||
}
|
||||
|
||||
return res.status(status).json(payload);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendError,
|
||||
};
|
||||
Reference in New Issue
Block a user