diff --git a/MultiServer.py b/MultiServer.py index 7b90523b..86fbf9bb 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -166,22 +166,24 @@ class Context(Node): logging.error('No save data found, starting a new game') except Exception as e: logging.exception(e) + self._start_async_saving() - if not self.auto_saver_thread: - def save_regularly(): - import time - while self.running: - time.sleep(self.auto_save_interval) - if self.save_dirty: - logging.debug("Saving multisave via thread.") - self.save_dirty = False - self._save() + def _start_async_saving(self): + if not self.auto_saver_thread: + def save_regularly(): + import time + while self.running: + time.sleep(self.auto_save_interval) + if self.save_dirty: + logging.debug("Saving multisave via thread.") + self.save_dirty = False + self._save() - self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True) - self.auto_saver_thread.start() + self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True) + self.auto_saver_thread.start() - import atexit - atexit.register(self._save) # make sure we save on exit too + import atexit + atexit.register(self._save) # make sure we save on exit too def get_save(self) -> dict: d = { diff --git a/WebHost.py b/WebHost.py new file mode 100644 index 00000000..5bd13644 --- /dev/null +++ b/WebHost.py @@ -0,0 +1,31 @@ +import os +import multiprocessing +import logging + +from WebHost import app +from waitress import serve + +from WebHost.models import db + +DEBUG = False + +if __name__ == "__main__": + multiprocessing.freeze_support() + multiprocessing.set_start_method('spawn') + logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO) + + configpath = "config.yaml" + + if os.path.exists(configpath): + import yaml + + with open(configpath) as c: + app.config.update(yaml.safe_load(c)) + + logging.info(f"Updated config from {configpath}") + db.bind(**app.config["PONY"]) + db.generate_mapping(create_tables=True) + if DEBUG: + app.run(debug=True) + else: + serve(app, port=80, threads=1) diff --git a/WebHost/__init__.py b/WebHost/__init__.py index 2ee236ab..bdfd034a 100644 --- a/WebHost/__init__.py +++ b/WebHost/__init__.py @@ -1,21 +1,19 @@ -# module has yet to be made capable of running in multiple processes - import os import logging -import threading import typing import multiprocessing -from pony.orm import Database, db_session - -from flask import Flask, flash, request, redirect, url_for, render_template, Response -from werkzeug.utils import secure_filename +import threading +import json +import zlib +from pony.orm import db_session, commit +from pony.flask import Pony +from flask import Flask, flash, request, redirect, url_for, render_template, Response, session +from .models import * UPLOAD_FOLDER = os.path.relpath('uploads') LOGS_FOLDER = os.path.relpath('logs') -multidata_folder = os.path.join(UPLOAD_FOLDER, "multidata") -os.makedirs(multidata_folder, exist_ok=True) os.makedirs(LOGS_FOLDER, exist_ok=True) @@ -24,36 +22,43 @@ def allowed_file(filename): app = Flask(__name__) +Pony(app) app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024 # 1 megabyte limit +# if you want persistent sessions on your server, make sure you make this a constant in your config.yaml app.config["SECRET_KEY"] = os.urandom(32) +app.config['SESSION_PERMANENT'] = True app.config["PONY"] = { 'provider': 'sqlite', - 'filename': 'db.db3', + 'filename': os.path.abspath('db.db3'), 'create_db': True } -db = Database() - -name = "localhost" - multiworlds = {} -class Multiworld(): - def __init__(self, multidata: str): - self.multidata = multidata +@app.before_first_request +def register_session(): + session.permanent = True # technically 31 days after the last visit + if not session.get("_id", None): + session["_id"] = uuid4() # uniquely identify each session without needing a login + + +class MultiworldInstance(): + def __init__(self, room: Room): + self.room_id = room.id self.process: typing.Optional[multiprocessing.Process] = None - multiworlds[multidata] = self + multiworlds[self.room_id] = self def start(self): if self.process and self.process.is_alive(): return False - logging.info(f"Spinning up {self.multidata}") - self.process = multiprocessing.Process(group=None, target=run_server_process, - args=(self.multidata,), - name="MultiHost") + logging.info(f"Spinning up {self.room_id}") + with db_session: + self.process = multiprocessing.Process(group=None, target=run_server_process, + args=(self.room_id, app.config["PONY"]), + name="MultiHost") self.process.start() def stop(self): @@ -67,21 +72,40 @@ def upload_multidata(): # check if the post request has the file part if 'file' not in request.files: flash('No file part') - return redirect(request.url) - file = request.files['file'] - # if user does not select file, browser also - # submit an empty part without filename - if file.filename == '': - flash('No selected file') - return redirect(request.url) - if file and allowed_file(file.filename): - filename = secure_filename(file.filename) - file.save(os.path.join(multidata_folder, filename)) - return redirect(url_for('host_multidata', - filename=filename)) + else: + file = request.files['file'] + # if user does not select file, browser also + # submit an empty part without filename + if file.filename == '': + flash('No selected file') + elif file and allowed_file(file.filename): + try: + multidata = json.loads(zlib.decompress(file.read()).decode("utf-8-sig")) + except: + flash("Could not load multidata. File may be corrupted or incompatible.") + else: + seed = Seed(multidata=multidata) + commit() # place into DB and generate ids + return redirect(url_for("view_seed", seed=seed.id)) + else: + flash("Not recognized file format. Awaiting a .multidata file.") return render_template("upload_multidata.html") +@app.route('/seed/') +def view_seed(seed: int): + seed = Seed.get(id=seed) + return render_template("view_seed.html", seed=seed) + + +@app.route('/new_room/') +def new_room(seed: int): + seed = Seed.get(id=seed) + room = Room(seed=seed, owner=session["_id"]) + commit() + return redirect(url_for("host_room", room=room.id)) + + def _read_log(path: str): if os.path.exists(path): with open(path) as log: @@ -91,34 +115,32 @@ def _read_log(path: str): f"Likely a crash during spinup of multiworld instance or it is still spinning up." -@app.route('/log/') -def display_log(filename: str): - filename = secure_filename(filename) +@app.route('/log/') +def display_log(room: int): # noinspection PyTypeChecker - return Response(_read_log(os.path.join("logs", filename + ".txt")), mimetype="text/plain;charset=UTF-8") + return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8") processstartlock = threading.Lock() -@app.route('/hosted/') -def host_multidata(filename: str): +@app.route('/hosted/', methods=['GET', 'POST']) +def host_room(room: int): + room = Room.get(id=room) + if request.method == "POST": + if room.owner == session["_id"]: + cmd = request.form["cmd"] + Command(room=room, commandtext=cmd) + commit() with db_session: - multiworld = multiworlds.get(filename, None) + multiworld = multiworlds.get(room.id, None) if not multiworld: - multiworld = Multiworld(filename) + multiworld = MultiworldInstance(room) - with processstartlock: - multiworld.start() + with processstartlock: + multiworld.start() - return render_template("host_multidata.html", filename=filename) + return render_template("host_room.html", room=room) from WebHost.customserver import run_server_process - -if __name__ == "__main__": - multiprocessing.freeze_support() - multiprocessing.set_start_method('spawn') - db.bind(**app.config["PONY"]) - db.generate_mapping(create_tables=True) - app.run(debug=True) diff --git a/WebHost/customserver.py b/WebHost/customserver.py index 6af7925d..4d2a0e9d 100644 --- a/WebHost/customserver.py +++ b/WebHost/customserver.py @@ -1,21 +1,74 @@ import functools import logging import os -import sys - import websockets +import asyncio +import socket +import threading +import time -from WebHost import LOGS_FOLDER, multidata_folder +from WebHost import LOGS_FOLDER +from .models import * + +from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor +from Utils import get_public_ipv4, get_public_ipv6 -def run_server_process(multidata: str): +class DBCommandProcessor(ServerCommandProcessor): + def output(self, text: str): + logging.info(text) + + +class WebHostContext(Context): + def __init__(self): + super(WebHostContext, self).__init__("", 0, "", 1, 40, True, "enabled", "enabled", 0) + + def listen_to_db_commands(self): + cmdprocessor = DBCommandProcessor(self) + + while self.running: + with db_session: + commands = select(command for command in Command if command.room.id == self.room_id) + if commands: + for command in commands: + cmdprocessor(command.commandtext) + command.delete() + commit() + time.sleep(5) + + @db_session + def load(self, room_id: int): + self.room_id = room_id + return self._load(Room.get(id=room_id).seed.multidata, True) + + @db_session + def init_save(self, enabled: bool = True): + self.saving = enabled + if self.saving: + existings_savegame = Room.get(id=self.room_id).multisave + if existings_savegame: + self.set_save(existings_savegame) + self._start_async_saving() + threading.Thread(target=self.listen_to_db_commands, daemon=True).start() + + @db_session + def _save(self) -> bool: + Room.get(id=self.room_id).multisave = self.get_save() + return True + + +def run_server_process(room_id, ponyconfig: dict): + # establish DB connection for multidata and multisave + db.bind(**ponyconfig) + db.generate_mapping(check_tables=False) + async def main(): + logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO, - filename=os.path.join(LOGS_FOLDER, multidata + ".txt")) - ctx = Context("", 0, "", 1, 1000, - True, "enabled", "goal", 0) - ctx.load(os.path.join(multidata_folder, multidata), True) + filename=os.path.join(LOGS_FOLDER, f"{room_id}.txt")) + ctx = WebHostContext() + ctx.load(room_id) ctx.auto_shutdown = 24 * 60 * 60 # 24 hours ctx.init_save() @@ -34,10 +87,4 @@ def run_server_process(multidata: str): await ctx.shutdown_task logging.info("Shutting down") - import asyncio - if ".." not in sys.path: - sys.path.append("..") - from MultiServer import Context, server, auto_shutdown - from Utils import get_public_ipv4, get_public_ipv6 - import socket asyncio.run(main()) diff --git a/WebHost/models.py b/WebHost/models.py new file mode 100644 index 00000000..88bebbd2 --- /dev/null +++ b/WebHost/models.py @@ -0,0 +1,49 @@ +from datetime import datetime +from uuid import UUID, uuid4 +from pony.orm import * + +db = Database() + + +class Patch(db.Entity): + id = PrimaryKey(int, auto=True) + data = Required(buffer) + simple_seed = Required('Seed') + + +class Room(db.Entity): + id = PrimaryKey(int, auto=True) + last_activity = Required(datetime, default=lambda: datetime.utcnow()) + owner = Required(UUID) + commands = Set('Command') + host_jobs = Set('HostJob') + seed = Required('Seed') + multisave = Optional(Json) + + +class HostJob(db.Entity): + id = PrimaryKey(int, auto=True) + sockets = Set('Socket') + room = Required(Room) + scheduler_id = Required(int, unique=True) + + +class Socket(db.Entity): + port = PrimaryKey(int) + ipv6 = Required(bool) + host_job = Required(HostJob) + + +class Seed(db.Entity): + id = PrimaryKey(int, auto=True) + rooms = Set(Room) + multidata = Optional(Json) + creation_time = Required(datetime, default=lambda: datetime.utcnow()) + patches = Set(Patch) + spoiler = Optional(str) + + +class Command(db.Entity): + id = PrimaryKey(int, auto=True) + room = Required(Room) + commandtext = Required(str) diff --git a/WebHost/run.py b/WebHost/run.py deleted file mode 100644 index 2755e11c..00000000 --- a/WebHost/run.py +++ /dev/null @@ -1,9 +0,0 @@ -from waitress import serve -import multiprocessing - -from __init__ import app - -if __name__ == "__main__": - multiprocessing.freeze_support() - multiprocessing.set_start_method('spawn') - serve(app, port=80, threads=1) diff --git a/WebHost/templates/host_multidata.html b/WebHost/templates/host_multidata.html deleted file mode 100644 index ebc88b2d..00000000 --- a/WebHost/templates/host_multidata.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - Multiworld {{ filename }} - - -Log: -
- - - \ No newline at end of file diff --git a/WebHost/templates/host_room.html b/WebHost/templates/host_room.html new file mode 100644 index 00000000..9f007798 --- /dev/null +++ b/WebHost/templates/host_room.html @@ -0,0 +1,36 @@ +{% extends 'layout.html' %} +{% block head %} + Multiworld {{ room.id }} +{% endblock %} +{% block body %} + Room created from Seed #{{ room.seed.id }}
+ {% if room.owner == session["_id"] %} +
+
+ + +
+
+ {% endif %} + Log: +
+ +{% endblock %} \ No newline at end of file diff --git a/WebHost/templates/layout.html b/WebHost/templates/layout.html new file mode 100644 index 00000000..68a0fa2b --- /dev/null +++ b/WebHost/templates/layout.html @@ -0,0 +1,21 @@ + + + + + + {% block head %}Berserker's Multiworld + {% endblock %} + + +{% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} +{% endwith %} +{% block body %}{% endblock %} + \ No newline at end of file diff --git a/WebHost/templates/upload_multidata.html b/WebHost/templates/upload_multidata.html index 95ceed6a..05b97b02 100644 --- a/WebHost/templates/upload_multidata.html +++ b/WebHost/templates/upload_multidata.html @@ -1,7 +1,11 @@ - -Upload Multidata -

Upload Multidata

-
- - -
\ No newline at end of file +{% extends 'layout.html' %} +{% block head %} + Upload Multidata +{% endblock %} +{% block body %} +

Upload Multidata

+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/WebHost/templates/view_seed.html b/WebHost/templates/view_seed.html new file mode 100644 index 00000000..2c06b079 --- /dev/null +++ b/WebHost/templates/view_seed.html @@ -0,0 +1,27 @@ +{% extends 'layout.html' %} +{% block head %} + Multiworld Seed {{ seed.id }} +{% endblock %} +{% block body %} + Seed #{{ seed.id }}
+ Players: +
    + {% for team in seed.multidata["names"] %} +
  • Team #{{ loop.index }} - {{ team | length }} +
      + {% for player in team %} +
    • {{ player }}
    • + {% endfor %} +
    +
  • + {% endfor %} +
+ Rooms: + +{% endblock %} \ No newline at end of file