From 4d13965e6b483f8a19a5470e83f42ab5951970d4 Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Tue, 21 Apr 2020 17:59:53 -0500 Subject: [PATCH] Backend cleanup (#146) * Add ServerRegistration, begin refactoring to match frontend * move graphData logic into ServerRegistration * move ping updates/history into ServerRegistration * start updating main app entry methods * fix default rates.updateMojangStatus * fix record loading delays on freshly booted instances * move database loading logic to method + callback * use data in frontend for type lookup instead of ping * cleanup app.js * reorganize methods to improve flow * avoid useless mojang updates, remove legacy fields * rename legacy fields for consistency * finish restructure around App model * ensure versions are sorted by release order * filter errors sent to frontend to avoid data leaks * fix version listing behavior on frontend * 5.1.0 --- app.js | 392 ----------------------------------------- assets/js/main.js | 11 +- assets/js/mojang.js | 12 +- assets/js/servers.js | 5 +- assets/js/util.js | 6 +- config.json | 2 +- docs/CHANGELOG.md | 5 +- lib/app.js | 92 ++++++++++ lib/database.js | 134 ++++++++++---- lib/logger.js | 30 ++-- lib/mojang.js | 96 ++++++++++ lib/mojang_services.js | 85 --------- lib/ping.js | 202 +++++++++++++-------- lib/server.js | 78 +++++--- lib/servers.js | 279 +++++++++++++++++++++++++++++ lib/util.js | 174 ------------------ main.js | 36 ++++ package.json | 4 +- scripts/start.sh | 2 +- 19 files changed, 822 insertions(+), 823 deletions(-) delete mode 100644 app.js create mode 100644 lib/app.js create mode 100644 lib/mojang.js delete mode 100644 lib/mojang_services.js create mode 100644 lib/servers.js delete mode 100644 lib/util.js create mode 100644 main.js diff --git a/app.js b/app.js deleted file mode 100644 index 674f0c7..0000000 --- a/app.js +++ /dev/null @@ -1,392 +0,0 @@ -/** - * THIS IS LEGACY, UNMAINTAINED CODE - * IT MAY (AND LIKELY DOES) CONTAIN BUGS - * USAGE IS NOT RECOMMENDED - */ -var server = require('./lib/server'); -var ping = require('./lib/ping'); -var logger = require('./lib/logger'); -var mojang = require('./lib/mojang_services'); -var util = require('./lib/util'); -var db = require('./lib/database'); - -var config = require('./config.json'); -var servers = require('./servers.json'); -var minecraftVersions = require('./minecraft_versions.json'); - -var networkHistory = []; -var connectedClients = 0; - -var networkVersions = []; - -const lastFavicons = []; - -var graphData = []; -var highestPlayerCount = {}; -var lastGraphPush = []; -var graphPeaks = {}; - -const serverProtocolVersionIndexes = [] - -function getNextProtocolVersion (server) { - // Minecraft Bedrock Edition does not have protocol versions - if (server.type === 'PE') { - return { - protocolId: 0, - protocolIndex: 0 - } - } - const protocolVersions = minecraftVersions[server.type] - let nextProtocolVersion = serverProtocolVersionIndexes[server.name] - if (typeof nextProtocolVersion === 'undefined' || nextProtocolVersion + 1 >= protocolVersions.length) { - nextProtocolVersion = 0 - } else { - nextProtocolVersion++ - } - serverProtocolVersionIndexes[server.name] = nextProtocolVersion - return { - protocolId: protocolVersions[nextProtocolVersion].protocolId, - protocolIndex: nextProtocolVersion - } -} - -function pingAll() { - for (var i = 0; i < servers.length; i++) { - // Make sure we lock our scope. - (function(network) { - // Asign auto generated color if not present - if (!network.color) { - network.color = util.stringToColor(network.name); - } - - const attemptedVersion = getNextProtocolVersion(network) - ping.ping(network.ip, network.port, network.type, config.rates.connectTimeout, function(err, res) { - // Handle our ping results, if it succeeded. - if (err) { - logger.log('error', 'Failed to ping ' + network.ip + ': ' + err.message); - } - - // If we have favicon override specified, use it. - if (network.favicon) { - res.favicon = network.favicon - } - - handlePing(network, res, err, attemptedVersion); - }, attemptedVersion.protocolId); - })(servers[i]); - } -} - -// This is where the result of a ping is feed. -// This stores it and converts it to ship to the frontend. -function handlePing(network, res, err, attemptedVersion) { - // Log our response. - if (!networkHistory[network.name]) { - networkHistory[network.name] = []; - } - - // Update the version list - if (!networkVersions[network.name]) { - networkVersions[network.name] = []; - } - - const serverVersionHistory = networkVersions[network.name] - - // If the result version matches the attempted version, the version is supported - if (res && res.version !== undefined) { - const indexOf = serverVersionHistory.indexOf(attemptedVersion.protocolIndex) - - // Test indexOf to avoid inserting previously recorded protocolIndex values - if (res.version === attemptedVersion.protocolId && indexOf === -1) { - serverVersionHistory.push(attemptedVersion.protocolIndex) - } else if (res.version !== attemptedVersion.protocolId && indexOf >= 0) { - serverVersionHistory.splice(indexOf, 1) - } - } - - const timestamp = util.getCurrentTimeMs() - - if (res) { - const recordData = highestPlayerCount[network.ip] - - // Validate that we have logToDatabase enabled otherwise in memory pings - // will create a record that's only valid for the runtime duration. - if (config.logToDatabase && (!recordData || res.players.online > recordData.playerCount)) { - highestPlayerCount[network.ip] = { - playerCount: res.players.online, - timestamp: timestamp - } - } - } - - // Update the clients - var networkSnapshot = { - info: { - name: network.name, - timestamp: timestamp, - type: network.type - }, - versions: serverVersionHistory, - recordData: highestPlayerCount[network.ip] - }; - - if (res) { - networkSnapshot.result = res; - - // Only emit updated favicons - // Favicons will otherwise be explicitly emitted during the handshake process - if (res.favicon) { - const lastFavicon = lastFavicons[network.name] - if (lastFavicon !== res.favicon) { - lastFavicons[network.name] = res.favicon - networkSnapshot.favicon = res.favicon // Send updated favicon directly on object - } - delete res.favicon // Never store favicons in memory outside lastFavicons - } - } else if (err) { - networkSnapshot.error = err; - } - - server.io.sockets.emit('update', networkSnapshot); - - var _networkHistory = networkHistory[network.name]; - - // Remove our previous data that we don't need anymore. - for (var i = 0; i < _networkHistory.length; i++) { - delete _networkHistory[i].versions - delete _networkHistory[i].info; - } - - _networkHistory.push({ - error: err, - result: res, - versions: serverVersionHistory, - timestamp: timestamp, - info: { - ip: network.ip, - port: network.port, - type: network.type, - name: network.name - } - }); - - // Make sure we never log too much. - if (_networkHistory.length > 72) { // 60/2.5 = 24, so 24 is one minute - _networkHistory.shift(); - } - - // Log it to the database if needed. - if (config.logToDatabase) { - db.log(network.ip, timestamp, res ? res.players.online : 0); - } - - - // The same mechanic from trimUselessPings is seen here. - // If we dropped the ping, then to avoid destroying the graph, ignore it. - // However if it's been too long since the last successful ping, we'll send it anyways. - if (config.logToDatabase) { - if (!lastGraphPush[network.ip] || (timestamp - lastGraphPush[network.ip] >= 60 * 1000 && res) || timestamp - lastGraphPush[network.ip] >= 70 * 1000) { - lastGraphPush[network.ip] = timestamp; - - // Don't have too much data! - util.trimOldPings(graphData); - - if (!graphData[network.name]) { - graphData[network.name] = []; - } - - graphData[network.name].push([timestamp, res ? res.players.online : 0]); - - // Send the update. - server.io.sockets.emit('updateHistoryGraph', { - ip: network.ip, - name: network.name, - players: (res ? res.players.online : 0), - timestamp: timestamp - }); - } - - // Update calculated graph peak regardless if the graph is being updated - // This can cause a (harmless) desync between live and stored data, but it allows it to be more accurate for long surviving processes - var networkData = graphData[network.name]; - - if (networkData) { - var graphPeakIndex = -1; - var graphPeakPlayerCount = 0; - for (var i = 0; i < networkData.length; i++) { - // [1] refers to the online player count - var point = networkData[i][1]; - if (point > 0 && (graphPeakIndex === -1 || point > graphPeakPlayerCount)) { - graphPeakIndex = i; - graphPeakPlayerCount = point; - } - } - // Test if a highest index has been selected and has changed from any previous selections - var previousPeak = graphPeaks[network.name]; - // [1] refers to the online player count - if (graphPeakIndex !== -1 && (!previousPeak || previousPeak[1] !== graphPeakPlayerCount)) { - var graphPeakData = networkData[graphPeakIndex]; - graphPeaks[network.name] = graphPeakData; - - // Broadcast update event to clients - server.io.sockets.emit('updatePeak', { - ip: network.ip, - name: network.name, - players: graphPeakData[1], - timestamp: graphPeakData[0] - }); - } - } - } -} - -// Start our main loop that does everything. -function startMainLoop() { - util.setIntervalNoDelay(pingAll, config.rates.pingAll); - - util.setIntervalNoDelay(function() { - mojang.update(config.rates.mojangStatusTimeout); - - server.io.sockets.emit('updateMojangServices', mojang.toMessage()); - }, config.rates.upateMojangStatus); -} - -function startServices() { - server.start(); - - // Track how many people are currently connected. - server.io.on('connect', function(client) { - // We're good to connect them! - connectedClients += 1; - - logger.log('info', '%s connected, total clients: %d', util.getRemoteAddr(client.request), connectedClients); - - // Attach our listeners. - client.on('disconnect', function() { - connectedClients -= 1; - - logger.log('info', '%s disconnected, total clients: %d', util.getRemoteAddr(client.request), connectedClients); - }); - - client.on('requestHistoryGraph', function() { - if (config.logToDatabase) { - // Send them the big 24h graph. - client.emit('historyGraph', graphData); - - // Send current peaks, if any - if (Object.keys(graphPeaks).length > 0) { - client.emit('peaks', graphPeaks); - } - } - }); - - const minecraftVersionNames = {} - Object.keys(minecraftVersions).forEach(function (key) { - minecraftVersionNames[key] = minecraftVersions[key].map(version => version.name) - }) - - // Send configuration data for rendering the page - client.emit('setPublicConfig', { - graphDuration: config.graphDuration, - servers: servers, - minecraftVersions: minecraftVersionNames, - isGraphVisible: config.logToDatabase - }); - - // Send them our previous data, so they have somewhere to start. - client.emit('updateMojangServices', mojang.toMessage()); - - // Send each individually, this should look cleaner than waiting for one big array to transfer. - for (var i = 0; i < servers.length; i++) { - var server = servers[i]; - - if (!(server.name in networkHistory) || networkHistory[server.name].length < 1) { - // This server hasn't been ping'd yet. Send a hacky placeholder. - client.emit('add', [[{ - error: { - description: 'Waiting...', - placeholder: true - }, - result: null, - timestamp: util.getCurrentTimeMs(), - info: { - ip: server.ip, - port: server.port, - type: server.type, - name: server.name - } - }]]); - } else { - // Append the lastFavicon to the last ping entry - const serverHistory = networkHistory[server.name]; - const lastFavicon = lastFavicons[server.name]; - if (lastFavicon) { - serverHistory[serverHistory.length - 1].favicon = lastFavicon - } - client.emit('add', [serverHistory]) - } - } - - client.emit('syncComplete'); - }); - - startMainLoop(); -} - -logger.log('info', 'Booting, please wait...'); - -if (config.logToDatabase) { - // Setup our database. - db.setup(); - - var timestamp = util.getCurrentTimeMs(); - - db.queryPings(config.graphDuration, function(data) { - graphData = util.convertServerHistory(data); - completedQueries = 0; - - logger.log('info', 'Queried and parsed ping history in %sms', util.getCurrentTimeMs() - timestamp); - - for (var i = 0; i < servers.length; i++) { - // Compute graph peak from historical data - var networkData = graphData[servers[i].name]; - if (networkData) { - var graphPeakIndex = -1; - var graphPeakPlayerCount = 0; - for (var x = 0; x < networkData.length; x++) { - // [1] refers to the online player count - var point = networkData[x][1]; - if (point > 0 && (graphPeakIndex === -1 || point > graphPeakPlayerCount)) { - graphPeakIndex = x; - graphPeakPlayerCount = point; - } - } - if (graphPeakIndex !== -1) { - graphPeaks[servers[i].name] = networkData[graphPeakIndex]; - logger.log('info', 'Selected graph peak %d (%s)', networkData[graphPeakIndex][1], servers[i].name); - } - } - - (function(server) { - db.getTotalRecord(server.ip, function(playerCount, timestamp) { - logger.log('info', 'Computed total record %s (%d) @ %d', server.ip, playerCount, timestamp); - - highestPlayerCount[server.ip] = { - playerCount: playerCount, - timestamp: timestamp - }; - - completedQueries += 1; - - if (completedQueries === servers.length) { - startServices(); - } - }); - })(servers[i]); - } - }); -} else { - logger.log('warn', 'Database logging is not enabled. You can enable it by setting "logToDatabase" to true in config.json. This requires sqlite3 to be installed.'); - - startServices(); -} diff --git a/assets/js/main.js b/assets/js/main.js index 5e809d0..d34f4cc 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -69,7 +69,7 @@ document.addEventListener('DOMContentLoaded', function () { const serverRegistration = app.serverRegistry.getServerRegistration(data.name) if (serverRegistration) { - app.graphDisplayManager.addGraphPoint(serverRegistration.serverId, data.timestamp, data.players) + app.graphDisplayManager.addGraphPoint(serverRegistration.serverId, data.timestamp, data.playerCount) // Only redraw the graph if not mutating hidden data if (serverRegistration.isVisible) { @@ -94,7 +94,7 @@ document.addEventListener('DOMContentLoaded', function () { }) socket.on('updateMojangServices', function (data) { - Object.values(data).forEach(app.mojangUpdater.updateServiceStatus) + app.mojangUpdater.updateStatus(data) }) socket.on('setPublicConfig', function (data) { @@ -125,7 +125,7 @@ document.addEventListener('DOMContentLoaded', function () { const serverRegistration = app.serverRegistry.getServerRegistration(data.name) if (serverRegistration) { - serverRegistration.updateServerPeak(data.timestamp, data.players) + serverRegistration.updateServerPeak(data.timestamp, data.playerCount) } }) @@ -134,10 +134,9 @@ document.addEventListener('DOMContentLoaded', function () { const serverRegistration = app.serverRegistry.getServerRegistration(serverName) if (serverRegistration) { - const graphData = data[serverName] + const graphPeak = data[serverName] - // [0] and [1] indexes correspond to flot.js' graphing data structure - serverRegistration.updateServerPeak(graphData[0], graphData[1]) + serverRegistration.updateServerPeak(graphPeak.timestamp, graphPeak.playerCount) } }) }) diff --git a/assets/js/mojang.js b/assets/js/mojang.js index 236388d..6638bba 100644 --- a/assets/js/mojang.js +++ b/assets/js/mojang.js @@ -1,10 +1,16 @@ const MOJANG_STATUS_BASE_CLASS = 'header-button header-button-group' export class MojangUpdater { - updateServiceStatus (status) { + updateStatus (services) { + for (const name of Object.keys(services)) { + this.updateServiceStatus(name, services[name]) + } + } + + updateServiceStatus (name, title) { // HACK: ensure mojang-status is added for alignment, replace existing class to swap status color - document.getElementById('mojang-status_' + status.name).setAttribute('class', MOJANG_STATUS_BASE_CLASS + ' mojang-status-' + status.title.toLowerCase()) - document.getElementById('mojang-status-text_' + status.name).innerText = status.title + document.getElementById('mojang-status_' + name).setAttribute('class', MOJANG_STATUS_BASE_CLASS + ' mojang-status-' + title.toLowerCase()) + document.getElementById('mojang-status-text_' + name).innerText = title } reset () { diff --git a/assets/js/servers.js b/assets/js/servers.js index 6d16369..6b37212 100644 --- a/assets/js/servers.js +++ b/assets/js/servers.js @@ -204,7 +204,7 @@ export class ServerRegistration { const versionsElement = document.getElementById('version_' + this.serverId) versionsElement.style.display = 'block' - versionsElement.innerText = formatMinecraftVersions(ping.versions, minecraftVersions[ping.info.type]) || '' + versionsElement.innerText = formatMinecraftVersions(ping.versions, minecraftVersions[this.data.type]) || '' } // Compare against a cached value to avoid empty updates @@ -237,8 +237,7 @@ export class ServerRegistration { playerCountLabelElement.style.display = 'none' errorElement.style.display = 'block' - // Attempt to find an error cause from documented options - errorElement.innerText = ping.error.description || ping.error.errno || 'Unknown error' + errorElement.innerText = ping.error.message } else if (ping.result) { // Ensure the player-count element is visible and hide the error element playerCountLabelElement.style.display = 'block' diff --git a/assets/js/util.js b/assets/js/util.js index 0827a26..e0f7932 100644 --- a/assets/js/util.js +++ b/assets/js/util.js @@ -64,16 +64,14 @@ export function formatMinecraftVersions (versions, knownVersions) { const versionGroups = [] for (let i = 0; i < versions.length; i++) { - const versionIndex = versions[i] - // Look for value mismatch between the previous index // Require i > 0 since lastVersionIndex is undefined for i === 0 - if (i > 0 && versions[i] - 1 !== versionIndex - 1) { + if (i > 0 && versions[i] - versions[i - 1] !== 1) { versionGroups.push(currentVersionGroup) currentVersionGroup = [] } - currentVersionGroup.push(versionIndex) + currentVersionGroup.push(versions[i]) } // Ensure the last versionGroup is always pushed diff --git a/config.json b/config.json index 88275be..3fd9359 100644 --- a/config.json +++ b/config.json @@ -4,7 +4,7 @@ "ip": "0.0.0.0" }, "rates": { - "upateMojangStatus": 5000, + "updateMojangStatus": 5000, "mojangStatusTimeout": 3500, "pingAll": 3000, "connectTimeout": 2500 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ffc8c14..324b71d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,4 +1,7 @@ -**5** *(Apr 8 2020)* +**5.1.0** *(Apr 21 2020)* +- Completely rebuilt the backend. This includes several optimizations, code cleanup and syncing fixes. Its code model now pairs nicely with the frontend's Javascript model. + +**5.0.0** *(Apr 8 2020)* - New logo! - Completely rebuilt the frontend's Javascript (heavy optimizations and cleanup!) - Adds a button for mobile devices to manually request the historical graph diff --git a/lib/app.js b/lib/app.js new file mode 100644 index 0000000..e5e3d9e --- /dev/null +++ b/lib/app.js @@ -0,0 +1,92 @@ +const Database = require('./database') +const MojangUpdater = require('./mojang') +const PingController = require('./ping') +const Server = require('./server') + +const config = require('../config') +const minecraftVersions = require('../minecraft_versions') + +class App { + serverRegistrations = [] + + constructor () { + this.mojangUpdater = new MojangUpdater(this) + this.pingController = new PingController(this) + this.server = new Server(this.handleClientConnection) + } + + loadDatabase (callback) { + this.database = new Database(this) + + // Setup database instance + this.database.ensureIndexes() + + this.database.loadGraphPoints(config.graphDuration, () => { + this.database.loadRecords(callback) + }) + } + + handleReady () { + this.server.listen(config.site.ip, config.site.port) + + // Allow individual modules to manage their own task scheduling + this.mojangUpdater.schedule() + this.pingController.schedule() + } + + handleClientConnection = (client) => { + if (config.logToDatabase) { + client.on('requestHistoryGraph', () => { + // Send historical graphData built from all serverRegistrations + const graphData = {} + const graphPeaks = {} + + this.serverRegistrations.forEach((serverRegistration) => { + graphData[serverRegistration.data.name] = serverRegistration.graphData + + // Send current peak, if any + const graphPeak = serverRegistration.getGraphPeak() + if (graphPeak) { + graphPeaks[serverRegistration.data.name] = graphPeak + } + }) + + // Send current peaks, if any + // Emit peaks first since graphData may take a while to receive + if (Object.keys(graphPeaks).length > 0) { + client.emit('peaks', graphPeaks) + } + + client.emit('historyGraph', graphData) + }) + } + + client.emit('setPublicConfig', (() => { + // Remap minecraftVersion entries into name values + const minecraftVersionNames = {} + Object.keys(minecraftVersions).forEach(function (key) { + minecraftVersionNames[key] = minecraftVersions[key].map(version => version.name) + }) + + // Send configuration data for rendering the page + return { + graphDuration: config.graphDuration, + servers: this.serverRegistrations.map(serverRegistration => serverRegistration.data), + minecraftVersions: minecraftVersionNames, + isGraphVisible: config.logToDatabase + } + })()) + + // Send last Mojang update, if any + this.mojangUpdater.sendLastUpdate(client) + + // Send pingHistory of all ServerRegistrations + client.emit('add', this.serverRegistrations.map(serverRegistration => serverRegistration.getPingHistory())) + + // Always send last + // This tells the frontend to do final processing and render + client.emit('syncComplete') + } +} + +module.exports = App diff --git a/lib/database.js b/lib/database.js index c8a80b7..821896a 100644 --- a/lib/database.js +++ b/lib/database.js @@ -1,47 +1,105 @@ -/** - * THIS IS LEGACY, UNMAINTAINED CODE - * IT MAY (AND LIKELY DOES) CONTAIN BUGS - * USAGE IS NOT RECOMMENDED - */ -var util = require('./util'); +const sqlite = require('sqlite3') -exports.setup = function() { - var sqlite = require('sqlite3'); +class Database { + constructor (app) { + this._app = app + this._sql = new sqlite.Database('database.sql') + } - var db = new sqlite.Database('database.sql'); + ensureIndexes () { + this._sql.serialize(() => { + this._sql.run('CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)') + this._sql.run('CREATE INDEX IF NOT EXISTS ip_index ON pings (ip, playerCount)') + this._sql.run('CREATE INDEX IF NOT EXISTS timestamp_index on PINGS (timestamp)') + }) + } - db.serialize(function() { - db.run('CREATE TABLE IF NOT EXISTS pings (timestamp BIGINT NOT NULL, ip TINYTEXT, playerCount MEDIUMINT)'); - db.run('CREATE INDEX IF NOT EXISTS ip_index ON pings (ip, playerCount)'); - db.run('CREATE INDEX IF NOT EXISTS timestamp_index on PINGS (timestamp)'); - }); + loadGraphPoints (graphDuration, callback) { + // Query recent pings + const endTime = new Date().getTime() + const startTime = endTime - graphDuration - exports.log = function(ip, timestamp, playerCount) { - var insertStatement = db.prepare('INSERT INTO pings (timestamp, ip, playerCount) VALUES (?, ?, ?)'); + this.getRecentPings(startTime, endTime, pingData => { + const graphPointsByIp = [] - db.serialize(function() { - insertStatement.run(timestamp, ip, playerCount); - }); + for (const row of pingData) { + // Avoid loading outdated records + // This shouldn't happen and is mostly a sanity measure + if (startTime - row.timestamp <= graphDuration) { + // Load into temporary array + // This will be culled prior to being pushed to the serverRegistration + let graphPoints = graphPointsByIp[row.ip] + if (!graphPoints) { + graphPoints = graphPointsByIp[row.ip] = [] + } - insertStatement.finalize(); - }; + graphPoints.push([row.timestamp, row.playerCount]) + } + } - exports.getTotalRecord = function(ip, callback) { - db.all("SELECT MAX(playerCount), timestamp FROM pings WHERE ip = ?", [ - ip - ], function(err, data) { - callback(data[0]['MAX(playerCount)'], data[0]['timestamp']); - }); - }; + Object.keys(graphPointsByIp).forEach(ip => { + // Match IPs to serverRegistration object + for (const serverRegistration of this._app.serverRegistrations) { + if (serverRegistration.data.ip === ip) { + const graphPoints = graphPointsByIp[ip] - exports.queryPings = function(duration, callback) { - var currentTime = util.getCurrentTimeMs(); + // Push the data into the instance and cull if needed + serverRegistration.loadGraphPoints(graphPoints) - db.all("SELECT * FROM pings WHERE timestamp >= ? AND timestamp <= ?", [ - currentTime - duration, - currentTime - ], function(err, data) { - callback(data); - }); - }; -}; + break + } + } + }) + + callback() + }) + } + + loadRecords (callback) { + let completedTasks = 0 + + this._app.serverRegistrations.forEach(serverRegistration => { + // Find graphPeaks + // This pre-computes the values prior to clients connecting + serverRegistration.findNewGraphPeak() + + // Query recordData + // When complete increment completeTasks to know when complete + this.getRecord(serverRegistration.data.ip, (playerCount, timestamp) => { + serverRegistration.recordData = { + playerCount: playerCount, + timestamp: timestamp + } + + // Check if completedTasks hit the finish value + // Fire callback since #readyDatabase is complete + if (++completedTasks === this._app.serverRegistrations.length) { + callback() + } + }) + }) + } + + getRecentPings (startTime, endTime, callback) { + this._sql.all('SELECT * FROM pings WHERE timestamp >= ? AND timestamp <= ?', [ + startTime, + endTime + ], (_, data) => callback(data)) + } + + getRecord (ip, callback) { + this._sql.all('SELECT MAX(playerCount), timestamp FROM pings WHERE ip = ?', [ + ip + ], (_, data) => callback(data[0]['MAX(playerCount)'], data[0].timestamp)) + } + + insertPing (ip, timestamp, playerCount) { + const statement = this._sql.prepare('INSERT INTO pings (timestamp, ip, playerCount) VALUES (?, ?, ?)') + this._sql.serialize(() => { + statement.run(timestamp, ip, playerCount) + }) + statement.finalize() + } +} + +module.exports = Database diff --git a/lib/logger.js b/lib/logger.js index c38405c..ea99dd7 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -1,23 +1,17 @@ -/** - * THIS IS LEGACY, UNMAINTAINED CODE - * IT MAY (AND LIKELY DOES) CONTAIN BUGS - * USAGE IS NOT RECOMMENDED - */ -var winston = require('winston'); +const winston = require('winston') -winston.remove(winston.transports.Console); +winston.remove(winston.transports.Console) -winston.add(winston.transports.File, { - filename: 'minetrack.log' -}); +winston.add(winston.transports.File, { + filename: 'minetrack.log' +}) winston.add(winston.transports.Console, { - 'timestamp': function() { - var date = new Date(); + timestamp: () => { + const date = new Date() + return date.toLocaleTimeString() + ' ' + date.getDate() + '/' + (date.getMonth() + 1) + '/' + date.getFullYear().toString().substring(2, 4) + }, + colorize: true +}) - return date.toLocaleTimeString() + ' ' + date.getDate() + '/' + (date.getMonth() + 1) + '/' + date.getFullYear().toString().substring(2, 4); - }, - 'colorize': true -}); - -module.exports = winston; \ No newline at end of file +module.exports = winston diff --git a/lib/mojang.js b/lib/mojang.js new file mode 100644 index 0000000..5f932b5 --- /dev/null +++ b/lib/mojang.js @@ -0,0 +1,96 @@ +const request = require('request') + +const logger = require('./logger') + +const config = require('../config') + +const SERVICE_URL_LOOKUP = { + 'sessionserver.mojang.com': 'Sessions', + 'authserver.mojang.com': 'Auth', + 'textures.minecraft.net': 'Skins', + 'api.mojang.com': 'API' +} + +const TITLE_BY_MOJANG_COLOR = { + red: 'Offline', + yellow: 'Unstable', + green: 'Online' +} + +class MojangUpdater { + constructor (app) { + this._app = app + } + + schedule () { + setInterval(this.updateServices, config.rates.updateMojangStatus) + + this.updateServices() + } + + updateServices = () => { + request({ + uri: 'https://status.mojang.com/check', + method: 'GET', + timeout: config.rates.mojangStatusTimeout + }, (err, _, body) => { + if (err) { + logger.log('error', 'Failed to update Mojang services: %s', err.message) + + // Set all services to offline + // This may be incorrect, but if mojang.com is offline, it would never otherwise be reflected + Object.keys(SERVICE_URL_LOOKUP).forEach(url => { + this.handleServiceUpdate(url, 'red') + }) + + this.pushUpdate() + } else { + try { + JSON.parse(body).forEach(service => { + // Each service is formatted as an object with the 0 key being the URL + const url = Object.keys(service)[0] + this.handleServiceUpdate(url, service[url]) + }) + } catch (err) { + logger.log('error', 'Failed to parse Mojang response: %s', err.message) + } + + this.pushUpdate() + } + }) + } + + pushUpdate () { + // Only fire callback when previous state is modified + if (this._hasUpdated) { + this._hasUpdated = false + + this._app.server.broadcast('updateMojangServices', this._services) + } + } + + sendLastUpdate (client) { + if (this._services) { + client.emit('updateMojangServices', this._services) + } + } + + handleServiceUpdate (url, color) { + const service = SERVICE_URL_LOOKUP[url] + + if (service) { + const requiredTitle = TITLE_BY_MOJANG_COLOR[color] + + if (!this._services) { + this._services = {} + } + + if (this._services[service] !== requiredTitle) { + this._services[service] = requiredTitle + this._hasUpdated = true + } + } + } +} + +module.exports = MojangUpdater diff --git a/lib/mojang_services.js b/lib/mojang_services.js deleted file mode 100644 index 5c12cc8..0000000 --- a/lib/mojang_services.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * THIS IS LEGACY, UNMAINTAINED CODE - * IT MAY (AND LIKELY DOES) CONTAIN BUGS - * USAGE IS NOT RECOMMENDED - */ -var request = require('request'); - -var logger = require('./logger'); -var util = require('./util'); - -var serviceNameLookup = { - 'sessionserver.mojang.com': 'Sessions', - 'authserver.mojang.com': 'Auth', - 'textures.minecraft.net': 'Skins', - 'api.mojang.com': 'API' -}; - -var serviceStates = { - // Lazy populated. -}; - -function updateService(name, status) { - // Only update if we need to. - if (!(name in serviceStates) || serviceStates[name].status !== status) { - var newEntry = { - name: serviceNameLookup[name], // Send the clean name, not the URL. - status: status - }; - - // If it's an outage, track when it started. - if (status === 'yellow'|| status === 'red') { - newEntry.startTime = util.getCurrentTimeMs(); - } - - // Generate a nice title from the color. - if (status === 'green') { - newEntry.title = 'Online'; - } else if (status === 'yellow') { - newEntry.title = 'Unstable'; - } else if (status === 'red') { - newEntry.title = 'Offline'; - } else { - throw new Error('Unknown Mojang status: ' + status); - } - - // Wipe the old status in favor of the new one. - serviceStates[name] = newEntry; - } -} - -exports.update = function(timeout) { - request({ - uri: 'http://status.mojang.com/check', - method: 'GET', - timeout: timeout - }, function(err, res, body) { - if (err) { - logger.log('error', 'Failed to update Mojang services: %s', JSON.stringify(err)); - } else { - try { - body = JSON.parse(body); - - for (var i = 0; i < body.length; i++) { - var service = body[i]; - var name = Object.keys(service)[0]; // Because they return an array of object, we have to do this :( - - // If it's not in the lookup, we don't care about it. - if (name in serviceNameLookup) { - updateService(name, service[name]); - } - } - - logger.log('debug', 'Updated Mojang services: %s', JSON.stringify(serviceStates)); - } catch(err) { - // Catch anything weird that can happen, since things probably will. - logger.log('error', 'Failed to parse Mojang\'s response: %s', JSON.stringify(err)); - } - } - }); -}; - -exports.toMessage = function() { - // This is what we send to the clients. - return serviceStates; -}; diff --git a/lib/ping.js b/lib/ping.js index 308d451..6128a0c 100644 --- a/lib/ping.js +++ b/lib/ping.js @@ -1,85 +1,143 @@ -/** - * THIS IS LEGACY, UNMAINTAINED CODE - * IT MAY (AND LIKELY DOES) CONTAIN BUGS - * USAGE IS NOT RECOMMENDED - */ -var mcpe_ping = require('mcpe-ping-fixed'); -var mcpc_ping = require('mc-ping-updated'); +const dns = require('dns') -var logger = require('./logger'); -var util = require('./util'); +const minecraftJavaPing = require('mc-ping-updated') +const minecraftBedrockPing = require('mcpe-ping-fixed') -// This is a wrapper function for mc-ping-updated, mainly used to convert the data structure of the result. -function pingMinecraftPC(host, port, timeout, callback, version) { - var startTime = util.getCurrentTimeMs(); +const logger = require('./logger') - mcpc_ping(host, port, function(err, res) { - if (err) { - callback(err, null); - } else { - // Remap our JSON into our custom structure. - var favicon; +const config = require('../config') - // Ensure the returned favicon is a data URI - if (res.favicon && res.favicon.indexOf('data:image/') === 0) { - favicon = res.favicon; - } +function ping (host, port, type, timeout, callback, version) { + switch (type) { + case 'PC': + unfurlSrv(host, port, (host, port) => { + minecraftJavaPing(host, port || 25565, (err, res) => { + if (err) { + callback(err) + } else { + const payload = { + players: { + online: capPlayerCount(host, parseInt(res.players.online)) + }, + version: parseInt(res.version.protocol) + } - callback(null, { - players: { - online: capPlayerCount(host, parseInt(res.players.online)), - max: parseInt(res.players.max) - }, - version: parseInt(res.version.protocol), - latency: util.getCurrentTimeMs() - startTime, - favicon: favicon - }); - } - }, timeout, version); + // Ensure the returned favicon is a data URI + if (res.favicon && res.favicon.startsWith('data:image/')) { + payload.favicon = res.favicon + } + + callback(null, payload) + } + }, timeout, version) + }) + break + + case 'PE': + minecraftBedrockPing(host, port || 19132, (err, res) => { + if (err) { + callback(err) + } else { + callback(null, { + players: { + online: capPlayerCount(host, parseInt(res.currentPlayers)) + } + }) + } + }, timeout) + break + + default: + throw new Error('Unsupported type: ' + type) + } } -// This is a wrapper function for mcpe-ping, mainly used to convert the data structure of the result. -function pingMinecraftPE(host, port, timeout, callback) { - var startTime = util.getCurrentTimeMs(); - - mcpe_ping(host, port || 19132, function(err, res) { - if (err) { - callback(err, null); - } else { - // Remap our JSON into our custom structure. - callback(err, { - players: { - online: capPlayerCount(host, parseInt(res.currentPlayers)), - max: parseInt(res.maxPlayers) - }, - latency: util.getCurrentTimeMs() - startTime - }); - } - }, timeout); +function unfurlSrv (hostname, port, callback) { + dns.resolveSrv('_minecraft._tcp.' + hostname, (_, records) => { + if (!records || records.length < 1) { + callback(hostname, port) + } else { + callback(records[0].name, records[0].port) + } + }) } // player count can be up to 1^32-1, which is a massive scale and destroys browser performance when rendering graphs // Artificially cap and warn to prevent propogating garbage -function capPlayerCount(host, playerCount) { - const maxPlayerCount = 250000; - if (playerCount !== Math.min(playerCount, maxPlayerCount)) { - logger.log('warn', '%s returned a player count of %d, Minetrack has capped it to %d to prevent browser performance issues with graph rendering. If this is in error, please edit maxPlayerCount in ping.js!', host, playerCount, maxPlayerCount); - return maxPlayerCount; - } else if (playerCount !== Math.max(playerCount, 0)) { - logger.log('warn', '%s returned an invalid player count of %d, setting to 0.', host, playerCount); - return 0; - } - return playerCount; +function capPlayerCount (host, playerCount) { + const maxPlayerCount = 250000 + + if (playerCount !== Math.min(playerCount, maxPlayerCount)) { + logger.log('warn', '%s returned a player count of %d, Minetrack has capped it to %d to prevent browser performance issues with graph rendering. If this is in error, please edit maxPlayerCount in ping.js!', host, playerCount, maxPlayerCount) + + return maxPlayerCount + } else if (playerCount !== Math.max(playerCount, 0)) { + logger.log('warn', '%s returned an invalid player count of %d, setting to 0.', host, playerCount) + + return 0 + } + return playerCount } -exports.ping = function(host, port, type, timeout, callback, version) { - if (type === 'PC') { - util.unfurlSRV(host, port, function(host, port){ - pingMinecraftPC(host, port || 25565, timeout, callback, version); - }) - } else if (type === 'PE') { - pingMinecraftPE(host, port || 19132, timeout, callback); - } else { - throw new Error('Unsupported type: ' + type); - } -}; +class PingController { + constructor (app) { + this._app = app + } + + schedule () { + setInterval(this.pingAll, config.rates.pingAll) + + this.pingAll() + } + + pingAll = () => { + for (const serverRegistration of this._app.serverRegistrations) { + const version = serverRegistration.getNextProtocolVersion() + + ping(serverRegistration.data.ip, serverRegistration.data.port, serverRegistration.data.type, config.rates.connectTimeout, (err, resp) => { + if (err) { + logger.log('error', 'Failed to ping %s: %s', serverRegistration.data.ip, err.message) + } + + this.handlePing(serverRegistration, resp, err, version) + }, version.protocolId) + } + } + + handlePing (serverRegistration, resp, err, version) { + const timestamp = new Date().getTime() + + this._app.server.broadcast('update', serverRegistration.getUpdate(timestamp, resp, err, version)) + + serverRegistration.addPing(timestamp, resp) + + if (config.logToDatabase) { + const playerCount = resp ? resp.players.online : 0 + + // Log to database + this._app.database.insertPing(serverRegistration.data.ip, timestamp, playerCount) + + if (serverRegistration.addGraphPoint(resp !== undefined, playerCount, timestamp)) { + this._app.server.broadcast('updateHistoryGraph', { + name: serverRegistration.data.name, + playerCount: playerCount, + timestamp: timestamp + }) + } + + // Update calculated graph peak regardless if the graph is being updated + // This can cause a (harmless) desync between live and stored data, but it allows it to be more accurate for long surviving processes + if (serverRegistration.findNewGraphPeak()) { + const graphPeak = serverRegistration.getGraphPeak() + + this._app.server.broadcast('updatePeak', { + name: serverRegistration.data.name, + playerCount: graphPeak.playerCount, + timestamp: graphPeak.timestamp + }) + } + } + } +} + +module.exports = PingController diff --git a/lib/server.js b/lib/server.js index fb516a8..eb52491 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1,32 +1,64 @@ -/** - * THIS IS LEGACY, UNMAINTAINED CODE - * IT MAY (AND LIKELY DOES) CONTAIN BUGS - * USAGE IS NOT RECOMMENDED - */ const http = require('http') -const io = require('socket.io') -const finalHandler = require('finalhandler') +const finalHttpHandler = require('finalhandler') const serveStatic = require('serve-static') +const io = require('socket.io') -const util = require('./util') const logger = require('./logger') -const config = require('../config.json') - -const distHandler = serveStatic('dist/') -const faviconsHandler = serveStatic('favicons/') - -function onRequest (req, res) { - logger.log('info', '%s requested: %s', util.getRemoteAddr(req), req.url) - distHandler(req, res, function () { - faviconsHandler(req, res, finalHandler(req, res)) - }) +function getRemoteAddr (req) { + return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.connection.remoteAddress } -exports.start = function () { - const server = http.createServer(onRequest) - server.listen(config.site.port, config.site.ip) - exports.io = io.listen(server) - logger.log('info', 'Started on %s:%d', config.site.ip, config.site.port) +class Server { + constructor (clientSocketHandler) { + this._clientSocketHandler = clientSocketHandler + this._connectedSockets = 0 + + this._http = http.createServer(this.handleHttpRequest) + + this._distServeStatic = serveStatic('dist/') + this._faviconsServeStatic = serveStatic('favicons/') + } + + listen (host, port) { + this._http.listen(port, host) + + this._io = io.listen(this._http) + this._io.on('connect', this.handleClientSocket) + + logger.log('info', 'Started on %s:%d', host, port) + } + + broadcast (event, payload) { + this._io.sockets.emit(event, payload) + } + + handleHttpRequest = (req, res) => { + logger.log('info', '%s requested: %s', getRemoteAddr(req), req.url) + + // Attempt to handle req using distServeStatic, otherwise fail over to faviconServeStatic + // If faviconServeStatic fails, pass to finalHttpHandler to terminate + this._distServeStatic(req, res, () => { + this._faviconsServeStatic(req, res, finalHttpHandler(req, res)) + }) + } + + handleClientSocket = (client) => { + this._connectedSockets++ + + logger.log('info', '%s connected, total clients: %d', getRemoteAddr(client.request), this._connectedSockets) + + // Bind disconnect event for logging + client.on('disconnect', () => { + this._connectedSockets-- + + logger.log('info', '%s disconnected, total clients: %d', getRemoteAddr(client.request), this._connectedSockets) + }) + + // Pass client off to proxy handler + this._clientSocketHandler(client) + } } + +module.exports = Server diff --git a/lib/servers.js b/lib/servers.js new file mode 100644 index 0000000..8436041 --- /dev/null +++ b/lib/servers.js @@ -0,0 +1,279 @@ +const config = require('../config') +const minecraftVersions = require('../minecraft_versions') + +class ServerRegistration { + lastFavicon + versions = [] + recordData + graphData = [] + + constructor (data) { + this.data = data + this._pingHistory = [] + this._hasInitialRecordData = false + } + + getUpdate (timestamp, resp, err, version) { + const update = { + info: { + name: this.data.name, + timestamp: timestamp + } + } + + if (resp) { + if (resp.version && this.updateProtocolVersionCompat(resp.version, version.protocolId, version.protocolIndex)) { + // Append an updated version listing + update.versions = this.versions + } + + // Validate that we have logToDatabase enabled otherwise in memory pings + // will create a record that's only valid for the runtime duration. + if (config.logToDatabase && (!this._hasInitialRecordData || !this.recordData || resp.players.online > this.recordData.playerCount)) { + // For instances with existing data, recordData will be defined at boot + // This causes initial updates to NOT include recordData since it hasn't changed + // This flag force includes it to avoid "late loading" + this._hasInitialRecordData = true + + this.recordData = { + playerCount: resp.players.online, + timestamp: timestamp + } + + // Append an updated recordData + update.recordData = this.recordData + } + + // Compare against this.data.favicon to support favicon overrides + const newFavicon = resp.favicon || this.data.favicon + if (this.updateFavicon(newFavicon)) { + // Append an updated favicon + update.favicon = newFavicon + } + + // Append a result object + // This filters out unwanted data from resp + update.result = { + players: resp.players + } + } else if (err) { + // Append a filtered copy of err + // This ensures any unintended data is not leaked + update.error = this.filterError(err) + } + + return update + } + + addPing (timestamp, resp) { + const ping = { + timestamp: timestamp + } + + if (resp) { + // Append a result object + // This filters out unwanted data from resp + ping.result = { + players: { + online: resp.players.online + } + } + } + + this._pingHistory.push(ping) + + // Trim pingHistory to avoid memory leaks + if (this._pingHistory.length > 72) { + this._pingHistory.shift() + } + } + + getPingHistory () { + if (this._pingHistory.length > 0) { + const pingHistory = [] + + for (let i = 0; i < this._pingHistory.length - 1; i++) { + pingHistory[i] = this._pingHistory[i] + } + + // Insert the latest update manually into the array + // This is a mutated copy of the last update to contain live metadata + // The metadata is used by the frontend for rendering + const lastPing = this._pingHistory[this._pingHistory.length - 1] + + const payload = { + info: { + name: this.data.name + }, + timestamp: lastPing.timestamp, + versions: this.versions, + recordData: this.recordData, + favicon: this.lastFavicon + } + + // Conditionally append to avoid defining fields with undefined values + if (lastPing.result) { + payload.result = lastPing.result + } else if (lastPing.error) { + payload.error = lastPing.error + } + + // Insert the reconstructed update as the last entry + // pingHistory is already sorted during its copy from _pingHistory + pingHistory.push(payload) + + return pingHistory + } + + return [{ + error: { + message: 'Waiting...', + placeholder: true + }, + timestamp: new Date().getTime(), + info: { + name: this.data.name + }, + recordData: this.recordData + }] + } + + loadGraphPoints (points) { + // Filter pings so each result is a minute apart + const minutePoints = [] + let lastTimestamp = 0 + + for (const point of points) { + // 0 is the index of the timestamp + if (point[0] - lastTimestamp >= 60 * 1000) { + // This check tries to smooth out randomly dropped pings + // By default only filter pings that are online (playerCount > 0) + // This will keep looking forward until it finds a ping that is online + // If it can't find one within a reasonable timeframe, it will select a failed ping + if (point[0] - lastTimestamp >= 120 * 1000 || point[1] > 0) { + minutePoints.push(point) + lastTimestamp = point[0] + } + } + } + + if (minutePoints.length > 0) { + this.graphData = minutePoints + + // Select the last entry to use for lastGraphDataPush + this._lastGraphDataPush = minutePoints[minutePoints.length - 1][0] + } + } + + addGraphPoint (isSuccess, playerCount, timestamp) { + // If the ping failed, then to avoid destroying the graph, ignore it + // However if it's been too long since the last successful ping, push it anyways + if (this._lastGraphDataPush) { + const timeSince = timestamp - this._lastGraphDataPush + if ((isSuccess && timeSince < 60 * 1000) || (!isSuccess && timeSince < 70 * 1000)) { + return false + } + } + + this.graphData.push([timestamp, playerCount]) + this._lastGraphDataPush = timestamp + + // Trim old graphPoints according to graphDuration + for (let i = 1; i < this.graphData.length; i++) { + // Find a break point where i - 1 is too old and i is new + if (timestamp - this.graphData[i - 1][0] > config.graphDuration && timestamp - this.graphData[i] <= config.graphDuration) { + this.graphData.splice(0, i) + break + } + } + + return true + } + + findNewGraphPeak () { + let index = -1 + for (let i = 0; i < this.graphData.length; i++) { + const point = this.graphData[i] + if (index === -1 || point[1] > this.graphData[index][1]) { + index = i + } + } + if (index >= 0) { + const lastGraphPeakIndex = this._graphPeakIndex + this._graphPeakIndex = index + return index !== lastGraphPeakIndex + } else { + this._graphPeakIndex = undefined + return false + } + } + + getGraphPeak () { + if (this._graphPeakIndex === undefined) { + return + } + const graphPeak = this.graphData[this._graphPeakIndex] + return { + playerCount: graphPeak[1], + timestamp: graphPeak[0] + } + } + + updateFavicon (favicon) { + if (favicon && favicon !== this.lastFavicon) { + this.lastFavicon = favicon + return true + } + return false + } + + updateProtocolVersionCompat (incomingId, outgoingId, protocolIndex) { + // If the result version matches the attempted version, the version is supported + const isSuccess = incomingId === outgoingId + const indexOf = this.versions.indexOf(protocolIndex) + + // Test indexOf to avoid inserting previously recorded protocolIndex values + if (isSuccess && indexOf < 0) { + this.versions.push(protocolIndex) + + // Sort versions in ascending order + // This matches protocol ids to Minecraft versions release order + this.versions.sort((a, b) => a - b) + + return true + } else if (!isSuccess && indexOf >= 0) { + this.versions.splice(indexOf, 1) + return true + } + return false + } + + getNextProtocolVersion () { + const protocolVersions = minecraftVersions[this.data.type] + if (typeof this._nextProtocolIndex === 'undefined' || this._nextProtocolIndex + 1 >= protocolVersions.length) { + this._nextProtocolIndex = 0 + } else { + this._nextProtocolIndex++ + } + return { + protocolId: protocolVersions[this._nextProtocolIndex].protocolId, + protocolIndex: this._nextProtocolIndex + } + } + + filterError (err) { + // Attempt to match to the first possible value + for (const key of ['message', 'description', 'errno']) { + if (err[key]) { + return { + message: err[key] + } + } + } + return { + message: 'Unknown error' + } + } +} + +module.exports = ServerRegistration diff --git a/lib/util.js b/lib/util.js deleted file mode 100644 index ec222a0..0000000 --- a/lib/util.js +++ /dev/null @@ -1,174 +0,0 @@ -/** - * THIS IS LEGACY, UNMAINTAINED CODE - * IT MAY (AND LIKELY DOES) CONTAIN BUGS - * USAGE IS NOT RECOMMENDED - */ -var logger = require('./logger'); -var dns = require('dns'); - -var config = require('../config.json'); -var servers = require('../servers.json'); - -var serverNameLookup = []; - -// Finds a server in servers.json with a matching IP. -// If it finds one, it caches the result for faster future lookups. -function getServerNameByIp(ip) { - var lookupName = serverNameLookup[ip]; - - if (lookupName) { - return lookupName; - } - - for (var i = 0; i < servers.length; i++) { - var entry = servers[i]; - - if (entry.ip === ip) { - serverNameLookup[entry.ip] = entry.name; - - return entry.name; - } - } -} - -// Returns a list of configured server IPs from servers.json -function getServerIps() { - var ips = []; - - for (var i = 0; i < servers.length; i++) { - ips.push(servers[i].ip); - } - - return ips; -} - -// This method is a monstrosity. -// Since we loaded ALL pings from the database, we need to filter out the pings so each entry is a minute apart. -// This is done by iterating over the list, since the time between each ping can be completely arbitrary. -function trimUselessPings(data) { - var keys = Object.keys(data); - - for (var i = 0; i < keys.length; i++) { - var listing = data[keys[i]]; - var lastTimestamp = 0; - - var filteredListing = []; - - for (var x = 0; x < listing.length; x++) { - var entry = listing[x]; - - // 0 is the index of the timestamp. - // See the convertPingsToGraph method. - if (entry[0] - lastTimestamp >= 60 * 1000) { - // This second check tries to smooth out randomly dropped pings. - // By default we only want entries that are online (playerCount > 0). - // This way we'll keep looking forward until we find one that is online. - // However if we can't find one within a reasonable timeframe, select the sucky one. - if (entry[0] - lastTimestamp >= 120 * 1000 || entry[1] > 0) { - filteredListing.push(entry); - - lastTimestamp = entry[0]; - } - } - } - - data[keys[i]] = filteredListing; - } -} - -exports.trimOldPings = function(data) { - var keys = Object.keys(data); - - var timeMs = exports.getCurrentTimeMs(); - - for (var x = 0; x < keys.length; x++) { - var listing = data[keys[x]]; - var toSplice = []; - - for (var i = 0; i < listing.length; i++) { - var entry = listing[i]; - - if (timeMs - entry[0] > config.graphDuration) { - toSplice.push(i); - } - } - - for (var i = 0; i < toSplice.length; i++) { - listing.splice(toSplice[i], 1); - } - } -} - -exports.getCurrentTimeMs = function() { - return new Date().getTime(); -}; - -exports.stringToColor = function(base) { - var hash; - - for (var i = base.length - 1, hash = 0; i >= 0; i--) { - hash = base.charCodeAt(i) + ((hash << 5) - hash); - } - - color = Math.floor(Math.abs((Math.sin(hash) * 10000) % 1 * 16777216)).toString(16); - - return '#' + Array(6 - color.length + 1).join('0') + color; -} - -exports.setIntervalNoDelay = function(func, delay) { - var task = setInterval(func, delay); - - func(); - - return task; -}; - -exports.convertServerHistory = function(sqlData) { - var serverIps = getServerIps(); - var graphData = {}; - - var startTime = exports.getCurrentTimeMs(); - - for (var i = 0; i < sqlData.length; i++) { - var entry = sqlData[i]; - - if (serverIps.indexOf(entry.ip) === -1) continue; - - var name = getServerNameByIp(entry.ip); - - if (!graphData[name]) graphData[name] = []; - graphData[name].push([entry.timestamp, entry.playerCount]); - } - - // Break it into minutes. - trimUselessPings(graphData); - - // Drop old data. - exports.trimOldPings(graphData); - - logger.info('Parsed ' + sqlData.length + ' ping records in ' + (exports.getCurrentTimeMs() - startTime) + 'ms'); - - return graphData; -}; - -/** - * Attempts to resolve Minecraft PC SRV records from DNS, otherwise falling back to the old hostname. - * - * @param hostname hostname to check - * @param port port to pass to callback if required - * @param callback function with a hostname and port parameter - */ -exports.unfurlSRV = function(hostname, port, callback) { - dns.resolveSrv("_minecraft._tcp."+hostname, function (err, records) { - if(!records||records.length<=0) { - callback(hostname, port); - return; - } - callback(records[0].name, records[0].port); - }) -}; - -exports.getRemoteAddr = function(req) { - let remoteAddress = req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.connection.remoteAddress; - return remoteAddress; -}; \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..32c53d1 --- /dev/null +++ b/main.js @@ -0,0 +1,36 @@ +const App = require('./lib/app') +const ServerRegistration = require('./lib/servers') + +const logger = require('./lib/logger') + +const config = require('./config') +const servers = require('./servers') + +const app = new App() + +servers.forEach(server => { + // Assign a generated color for each servers.json entry if not manually defined + // These will be passed to the frontend for use in rendering + if (!server.color) { + let hash = 0 + for (let i = server.name.length - 1; i >= 0; i--) { + hash = server.name.charCodeAt(i) + ((hash << 5) - hash) + } + + const color = Math.floor(Math.abs((Math.sin(hash) * 10000) % 1 * 16777216)).toString(16) + server.color = '#' + Array(6 - color.length + 1).join('0') + color + } + + // Init a ServerRegistration instance of each entry in servers.json + app.serverRegistrations.push(new ServerRegistration(server)) +}) + +if (!config.logToDatabase) { + logger.log('warn', 'Database logging is not enabled. You can enable it by setting "logToDatabase" to true in config.json. This requires sqlite3 to be installed.') + + app.handleReady() +} else { + app.loadDatabase(() => { + app.handleReady() + }) +} diff --git a/package.json b/package.json index 1d29df6..ba9de37 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "minetrack", - "version": "5.0.0", + "version": "5.1.0", "description": "A Minecraft server tracker that lets you focus on the basics.", - "main": "app.js", + "main": "main.js", "dependencies": { "finalhandler": "^1.1.2", "mc-ping-updated": "0.1.1", diff --git a/scripts/start.sh b/scripts/start.sh index d2ffccd..f61832e 100644 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -1,5 +1,5 @@ while true; do - node app.js + node main.js sleep 5 done \ No newline at end of file