From d4aeaabad2007c6eb71e381b9a2bd5bc6009dae1 Mon Sep 17 00:00:00 2001 From: ShadowNinja Date: Mon, 30 Jun 2014 17:01:29 -0400 Subject: [PATCH] Initial commit --- .gitignore | 4 + README.md | 79 +++++++++++ config.py | 26 ++++ server.py | 319 +++++++++++++++++++++++++++++++++++++++++++++ static/index.html | 11 ++ static/list.js | 101 ++++++++++++++ static/servers.jst | 74 +++++++++++ static/style.css | 35 +++++ 8 files changed, 649 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.py create mode 100755 server.py create mode 100644 static/index.html create mode 100644 static/list.js create mode 100644 static/servers.jst create mode 100644 static/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0efcbb1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*~ +static/list.json +static/servers.js + diff --git a/README.md b/README.md new file mode 100644 index 0000000..350f36a --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +Minetest server list +==================== + +Setting up the webpage +---------------------- + +You will have to install node.js, doT.js and their dependencies to compile +the serverlist webpage template. + +First install node.js, e.g.: + + # apt-get install nodejs + # # OR: + # pacman -S nodejs + # # OR: + # emerge nodejs + +Then install doT.js and its dependencies: + + $ cd ~ + $ npm install dot commander mkdirp + +And finally compile the template: + + $ cd util/master/static + $ ~/node_modules/dot/bin/dot-packer -s . -d . + +You can now serve the webpage by copying the files in static/ to your web root, or by [starting the master server](#setting-up-the-server). + + +Embedding the server list in a page +----------------------------------- + + + ... + + ... + + + ... +
+ ... + + + + +Setting up the server +--------------------- + + 1. Install Python 3 and pip: + + # pacman -S python python-pip + # # OR: + # apt-get install python3 python3-pip + + 2. Install Flask, APSchedule, and (if using in production) uwsgi: + + # # You might have to use pip3 if your system defaults to Python 2 + # pip install APSchedule flask uwsgi + + 3. Configure the server by changing options in config.py, which is a Flask + configuration file. + + 4. Start the server: + + $ ./server.py + $ # Or for production: + $ uwsgi -s /tmp/serverlist.sock -w server:app + $ # Then configure according to http://flask.pocoo.org/docs/deploying/uwsgi/ + diff --git a/config.py b/config.py new file mode 100644 index 0000000..895858e --- /dev/null +++ b/config.py @@ -0,0 +1,26 @@ + +# Enables detailed tracebacks and an interactive Python console on errors. +# Never use in production! +#DEBUG = True + +# Makes the server more performant at sending static files when the +# server is being proxied by a server that supports X-Sendfile. +#USE_X_SENDFILE = True + +# Address to listen for clients on +HOST = "0.0.0.0" + +# Port to listen on +PORT = 8000 + +# File to store the JSON server list data in. +FILENAME = "list.json" + +# Ammount of time, is seconds, after which servers are removed from the list +# if they haven't updated their listings. Note: By default Minetest servers +# only announce once every 5 minutes, so this should be more than 300. +PURGE_TIME = 350 + +# List of banned IP addresses. +BANLIST = [] + diff --git a/server.py b/server.py new file mode 100755 index 0000000..8bab7bd --- /dev/null +++ b/server.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +import os, sys, json, time, socket +from threading import Thread, RLock +from operator import itemgetter + +from apscheduler.scheduler import Scheduler +from flask import Flask, request, send_from_directory + +serverList = [] +maxServers = 0 +maxClients = 0 +listLock = RLock() + +sched = Scheduler() +sched.start() + +app = Flask(__name__, static_url_path = "") +app.config.from_pyfile("config.py") + +@app.route("/") +def index(): + return app.send_static_file("index.html") + + +@app.route("/list") +def list(): + # We have to make sure that the list isn't cached, + # since the list isn't really static. + return send_from_directory(app.static_folder, app.config["FILENAME"], + cache_timeout=0) + + +@app.route("/announce", methods=["GET", "POST"]) +def announce(): + ip = request.remote_addr + if ip.startswith("::ffff:"): + ip = ip[7:] + + if ip in app.config["BANLIST"]: + return "Banned.", 403 + + if request.method == "POST": + data = request.form["json"] + else: + data = request.args["json"] + + if len(data) > 5000: + return "JSON data is too big.", 413 + + try: + server = json.loads(data) + except: + return "Unable to process JSON data.", 400 + + if not "action" in server: + return "Missing action field.", 400 + + if server["action"] == "start": + server["uptime"] = 0 + + if server["action"] != "delete" and not checkRequest(server): + return "Invalid JSON data.", 400 + + server["ip"] = ip + + if not "port" in server: + server["port"] = 30000 + + old = getServer(server["ip"], server["port"]) + + if server["action"] == "delete": + if not old: + return "Server not found.", 500 + removeServer(old) + saveList() + return "Removed from server list." + + if server["action"] != "start" and not old: + # Server to update not found, continue as a new server + server["action"] = "start" + + server["update_time"] = time.time() + + if server["action"] == "start": + server["start"] = time.time() + else: + server["start"] = old["start"] + + if "clients_list" in server: + server["clients"] = len(server["clients_list"]) + + if old: + server["clients_top"] = max(server["clients"], old["clients_top"]) + else: + server["clients_top"] = server["clients"] + + # Make sure that startup options don't change + if server["action"] != "start": + if "mods" in old: + server["mods"] = old["mods"] + + # Popularity + if old: + server["updates"] = old["updates"] + 1 + # This is actally a count of all the client numbers we've received, + # it includes clients that were on in the previous update. + server["total_clients"] = old["total_clients"] + server["clients"] + else: + server["updates"] = 1 + server["total_clients"] = server["clients"] + server["pop_v"] = server["total_clients"] / server["updates"] + + finishRequestAsync(server) + + return "Thanks, your request has been filed.", 202 + + +# Returns ping time in seconds (up), False (down), or None (error). +def serverUp(address, port): + try: + start = time.time() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(3) + buf = b"\x4f\x45\x74\x03\x00\x00\x00\x01" + sock.sendto(buf, (address, port)) + data, addr = sock.recvfrom(1000) + if not data: + return False + peer_id = data[12:14] + buf = b"\x4f\x45\x74\x03" + peer_id + b"\x00\x00\x03" + sock.sendto(buf, (address, port)) + sock.close() + end = time.time() + return end - start + except socket.timeout: + return False + except: + return None + + +def getServerAndIndex(ip, port): + with listLock: + for i, server in enumerate(serverList): + if server["ip"] == ip and server["port"] == port: + return (i, server) + + +def getServer(ip, port): + server = getServerAndIndex(ip, port) + return server and server[1] + + +def removeServer(server): + with listLock: + try: + serverList.remove(server) + except: + pass + + +def sortList(): + with listLock: + serverList.sort(key=itemgetter("clients", "start"), reverse=True) + +@sched.interval_schedule(minutes=1, coalesce=True, max_instances=1) +def purgeOld(): + with listLock: + for server in serverList: + if server["update_time"] < time.time() - app.config["PURGE_TIME"]: + serverList.remove(server) + saveList() + + +def loadList(): + global serverList, maxServers, maxClients + try: + with open(os.path.join("static", app.config["FILENAME"]), "r") as fd: + data = json.load(fd) + except FileNotFoundError: + return + if not data: + return + with listLock: + serverList = data["list"] + maxServers = data["total_max"]["servers"] + maxClients = data["total_max"]["clients"] + + +def saveList(): + global maxServers, maxClients + with listLock: + servers = len(serverList) + clients = 0 + for server in serverList: + clients += server["clients"] + + maxServers = max(servers, maxServers) + maxClients = max(clients, maxClients) + + with open(os.path.join("static", app.config["FILENAME"]), "w") as fd: + json.dump({ + "total": {"servers": servers, "clients": clients}, + "total_max": {"servers": maxServers, "clients": maxClients}, + "list": serverList + }, + fd, + indent = "\t" if app.config["DEBUG"] else None) + + +# fieldName: (Required, Type, SubType) +fields = { + "action": (True, "str"), + + "address": (False, "str"), + "port": (False, "int"), + + "clients": (True, "int"), + "clients_max": (True, "int"), + "uptime": (True, "int"), + "game_time": (True, "int"), + "lag": (False, "float"), + + "clients_list": (False, "list", "str"), + "mods": (False, "list", "str"), + + "version": (True, "str"), + "gameid": (True, "str"), + "mapgen": (False, "str"), + "url": (False, "str"), + "privs": (False, "str"), + "name": (True, "str"), + "description": (True, "str"), + + # Flags + "creative": (False, "bool"), + "dedicated": (False, "bool"), + "damage": (False, "bool"), + "liquid_finite": (False, "bool"), + "pvp": (False, "bool"), + "password": (False, "bool"), + "rollback": (False, "bool"), + "can_see_far_names": (False, "bool"), +} +def checkRequest(server): + for name, data in fields.items(): + if not name in server: + if data[0]: return False + else: continue + #### Compatibility code #### + # Accept anything in boolean fields but convert it to a + # boolean, because old servers send some booleans as strings. + if data[1] == "bool": + server[name] = True if server[name] else False + continue + # clients_max and port were sent as strings instead of integers + if (name == "clients_max" or name == "port") and\ + type(server[name]).__name__ == "str": + server[name] = int(server[name]) + continue + #### End compatibility code #### + if type(server[name]).__name__ != data[1]: + return False + if len(data) >= 3: + for item in server[name]: + if type(item).__name__ != data[2]: + return False + return True + + +def finishRequestAsync(server): + th = Thread(name = "ServerListThread", + target = asyncFinishThread, + args = (server,)) + th.start() + + +def asyncFinishThread(server): + if "address" in server and server["address"] != "": + try: + info = socket.getaddrinfo(server["address"], server["port"]) + except: + app.logger.warning("Unable to get address info for %s." % (server["address"],)) + return + addresses = set(data[4][0] for data in info) + found = False + for addr in addresses: + if server["ip"] == addr: + found = True + break + if not found: + app.logger.warning("Invalid IP %s for address %s (address valid for %s)." + % (server["ip"], server["address"], addresses)) + return + else: + server["address"] = server["ip"] + + server["ping"] = serverUp(server["address"], server["port"]) + if not server["ping"]: + return + + del server["action"] + + with listLock: + old = getServerAndIndex(server["ip"], server["port"]) + if old: + serverList[old[0]] = server + else: + serverList.append(server) + + sortList() + saveList() + + +loadList() +purgeOld() + +if __name__ == "__main__": + app.run(host = app.config["HOST"], port = app.config["PORT"]) + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..48a973e --- /dev/null +++ b/static/index.html @@ -0,0 +1,11 @@ + + + + + Minetest server list + + +
+ + + diff --git a/static/list.js b/static/list.js new file mode 100644 index 0000000..2965d3d --- /dev/null +++ b/static/list.js @@ -0,0 +1,101 @@ +var master; +if (!master) master = {}; +if (typeof(master.root) == 'undefined') master.root = window.location.href; +if (!master.output) master.output = '#server_list'; +if (!master.list) master.list = "list"; +if (!master.list_root) master.list_root = master.root; +if (!master.list_url) master.list_url = master.list_root + master.list; + +function humanTime(seconds) { + if (!seconds) return '?'; + var conv = { + y: 31536000, + d: 86400, + h: 3600, + m: 60 + } + for (var i in conv) { + if (seconds >= conv[i]) { + return (seconds / conv[i]).toFixed(1) + i; + } + } + return seconds + 's'; +} + +function escapeHTML(str) { + if(!str) return str; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function addressString(server) { + var isIPv6 = server.address.indexOf(":") != -1; + var addrStr = (isIPv6 ? '[' : '') + + escapeHTML(server.address) + + (isIPv6 ? ']' : ''); + var shortStr = addrStr; + addrStr += ':' + server.port; + var str = ' 25) { + shortStr = shortStr.substr(0, 23) + "…"; + str += ' class="mts_tooltip" title="' + addrStr + '"' + } + if (server.port != 30000) + shortStr += ':' + server.port; + return str + '>' + shortStr + ''; +} + +function tooltipString(str, maxLen) { + str = escapeHTML(str); + var shortStr = str; + var ret = ' maxLen) { + shortStr = shortStr.substr(0, maxLen - 2) + "…"; + ret += ' class="mts_tooltip" title="' + str + '"'; + } + return ret + '>' + shortStr + ''; +} + +function hoverList(name, list) { + if (!list || list.length == 0) return ''; + var str = '
' + str += name + '(' + list.length + ')
'; + for (var i in list) { + str += escapeHTML(list[i]) + '
'; + } + return str + '
'; +} + +function hoverString(name, string) { + if (!string) return ''; + return '
' + + name + ':
' + + escapeHTML(string) + '
' + + '
'; +} + +function draw(json) { + html = window.render.servers(json); + jQuery(master.output).html(html); +} + +function get() { + jQuery.getJSON(master.list_url, draw); +} + +function loaded(){ + if (!master.no_refresh) { + setInterval(get, 60 * 1000); + } + get(); +} + +// https://github.com/pyrsmk/toast +this.toast=function(){var e=document,t=e.getElementsByTagName("head")[0],n=this.setTimeout,r="createElement",i="appendChild",s="addEventListener",o="onreadystatechange",u="styleSheet",a=10,f=0,l=function(){--f},c,h=function(e,r,i,s){if(!t)n(function(){h(e)},a);else if(e.length){c=-1;while(i=e[++c]){if((s=typeof i)=="function"){r=function(){return i(),!0};break}if(s=="string")p(i);else if(i.pop){p(i[0]),r=i[1];break}}d(r,Array.prototype.slice.call(e,c+1))}},p=function(n,s){++f,/\.css$/.test(n)?(s=e[r]("link"),s.rel=u,s.href=n,t[i](s),v(s)):(s=e[r]("script"),s.src=n,t[i](s),s[o]===null?s[o]=m:s.onload=l)},d=function(e,t){if(!f)if(!e||e()){h(t);return}n(function(){d(e,t)},a)},v=function(e){if(e.sheet||e[u]){l();return}n(function(){v(e)},a)},m=function(){/ded|co/.test(this.readyState)&&l()};h(arguments)}; + +toast(master.root + 'style.css', master.root + 'servers.js', function() { + if (typeof(jQuery) != 'undefined') + return loaded(); + else + toast('//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js', loaded); +}); + diff --git a/static/servers.jst b/static/servers.jst new file mode 100644 index 0000000..54eea28 --- /dev/null +++ b/static/servers.jst @@ -0,0 +1,74 @@ +{{? !master.no_total}} +
+ Players: {{=it.total.clients}}/{{=it.total_max.clients}}  + Servers: {{=it.total.servers}}/{{=it.total_max.servers}} +
+{{?}} + + + {{? !master.no_address}}{{?}} + {{? !master.no_clients}}{{?}} + {{? !master.no_version}}{{?}} + {{? !master.no_name}}{{?}} + {{? !master.no_description}}{{?}} + {{? !master.no_flags}}{{?}} + {{? !master.no_uptime}}{{?}} + {{? !master.no_ping}}{{?}} + + {{~it.list :server:index}} + {{ if (master.limit && index + 1 > master.limit) break;}} + {{ if (master.min_clients && server.clients < master.min_clients) continue;}} + + {{? !master.no_address}} + {{?}} + {{? !master.no_clients}} + {{?}} + {{? !master.no_version}} + {{?}} + {{? !master.no_name}} + {{?}} + {{? !master.no_description}} + {{?}} + {{? !master.no_flags}} + {{?}} + {{? !master.no_uptime}} + {{?}} + {{? !master.no_ping}} + {{?}} + + {{~}} +
IP[:Port]Players/Max{{? !master.no_avgtop}}
Avg/Top{{?}}
Version, Gameid, MapGenNameDescriptionFlagsUptime, AgePing, Lag
+ {{=addressString(server)}} + + {{=server.clients}}/{{=server.clients_max}}{{? !master.no_avgtop}}   {{=Math.floor(server.pop_v)}}/{{=server.clients_top}}{{?}} + {{=hoverList("Clients", server.clients_list)}} + + {{=escapeHTML(server.version)}}, {{=escapeHTML(server.gameid)}},  + {{=escapeHTML(server.mapgen || '?')}} + {{=hoverList("Mods", server.mods)}} + + {{? server.url}} + {{=tooltipString(server.name, 25)}} + {{??}} + {{=tooltipString(server.name, 25)}} + {{?}} + + {{=tooltipString(server.description, 50)}} + + {{=hoverString("Privs", server.privs)}} + {{=server.creative ? 'Cre ' : ''}} + {{=server.dedicated ? 'Ded ' : ''}} + {{=server.damage ? 'Dmg ' : ''}} + {{=server.liquid_finite ? 'Liq ' : ''}} + {{=server.pvp ? 'PvP ' : ''}} + {{=server.password ? 'Pwd ' : ''}} + {{=server.rollback ? 'Rol ' : ''}} + {{=server.can_see_far_names ? 'Far ' : ''}} + + {{=humanTime(server.uptime)}}, {{=humanTime(server.game_time)}} + + {{=Math.floor(server.ping * 1000)}}{{? server.lag}}, {{= Math.floor(server.lag * 1000)}}{{?}} +
+{{? master.min_clients || master.limit}} + More... +{{?}} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..13bcf29 --- /dev/null +++ b/static/style.css @@ -0,0 +1,35 @@ +#server_list table { + max-width: 100%; + width: 100%; + background-color: transparent; + border-collapse: collapse; + border-spacing: 0; + font-size: small; +} + +#server_list td, #server_list th { + border: 1px solid gray; +} + +.mts_hover_list{ + visibility: hidden; + border: gray solid 1px; + position: absolute; + z-index: 100; + background-color: white; + padding: 0.5em; +} + +td:hover .mts_hover_list { + visibility: visible; +} + +.mts_hover_list_text, .mts_tooltip { + text-decoration: underline; + text-decoration-style: dashed; +} + +.clickable { + text-decoration: underline; + cursor: pointer; +} \ No newline at end of file