#!/usr/bin/python3 import hashlib import os import flask import werkzeug import argparse import sys import json import datetime from pygbx import Gbx, GbxType import pygbx import tm2020parser import notifications import sqlalchemy from sqlalchemy import Column, Integer, String, Boolean, or_, and_, asc, desc from flask_sqlalchemy import SQLAlchemy import os from flask import send_from_directory, abort app = flask.Flask("TM Friends Replay Server") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("DB_URL") or "sqlite:///sqlite.db" app.config["AUTH_HEADER"] = os.environ.get("AUTH_HEADER") or "X-Forwarded-Preferred-Username" S3_ENDPOINT_URL = os.getenv("S3_ENDPOINT_URL") db = SQLAlchemy(app) SEASON_ORDERING = ["Winter", "Spring", "Summer", "Fall"] def filter_for_current_season(maps): maps = list(maps) year = str(datetime.datetime.now().year) season = 0 for m in maps: if year not in m.mapname: continue else: for i, season_name in enumerate(SEASON_ORDERING): if season_name in m.mapname and i > season: season = i filter_func = lambda m: SEASON_ORDERING[season] in m.mapname and year in m.mapname maps_season = list(filter(filter_func, maps)) return maps_season from sqlalchemy import Column, String, Integer, Boolean, ForeignKey, Index, or_, func from sqlalchemy.orm import relationship, selectinload class Map(db.Model): __tablename__ = "maps" map_uid = Column(String, primary_key=True) mapname = Column(String) game = Column(String) replays = relationship( "ParsedReplay", primaryjoin="Map.map_uid == foreign(ParsedReplay.map_uid)", lazy="raise", viewonly=True, ) def get_best_replay(self): return getattr(self, "_best_replay", None) def get_second_best_replay(self): return getattr(self, "_second_best_replay", None) def get_best_replay_for_player(self, player): return getattr(self, "_player_best_replay", None) def get_record_replay_percent_diff(self): best = self.get_best_replay() second = self.get_second_best_replay() if not best or not second: return "" if best.race_time == second.race_time: return f"Tied by {second.clean_login()}" return f"+ {(second.race_time - best.race_time) / best.race_time * 100:.2f}% by {second.clean_login()}" def get_best_replay_repr(self): best = self.get_best_replay() return str(best) if best else "-" def get_best_replay_age(self): best = self.get_best_replay() if not best: return None return (datetime.datetime.now() - datetime.datetime.fromisoformat(best.upload_dt)).days class UserSettings(db.Model): __tablename__ = "user_settings" user = Column(String, primary_key=True) show_tm_2020 = Column(Boolean) show_tmnf = Column(Boolean) show_tm_2020_current = Column(Boolean) notifications_all = Column(Boolean) notifications_self = Column(Boolean) class ParsedReplay(db.Model): __tablename__ = "replays" filehash = Column(String, primary_key=True) ghost_id = Column(Integer) race_time = Column(Integer) uploader = Column(String) filepath = Column(String) upload_dt = Column(String) map_uid = Column(String, ForeignKey("maps.map_uid"), index=True) login = Column(String) cp_times = Column(String) game = Column(String) login_uid_tm2020 = Column(String) __table_args__ = ( Index("ix_replays_map_uid_race_time", "map_uid", "race_time"), Index("ix_replays_map_uid_login_race_time", "map_uid", "login", "race_time"), ) def clean_login(self): if "/" in self.login: return self.login.split("/")[0] else: return self.login def get_human_readable_time(self): t = datetime.timedelta(microseconds=self.race_time*1000) t_string = str(t) if t.seconds < 60*60: t_string = t_string[2:] if t.microseconds != 0: if self.game == "tmnf": return t_string[:-4] else: return t_string[:-3] return t_string + ".00" def __repr__(self): return "{time} on {map_n} by {login}".format( time=self.get_human_readable_time(), map_n=self.map_uid, login=self.login) def to_dict(self): d = dict() d.update({ "login" : self.login }) d.update({ "filehash" : self.filehash }) d.update({ "race_time" : self.get_human_readable_time() }) d.update({ "filepath" : self.filepath }) d.update({ "upload_dt" : self.upload_dt }) return d @app.route("/update-user-settings", methods=["GET", "POST"]) def update_user_settings(): user = flask.request.headers.get(app.config["AUTH_HEADER"]) user_helper = user or "anonymous" settings = db.session.query(UserSettings).filter(UserSettings.user==user_helper).first() # handle new settings #/ if not settings: settings = UserSettings(user=user_helper, show_tm_2020=False, show_tmnf=False, show_tm_2020_current=True, notifications_self=True, notifications_all=False) db.session.add(settings) db.session.commit() settings = db.session.query(UserSettings).filter(UserSettings.user==user_helper).first() if flask.request.method == "GET": key = flask.request.args.get("key") # some sanity checks # if not key: return ("key missing in args", 422) if key == "user" or key.startswith("_") or "sql" in key: return ("key {} is not allowed".format(key), 422) # return attribute # return (str(getattr(settings, key)), 200) elif flask.request.method == "POST": json_dict = flask.request.json key_value_list = json_dict.get("payload") if not key_value_list: return ("'payload' field empty", 422) for el in key_value_list: key = el.get("key") value = el.get("value") if key == "user" or key.startswith("_") or "sql" in key: return ("key {} is not allowed".format(key), 422) if key is None or value is None: return ("element in payload list does not contain key and value", 422) try: getattr(settings, key) setattr(settings, key, bool(value)) db.session.merge(settings) db.session.commit() return ("", 204) except AttributeError: return ("key {} not part of user settings".format(key), 422) else: raise AssertionError("Unsupported Method: {}".format(flask.request.method)) class DataTable(): def __init__(self, d, cols): self.draw = int(d["draw"]) self.start = int(d["start"]) self.length = int(d["length"]) self.trueLength = -1 self.searchValue = d["search[value]"] self.searchIsRegex = d["search[regex]"] self.cols = cols self.orderByCol = int(d["order[0][column]"]) self.orderDirection = d["order[0][dir]"] # order variable for use with pythong sorted etc # self.orderAsc = self.orderDirection == "asc" # oder variable for use with sqlalchemy if self.orderAsc: self.orderAscDbClass = sqlalchemy.asc self.orderAscDbClassReverse = sqlalchemy.asc else: self.orderAscDbClass = sqlalchemy.desc self.orderAscDbClassReverse = sqlalchemy.desc def __build(self, results, total, filtered): self.cacheResults = results count = 0 resultDicts = [ r.to_dict() for r in results ] # data list must have the correct order (same as table scheme) # rows = [] for r in resultDicts: singleRow = [] for key in self.cols: singleRow.append(r[key]) rows.append(singleRow) d = dict() d.update({ "draw" : self.draw }) d.update({ "recordsTotal" : total }) d.update({ "recordsFiltered" : filtered }) d.update({ "data" : rows }) return d def get(self, map_uid=None): filtered = 0 total = 0 # base query query = db.session.query(ParsedReplay) if map_uid: print("Filter for map: {}".format(map_uid)) query = query.filter(ParsedReplay.map_uid == map_uid) total = query.count() if self.searchValue: # search string (search for all substrings individually # filterQuery = query for substr in self.searchValue.split(" "): searchSubstr = "%{}%".format(substr.strip()) filterQuery = filterQuery.filter(ParsedReplay.tags.like(searchSubstr)) filtered = filterQuery.count() results = filterQuery.offset(self.start).limit(self.length).all() else: query = query.order_by(self.orderAscDbClassReverse(ParsedReplay.race_time)) results = query.offset(self.start).limit(self.length).all() filtered = total return self.__build(results, total, filtered) def get_all_maps(self): filtered = 0 total = 0 # base query query = db.session.query(Map) total = query.count() if self.searchValue: # search string (search for all substrings individually # filterQuery = query for substr in self.searchValue.split(" "): searchSubstr = "%{}%".format(substr.strip()) filterQuery = filterQuery.filter(Map.mapname.like(searchSubstr)) filtered = filterQuery.count() results = filterQuery.offset(self.start).limit(self.length).all() else: query = query.order_by(self.orderAscDbClassReverse(Map.mapname)) results = query.offset(self.start).limit(self.length).all() filtered = total return self.__build(results, total, filtered) from sqlalchemy.orm import aliased def load_map_replays(maps, player=None): maps = list(maps) if not maps: return maps map_uids = [m.map_uid for m in maps] player_best_ranked = ( db.session.query( ParsedReplay.filehash.label("filehash"), func.row_number().over( partition_by=(ParsedReplay.map_uid, ParsedReplay.login), order_by=(ParsedReplay.race_time.asc(), ParsedReplay.filehash.asc()), ).label("player_replay_rank"), ) .filter(ParsedReplay.map_uid.in_(map_uids)) .subquery() ) player_best = ( db.session.query(ParsedReplay) .join( player_best_ranked, player_best_ranked.c.filehash == ParsedReplay.filehash, ) .filter(player_best_ranked.c.player_replay_rank == 1) .subquery() ) map_ranked = ( db.session.query( player_best.c.filehash, func.row_number().over( partition_by=player_best.c.map_uid, order_by=(player_best.c.race_time.asc(), player_best.c.filehash.asc()), ).label("map_rank"), ) .subquery() ) RankedReplay = aliased(ParsedReplay) ranked_results = ( db.session.query(RankedReplay, map_ranked.c.map_rank) .join(map_ranked, map_ranked.c.filehash == RankedReplay.filehash) .filter(map_ranked.c.map_rank <= 2) .all() ) ranked_by_map = {} for replay, rank in ranked_results: ranked_by_map.setdefault(replay.map_uid, {})[rank] = replay personal_by_map = {} if player and player != "anonymous": personal_ranked = ( db.session.query( ParsedReplay.filehash, func.row_number().over( partition_by=ParsedReplay.map_uid, order_by=(ParsedReplay.race_time.asc(), ParsedReplay.filehash.asc()), ).label("personal_rank"), ) .filter( ParsedReplay.map_uid.in_(map_uids), or_( ParsedReplay.uploader == player, ParsedReplay.login == player, ), ) .subquery() ) personal_results = ( db.session.query(ParsedReplay) .join(personal_ranked, personal_ranked.c.filehash == ParsedReplay.filehash) .filter(personal_ranked.c.personal_rank == 1) .all() ) personal_by_map = {replay.map_uid: replay for replay in personal_results} for map_obj in maps: ranked = ranked_by_map.get(map_obj.map_uid, {}) map_obj._best_replay = ranked.get(1) map_obj._second_best_replay = ranked.get(2) map_obj._player_best_replay = personal_by_map.get(map_obj.map_uid) return maps def _extracted_login_from_file(fullpath): '''Extract a login from a tmnf 2020 replay manually''' # TODO fix underscores in filenames # if "its_a_sheppy" in fullpath: login_from_filename = "its_a_sheppy" else: login_from_filename = os.path.basename(fullpath).split("_")[0] with open(fullpath, "rb") as f: content = f.read() decoded_string = content.decode("ascii", errors="ignore") if login_from_filename not in decoded_string: raise ValueError("Login indicated by filename does not match login in file") return login_from_filename def replay_from_path(fullpath, uploader=None): '''Load a replay from uploaded path''' # use ghost wrapper to parse both tmnf and tm2020 # ghost = tm2020parser.GhostWrapper(fullpath, uploader) # build a database replay from ghost wrapper # replay = ParsedReplay(filehash=ghost.filehash, race_time=ghost.race_time, uploader=ghost.uploader, filepath=ghost.fullpath, map_uid=ghost.map_uid, ghost_id=ghost.ghost_id, login=ghost.login, login_uid_tm2020=ghost.login_uid_tm2020, upload_dt=ghost.upload_dt, cp_times=ghost.cp_times, game=ghost.game) # build database map object from replay # m = Map(map_uid=replay.map_uid, mapname=replay.map_uid, game=replay.game) # merge the map & commit and return the replay # db.session.merge(m) db.session.commit() return replay def get_number_of_rank_x(rank): rank = int(rank) if rank < 1 or rank > 10: raise ValueError("Rank query must be between 1 and 10 (was {}".format(rank)) rank_query = '''SELECT login,COUNT(login) FROM (SELECT dISTINCT login||map_uid as dis, login, map_uid FROM replays r WHERE r.login IN ( SELECT login FROM replays r2 WHERE r2.map_uid = r.map_uid ORDER BY r2.race_time ASC LIMIT {limit} OFFSET {offset} ) ) GROUP BY login;'''.format(limit=rank, offset=rank-1) sql_query = sqlalchemy.sql.text(rank_query) result = db.session.execute(sql_query).all() return dict((login, count) for login, count in sorted(result, key=lambda x: x[1], reverse=True)) @app.route("/ranking-overview") def ranks(): rank_dict = { 1 : get_number_of_rank_x(1), 2 : get_number_of_rank_x(2), 3 : get_number_of_rank_x(3), } return flask.render_template("rank-info.html", rank_dict=rank_dict) @app.route("/map-info") def map_info(): player = flask.request.headers.get(app.config["AUTH_HEADER"]) header_col = ["Player", "Time", "Date", "Replay"] map_uid = flask.request.args.get("map_uid") return flask.render_template("map-info.html", header_col=header_col, map_uid=map_uid, player=player) import time import time @app.route("/") def mapnames(): started = time.perf_counter() player = flask.request.headers.get(app.config["AUTH_HEADER"]) or "anonymous" settings = db.session.get(UserSettings, player) after_settings = time.perf_counter() maps_query = db.session.query(Map).order_by(Map.mapname.asc()) if settings: if not settings.show_tm_2020 and not settings.show_tmnf: maps_query = maps_query.filter(Map.game == "tm2020") elif settings.show_tm_2020 and not settings.show_tmnf: maps_query = maps_query.filter(Map.game == "tm2020") elif not settings.show_tm_2020 and settings.show_tmnf: maps_query = maps_query.filter(Map.game == "tmnf") maps = maps_query.all() if settings and not settings.show_tm_2020 and not settings.show_tmnf: latest_season = tm2020parser.get_latest_season_from_maps(maps) if latest_season: maps = [m for m in maps if m.map_uid.startswith(latest_season)] allowed = ("A", "B", "C", "D", "E", "Fall", "Winter", "Spring", "Summer") maps = [m for m in maps if m.mapname.startswith(allowed)] if settings and settings.show_tm_2020_current: maps = filter_for_current_season(maps) after_maps = time.perf_counter() map_uids = [m.map_uid for m in maps] replays = [] if map_uids: replays = ( db.session.query(ParsedReplay) .filter(ParsedReplay.map_uid.in_(map_uids)) .order_by(ParsedReplay.map_uid.asc(), ParsedReplay.race_time.asc()) .all() ) after_replays = time.perf_counter() replays_by_map = {} for replay in replays: replays_by_map.setdefault(replay.map_uid, []).append(replay) for map_obj in maps: map_replays = replays_by_map.get(map_obj.map_uid, []) map_obj._best_replay = map_replays[0] if map_replays else None map_obj._second_best_replay = next( ( replay for replay in map_replays if map_obj._best_replay and replay.login != map_obj._best_replay.login ), None, ) map_obj._player_best_replay = next( ( replay for replay in map_replays if replay.login == player or replay.uploader == player ), None, ) after_grouping = time.perf_counter() response = flask.render_template( "index.html", maps=maps, player=player, ) after_render = time.perf_counter() print({ "settings_ms": round((after_settings - started) * 1000, 2), "maps_ms": round((after_maps - after_settings) * 1000, 2), "replays_ms": round((after_replays - after_maps) * 1000, 2), "grouping_ms": round((after_grouping - after_replays) * 1000, 2), "render_ms": round((after_render - after_grouping) * 1000, 2), "total_ms": round((after_render - started) * 1000, 2), "map_count": len(maps), "replay_count": len(replays), }, file=sys.stderr) return response @app.route("/open-info") def openinfo(): maps = db.session.query(Map).order_by(asc(Map.mapname)).all() data = dict() for m in maps: best_replay = m.get_best_replay() player = best_replay.clean_login() race_time = best_replay.race_time data.update( { m.mapname : { "player" : player, "time" : race_time } } ) return flask.jsonify(data) @app.route("/data-source/", methods=["POST"]) def source(map_uid): # path = map_uid dt = DataTable(flask.request.form.to_dict(), ["login", "race_time", "upload_dt", "filehash" ]) jsonDict = dt.get(map_uid=map_uid) return flask.Response(json.dumps(jsonDict), 200, mimetype='application/json') @app.route("/data-source-index", methods=["POST"]) def index_source(map_uid): cols = ["mapname", "personal_"] dt = DataTable(flask.request.form.to_dict(), ) jsonDict = dt.get(map_uid=map_uid) return flask.Response(json.dumps(jsonDict), 200, mimetype='application/json') import os import boto3 import flask import werkzeug import sqlalchemy S3_BUCKET = os.getenv("S3_BUCKET") def s3_enabled(): return all([ os.getenv("AWS_ACCESS_KEY_ID"), os.getenv("AWS_SECRET_ACCESS_KEY"), S3_BUCKET ]) def get_s3_client(): kwargs = {} if S3_ENDPOINT_URL: kwargs["endpoint_url"] = S3_ENDPOINT_URL return boto3.client("s3", **kwargs) def upload_to_s3(local_path, replay): s3 = get_s3_client() key = f"{replay.filehash}" s3.upload_file(local_path, S3_BUCKET, key) return key @app.route("/upload", methods=['GET', 'POST']) def upload(): results = [] uploader = flask.request.headers.get(app.config["AUTH_HEADER"]) if flask.request.method == 'POST': f_list = flask.request.files.getlist("file[]") for f_storage in f_list: fname = werkzeug.utils.secure_filename(f_storage.filename) os.makedirs("uploads", exist_ok=True) # temporary save tmp_path = os.path.join("uploads", fname) f_storage.save(tmp_path) try: replay = replay_from_path(tmp_path, uploader=uploader) new_basename = f"{replay.filehash}" fullpath = os.path.join("uploads", new_basename) os.rename(tmp_path, fullpath) replay.filepath = fullpath if s3_enabled(): s3_key = upload_to_s3(fullpath, replay) os.remove(fullpath) db.session.add(replay) db.session.commit() check_replay_trigger(replay) except ValueError as e: results.append((fname, str(e))) continue except pygbx.GbxLoadError as e: print(f"Failed to load Replay: {e}") continue except sqlalchemy.exc.IntegrityError as e: results.append((fname, str(e.args))) db.session.rollback() continue results.append((fname, None)) return flask.render_template("upload-post.html", results=results) else: return flask.render_template("upload.html") def check_replay_trigger(replay): map_obj = db.session.query(Map).filter(Map.map_uid == replay.map_uid).first() best = map_obj.get_best_replay() second = map_obj.get_second_best_replay() if not second: return if replay.filehash != best.filehash: return if second.uploader == replay.uploader: return settings = db.session.query(UserSettings).filter(UserSettings.user == second.uploader).first() if settings and settings.notifications_self: notifications.send_notification(app, settings.user, map_obj.map_uid, second, replay) @app.route("/downloads/") def downloads(filename): # Ensure directory exists os.makedirs("uploads", exist_ok=True) local_path = os.path.join("uploads/", filename) if not os.path.isfile(local_path): print(f"{local_path} missing, attempting to retrieve from S3") s3 = get_s3_client() try: s3.download_file( S3_BUCKET, f"{filename}", os.path.join("uploads/", filename) ) except Exception: print(f"{filename} not found on S3") abort(404) print(f"Sending {filename}") replay = db.session.query(ParsedReplay).filter(ParsedReplay.filehash==filename).first() return send_from_directory("uploads/", filename, as_attachment=True, download_name=os.path.basename(replay.filepath)) def create_app(): db.create_all() print(f"S3 enabled: {s3_enabled()} (if true will only write tmp/cache to disk") app.config["DISPATCH_SERVER"] = os.environ.get("DISPATCH_SERVER") if app.config["DISPATCH_SERVER"]: app.config["DISPATCH_TOKEN"] = os.environ["DISPATCH_TOKEN"] if __name__ == "__main__": parser = argparse.ArgumentParser(description='TM Replay Server', formatter_class=argparse.ArgumentDefaultsHelpFormatter) # general parameters # parser.add_argument("-i", "--interface", default="127.0.0.1", help="Interface to listen on") parser.add_argument("-p", "--port", default="5000", help="Port to listen on") args = parser.parse_args() # startup # with app.app_context(): create_app() app.run(host=args.interface, port=args.port, debug=True)