sw-serverlist-api/index.js

480 lines
14 KiB
JavaScript

/*
Some foreword:
This code is a mess, I know, but it works. Eventually I'll clean it up, probably in some big rewrite.
That in itself is pretty ironic, considering this could be used for some sort of server browser, where reliability is key, but I digress.
I'll try to comment it as best as I can, but I'm not the best at explaining things.
- Chris Chrome
*/
const Steam = require("steam-server-query")
const express = require('express');
const rateLimit = require('express-rate-limit');
const colors = require("colors");
const semver = require("semver");
const childProcess = require('child_process');
const app = express();
const port = 3004;
const config = require("./config.json");
// Define objects (Need to finish moving objects up here)
var masterList = {
lastUpdated: new Date(),
servers: []
};
var serverList = {
serviceStarted: new Date(),
serverCount: 0,
highestVersion: "v0.0.0",
lowestVersion: "v999.999.999",
outdatedServers: 0,
versions: {},
erroredCount: 0,
lastUpdated: new Date(),
servers: {},
errored: {},
offline: {}
}
BigInt.prototype.toJSON = function () {
return this.toString()
}
var removeDuplicates = function (nums) {
let length = nums.length;
for (let i = length - 1; i >= 0; i--) {
for (let j = i - 1; j >= 0; j--) {
if (nums[i] == nums[j]) {
nums.splice(j, 1);
}
}
}
return nums;
};
servers = [];
// Keyword split to version, dlcs, tps
function splitKeyword(keyword) {
data = keyword.split("-")
switch (data[1]) {
case "0":
dlcString = "None"
break;
case "1":
dlcString = "Weapons"
break;
case "2":
dlcString = "Arid"
break;
case "3":
dlcString = "Both"
break;
default:
break;
}
if (data[0] >= "v1.3.0") {
return {
"version": data[0],
dlcString,
dlc: data[1],
"tps": data[2]
}
} else { // For older versions
console.log(`${colors.magenta(`[DEBUG ${new Date()}]`)} Absolutely ancient server found, ${data}`);
return {
"version": data[0],
"tps": data[1]
}
}
};
// Do not use this function, it's broken for some reason
function countdown(seconds, start, end) {
return new Promise((resolve, reject) => {
var i = seconds;
var interval = setInterval(() => {
process.stdout.clearLine();
process.stdout.cursorTo(0);
// send newline on first iteration
if (i == seconds) {
process.stdout.write(`${start}${i}${end}\n`);
} else if (i < 0) {
process.stdout.write(`${start}${i}${end}\n`);
clearInterval(interval);
resolve();
} else {
process.stdout.write(`${start}${i}${end}`);
}
i--;
}, 1000);
});
}
function getGitCommitDetails() {
try {
// Use child_process.execSync to run the `git log -1 --format=%H%x09%an%x09%ae%x09%ad%x09%s` command
// and return the output as a string
const stdout = childProcess.execSync('git log -1 --format=%H%x09%an%x09%ae%x09%ad%x09%s').toString();
const origin = childProcess.execSync('git config --get remote.origin.url').toString().trim().replace(/\.git$/, '');
// Split the output string into an array of fields
const fields = stdout.split('\t');
// Return the commit details as a JSON object
return {
commit: {
hash: fields[0].substring(0, 7),
fullHash: fields[0],
author: fields[1],
email: fields[2],
timestamp: fields[3],
subject: fields[4]
},
origin
}
} catch (error) {
console.error(error);
}
}
function objectLength(object) {
var length = 0;
for (var key in object) {
if (object.hasOwnProperty(key)) {
++length;
}
}
return length;
};
// checkServer function
function checkServer(address) {
Steam.queryGameServerInfo(address).then(data => {
data.keywords.split("-")
data.address = address.split(":");
data.serverInfo = splitKeyword(data.keywords);
output = {
"name": data.name,
"address": data.address[0],
"port": data.address[1],
"password": data.visibility == 1 ? true : false,
"version": data.serverInfo.version,
"outdated": data.serverInfo.version < serverList.highestVersion ? true : false,
"dlc": data.serverInfo.dlc,
"dlcString": data.serverInfo.dlcString,
"tps": data.serverInfo.tps,
"players": data.bots,
"maxPlayers": data.maxPlayers,
"map": data.map,
"gameId": data.gameId,
"lastUpdated": new Date()
}
// Check if server is in errored list or offline list, if so, remove it
if (serverList.errored[address]) {
delete serverList.errored[address];
}
if (serverList.offline[address]) {
delete serverList.offline[address];
}
// Add server to server list
serverList.servers[address] = output;
return output;
}).catch((err) => {
output = {
"error": "Could not connect to server",
"name": "Unknown",
"address": address.split(":")[0],
"port": address.split(":")[1],
"version": "Unknown",
"dlc": null,
"dlcString": "Unknown",
"tps": 0,
"players": 0,
"maxPlayers": 0,
"map": "Unknown",
"gameId": "573090"
}
serverList.errored[address] = output;
return output;
});
}
var highestVersion = "v0.0.0";
// findHighestVersion function
function findHighestVersion() {
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Finding highest version...`);
for (const key in serverList.servers) {
if (serverList.servers.hasOwnProperty(key)) {
const currentVersion = serverList.servers[key].version;
if (semver.valid(currentVersion)) { // check if currentVersion is a valid semver string
if (semver.gt(currentVersion, highestVersion)) {
highestVersion = currentVersion;
}
}
}
}
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Highest version is ${highestVersion}`);
return highestVersion;
}
var lowestVersion = 'v999.999.999';
// findLowestVersion function
function findLowestVersion() {
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Finding lowest version...`);
for (const key in serverList.servers) {
if (serverList.servers.hasOwnProperty(key)) {
const currentVersion = serverList.servers[key].version;
if (semver.valid(currentVersion)) { // check if currentVersion is a valid semver string
if (semver.lt(currentVersion, lowestVersion)) {
lowestVersion = currentVersion;
}
}
}
}
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Lowest version is ${lowestVersion}`);
return lowestVersion;
}
var outdatedServers = 0;
// countOutdatedServers function, counts servers that are outdated
function countOutdatedServers() {
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Counting outdated servers, latest version is ${highestVersion}`);
outdatedServers = 0;
for (var key in serverList.servers) {
if (serverList.servers.hasOwnProperty(key)) {
if (serverList.servers[key].version != highestVersion) {
outdatedServers++;
}
}
}
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} ${outdatedServers} servers are outdated!`);
return outdatedServers;
};
var versions = {};
// Track server versions
function countVersions() {
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Counting server versions...`);
const versions = {};
for (const key in serverList.servers) {
const server = serverList.servers[key];
versions[server.version] = (versions[server.version] || 0) + 1;
}
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} ${Object.keys(versions).length} versions found!`);
return versions;
}
// updateMasterList function
function updateMasterList() {
// Get master list
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Getting master list...`);
Steam.queryMasterServer('hl2master.steampowered.com:27011', Steam.REGIONS.ALL, {
appid: 573090,
game: "Stormworks",
}, 1000, 400).then(servers => {
servers = removeDuplicates(servers);
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Got master list!`);
masterList.servers = servers;
masterList.lastUpdated = new Date();
updateServerList();
}).catch((err) => {
console.log(`${colors.red(`[ERROR ${new Date()}]`)} Error updating master list: ${err}`);
});
}
// updateServerList function
function updateServerList() {
// Get every server in master list
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Getting server list...`);
for (let i = 0; i < masterList.servers.length; i++) {
// Get server info
checkServer(masterList.servers[i]);
serverList.lastUpdated = new Date();
}
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Got server list!`);
setTimeout(() => {
purgeDeadServers();
serverList.serverCount = objectLength(serverList.servers);
serverList.highestVersion = findHighestVersion();
serverList.lowestVersion = findLowestVersion();
serverList.outdatedServers = countOutdatedServers();
serverList.versions = countVersions();
serverList.erroredCount = objectLength(serverList.errored);
}, 1500);
};
// purgeDeadServers function, moves dead servers to offline list
function purgeDeadServers() {
let counter = 0;
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Purging dead servers...`);
for (var key in serverList.servers) {
if (serverList.servers.hasOwnProperty(key)) {
if (serverList.servers[key].lastUpdated < new Date(new Date().getTime() - 60000)) {
serverList.offline[key] = serverList.servers[key];
delete serverList.servers[key];
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Server ${key} is offline!`);
// If server somehow got into errored list, remove it
if (serverList.errored[key]) {
delete serverList.errored[key];
}
counter++;
}
}
}
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Purged ${counter} dead servers!`);
}
// Startup messages
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Starting Stormworks Server List...`);
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Config: ${JSON.stringify(config)}`);
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Commit: ${getGitCommitDetails().hash}`);
// Update master list every 1 minute
setInterval(() => {
updateMasterList();
}, config.updateInterval * 1000);
updateMasterList();
setTimeout(() => {
updateServerList(); // Hacky fix for outdated server check
}, 5000);
if (config.rateLimiterEnabled) {
const rateLimiterWarnings = new Set();
app.use(rateLimit({
windowMs: config.rateLimitWindow * 60 * 1000, // X minutes
max: config.rateLimitMax, // limit each IP to X requests per windowMs.
keyGenerator: function (req) {
return config.behindProxy ? req.headers['x-real-ip'] : req.ip;
},
skipFailedRequests: true,
handler: function (req, res /*, next*/ ) {
const ip = config.behindProxy ? req.headers['x-real-ip'] : req.ip;
const remainingTime = Math.round((req.rateLimit.resetTime - Date.now()) / 1000);
res.status(429).json({
error: 'Too Many Requests',
message: `You have exceeded the rate limit. Please try again in ${remainingTime} seconds.`,
remainingTime: remainingTime
});
if (req.rateLimit.remaining === 0 && !rateLimiterWarnings.has(ip)) {
rateLimiterWarnings.add(ip);
console.log(`${colors.red(`[ERROR ${new Date()}]`)} ${req.headers["user-agent"]}@${ip} exceeded rate limit!`);
setTimeout(() => rateLimiterWarnings.delete(ip), req.rateLimit.resetTime - Date.now());
}
}
}));
}
app.get('/check', (req, res) => {
// Check that all required parameters are present
if (!req.query.address) {
res.send({
"error": "Missing required parameter: address"
});
return;
};
// Regex for IP address : port
const ipRegex = /(?:[0-9]{1,3}\.){3}[0-9]{1,3}:[0-9]{1,5}/;
// Check ip argument is valid
if (ipRegex.test(req.query.address)) {
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} ${req.headers["user-agent"]}@${req.ip} requested check server ${req.query.address}`);
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Checking server ${req.query.address}`);
Steam.queryGameServerInfo(req.query.address).then(data => {
// Check if server is not running Stormworks, in which case, someone is trying to be funny
if (data.gameId != "573090") {
res.status(418).send({
"error": "A server was found, but it is not running Stormworks"
});
console.log(`${colors.red(`[ERROR ${new Date()}]`)} Server ${req.query.address} is not running Stormworks! (AppID: ${data.appid})`);
return;
}
data.keywords.split("-")
data.address = req.query.address.split(":");
data.serverInfo = splitKeyword(data.keywords);
output = {
"name": data.name,
"address": data.address[0],
"port": data.address[1],
"password": data.visibility == 1 ? false : true,
"version": data.serverInfo.version,
"outdated": data.serverInfo.version < serverList.highestVersion ? true : false,
"dlc": data.serverInfo.dlc,
"dlcString": data.serverInfo.dlcString,
"tps": data.serverInfo.tps,
"players": data.bots,
"maxPlayers": data.maxPlayers,
"map": data.map,
"gameId": data.gameId,
"lastUpdated": new Date()
}
// Check if server is in errored list or offline list, if so, remove it
if (serverList.errored[req.query.address]) {
delete serverList.errored[req.query.address];
}
if (serverList.offline[req.query.address]) {
delete serverList.offline[req.query.address];
}
// Add server to server list
serverList.servers[req.query.address] = output;
res.setHeader("Content-Type", "application/json").send(JSON.stringify(output));
}).catch(err => {
console.log(err)
res.status(500).send(`Could not query server: ${err}`);
});
} else {
res.status(400).send("Invalid Server Address, must be in the format IP:PORT")
}
});
// Commented out because it could be abused, not that the serverList API couldnt be abused anyway
// app.get('/masterList', (req, res) => {
// res.setHeader("Content-Type", "application/json").send(JSON.stringify(masterList));
// });
app.get('/serverList', (req, res) => {
res.setHeader("Content-Type", "application/json").send(JSON.stringify(serverList));
});
app.get('/', (req, res) => {
// Send list of all endpoints
res.setHeader("Content-Type", "application/json").send(JSON.stringify({
"endpoints": [
"/check?address=IP:PORT",
"/serverList"
],
"about": {
"author": "Chris Chrome",
// Get repo
"repo": getGitCommitDetails()
},
// Rate limit X requests per Y minutes per IP, as a string
"rateLimit": `${config.rateLimitMax} requests per ${config.rateLimitWindow} minutes`,
"debug": {
// "yourIP" Either the IP of the user, or the IP of the proxy if one is used, proxy IP header is x-real-ip
"yourIP": req.headers["x-real-ip"] || req.ip,
"yourUserAgent": req.headers["user-agent"],
}
}));
});
// Basic robots.txt, deny all
app.get('/robots.txt', (req, res) => {
res.send("User-agent: *\nDisallow: /");
});
app.listen(port, () => {
console.log(`${colors.cyan(`[INFO ${new Date()}]`)} Server started on port ${port}`);
});