2022-08-07 18:28:50 +02:00
|
|
|
import datetime
|
|
|
|
import os
|
2024-07-02 01:03:55 +02:00
|
|
|
from typing import Any, IO, Dict, Iterator, List, Tuple, Union
|
2022-08-07 18:28:50 +02:00
|
|
|
|
|
|
|
import jinja2.exceptions
|
|
|
|
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
2022-10-17 01:08:31 +02:00
|
|
|
from pony.orm import count, commit, db_session
|
2024-10-16 23:28:42 +02:00
|
|
|
from werkzeug.utils import secure_filename
|
2022-08-07 18:28:50 +02:00
|
|
|
|
2025-08-02 21:12:58 +02:00
|
|
|
from worlds.AutoWorld import AutoWorldRegister, World
|
2022-08-07 18:28:50 +02:00
|
|
|
from . import app, cache
|
2022-10-17 01:08:31 +02:00
|
|
|
from .models import Seed, Room, Command, UUID, uuid4
|
2025-08-02 21:12:58 +02:00
|
|
|
from Utils import title_sorted
|
2022-08-07 18:28:50 +02:00
|
|
|
|
|
|
|
|
2025-08-02 21:12:58 +02:00
|
|
|
def get_world_theme(game_name: str) -> str:
|
2022-08-07 18:28:50 +02:00
|
|
|
if game_name in AutoWorldRegister.world_types:
|
|
|
|
return AutoWorldRegister.world_types[game_name].web.theme
|
|
|
|
return 'grass'
|
|
|
|
|
|
|
|
|
2025-08-02 21:12:58 +02:00
|
|
|
def get_visible_worlds() -> dict[str, type(World)]:
|
|
|
|
worlds = {}
|
|
|
|
for game, world in AutoWorldRegister.world_types.items():
|
|
|
|
if not world.hidden:
|
|
|
|
worlds[game] = world
|
|
|
|
return worlds
|
|
|
|
|
|
|
|
|
|
|
|
def render_markdown(path: str) -> str:
|
|
|
|
import mistune
|
|
|
|
from collections import Counter
|
|
|
|
|
|
|
|
markdown = mistune.create_markdown(
|
|
|
|
escape=False,
|
|
|
|
plugins=[
|
|
|
|
"strikethrough",
|
|
|
|
"footnotes",
|
|
|
|
"table",
|
|
|
|
"speedup",
|
|
|
|
],
|
|
|
|
)
|
|
|
|
|
|
|
|
heading_id_count: Counter[str] = Counter()
|
|
|
|
|
|
|
|
def heading_id(text: str) -> str:
|
|
|
|
nonlocal heading_id_count
|
|
|
|
import re # there is no good way to do this without regex
|
|
|
|
|
|
|
|
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
|
|
|
|
n = heading_id_count[s]
|
|
|
|
heading_id_count[s] += 1
|
|
|
|
if n > 0:
|
|
|
|
s += f"-{n}"
|
|
|
|
return s
|
|
|
|
|
|
|
|
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
|
|
|
|
for tok in state.tokens:
|
|
|
|
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
|
|
|
|
text = tok["text"]
|
|
|
|
assert isinstance(text, str)
|
|
|
|
unique_id = heading_id(text)
|
|
|
|
tok["attrs"]["id"] = unique_id
|
|
|
|
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
|
|
|
|
|
|
|
|
markdown.before_render_hooks.append(id_hook)
|
|
|
|
|
|
|
|
with open(path, encoding="utf-8-sig") as f:
|
|
|
|
document = f.read()
|
|
|
|
return markdown(document)
|
|
|
|
|
|
|
|
|
2022-08-07 18:28:50 +02:00
|
|
|
@app.errorhandler(404)
|
|
|
|
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
|
|
|
def page_not_found(err):
|
|
|
|
return render_template('404.html'), 404
|
|
|
|
|
|
|
|
|
|
|
|
# Start Playing Page
|
|
|
|
@app.route('/start-playing')
|
2023-10-02 20:06:56 +02:00
|
|
|
@cache.cached()
|
2022-08-07 18:28:50 +02:00
|
|
|
def start_playing():
|
|
|
|
return render_template(f"startPlaying.html")
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/games/<string:game>/info/<string:lang>')
|
2023-10-02 20:06:56 +02:00
|
|
|
@cache.cached()
|
2022-08-07 18:28:50 +02:00
|
|
|
def game_info(game, lang):
|
2025-08-02 21:12:58 +02:00
|
|
|
"""Game Info Pages"""
|
|
|
|
theme = get_world_theme(game)
|
|
|
|
secure_game_name = secure_filename(game)
|
|
|
|
lang = secure_filename(lang)
|
|
|
|
document = render_markdown(os.path.join(
|
|
|
|
app.static_folder, "generated", "docs",
|
|
|
|
secure_game_name, f"{lang}_{secure_game_name}.md"
|
|
|
|
))
|
|
|
|
return render_template(
|
|
|
|
"markdown_document.html",
|
|
|
|
title=f"{game} Guide",
|
|
|
|
html_from_markdown=document,
|
|
|
|
theme=theme,
|
|
|
|
)
|
2022-08-07 18:28:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
@app.route('/games')
|
2023-10-02 20:06:56 +02:00
|
|
|
@cache.cached()
|
2022-08-07 18:28:50 +02:00
|
|
|
def games():
|
2025-08-02 21:12:58 +02:00
|
|
|
"""List of supported games"""
|
|
|
|
return render_template("supportedGames.html", worlds=get_visible_worlds())
|
2022-08-07 18:28:50 +02:00
|
|
|
|
|
|
|
|
2025-08-02 21:12:58 +02:00
|
|
|
@app.route('/tutorial/<string:game>/<string:file>')
|
2023-10-02 20:06:56 +02:00
|
|
|
@cache.cached()
|
2025-08-02 21:12:58 +02:00
|
|
|
def tutorial(game: str, file: str):
|
|
|
|
theme = get_world_theme(game)
|
|
|
|
secure_game_name = secure_filename(game)
|
|
|
|
file = secure_filename(file)
|
|
|
|
document = render_markdown(os.path.join(
|
|
|
|
app.static_folder, "generated", "docs",
|
|
|
|
secure_game_name, file+".md"
|
|
|
|
))
|
|
|
|
return render_template(
|
|
|
|
"markdown_document.html",
|
|
|
|
title=f"{game} Guide",
|
|
|
|
html_from_markdown=document,
|
|
|
|
theme=theme,
|
|
|
|
)
|
2022-08-07 18:28:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
@app.route('/tutorial/')
|
2023-10-02 20:06:56 +02:00
|
|
|
@cache.cached()
|
2022-08-07 18:28:50 +02:00
|
|
|
def tutorial_landing():
|
2025-08-02 21:12:58 +02:00
|
|
|
tutorials = {}
|
|
|
|
worlds = AutoWorldRegister.world_types
|
|
|
|
for world_name, world_type in worlds.items():
|
|
|
|
current_world = tutorials[world_name] = {}
|
|
|
|
for tutorial in world_type.web.tutorials:
|
|
|
|
current_tutorial = current_world.setdefault(tutorial.tutorial_name, {
|
|
|
|
"description": tutorial.description, "files": {}})
|
|
|
|
current_tutorial["files"][secure_filename(tutorial.file_name).rsplit(".", 1)[0]] = {
|
|
|
|
"authors": tutorial.authors,
|
|
|
|
"language": tutorial.language
|
|
|
|
}
|
|
|
|
tutorials = {world_name: tutorials for world_name, tutorials in title_sorted(
|
|
|
|
tutorials.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game)}
|
|
|
|
return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)
|
2022-08-07 18:28:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
@app.route('/faq/<string:lang>/')
|
2023-10-02 20:06:56 +02:00
|
|
|
@cache.cached()
|
2024-10-16 23:28:42 +02:00
|
|
|
def faq(lang: str):
|
2025-08-02 21:12:58 +02:00
|
|
|
document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md"))
|
2024-10-16 23:28:42 +02:00
|
|
|
return render_template(
|
|
|
|
"markdown_document.html",
|
|
|
|
title="Frequently Asked Questions",
|
2025-08-02 21:12:58 +02:00
|
|
|
html_from_markdown=document,
|
2024-10-16 23:28:42 +02:00
|
|
|
)
|
2022-08-07 18:28:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
@app.route('/glossary/<string:lang>/')
|
2023-10-02 20:06:56 +02:00
|
|
|
@cache.cached()
|
2024-10-16 23:28:42 +02:00
|
|
|
def glossary(lang: str):
|
2025-08-02 21:12:58 +02:00
|
|
|
document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md"))
|
2024-10-16 23:28:42 +02:00
|
|
|
return render_template(
|
|
|
|
"markdown_document.html",
|
|
|
|
title="Glossary",
|
2025-08-02 21:12:58 +02:00
|
|
|
html_from_markdown=document,
|
2024-10-16 23:28:42 +02:00
|
|
|
)
|
2022-08-07 18:28:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
@app.route('/seed/<suuid:seed>')
|
|
|
|
def view_seed(seed: UUID):
|
|
|
|
seed = Seed.get(id=seed)
|
|
|
|
if not seed:
|
|
|
|
abort(404)
|
|
|
|
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/new_room/<suuid:seed>')
|
|
|
|
def new_room(seed: UUID):
|
|
|
|
seed = Seed.get(id=seed)
|
|
|
|
if not seed:
|
|
|
|
abort(404)
|
|
|
|
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
|
|
|
|
commit()
|
|
|
|
return redirect(url_for("host_room", room=room.id))
|
|
|
|
|
|
|
|
|
2024-07-02 01:03:55 +02:00
|
|
|
def _read_log(log: IO[Any], offset: int = 0) -> Iterator[bytes]:
|
|
|
|
marker = log.read(3) # skip optional BOM
|
|
|
|
if marker != b'\xEF\xBB\xBF':
|
|
|
|
log.seek(0, os.SEEK_SET)
|
|
|
|
log.seek(offset, os.SEEK_CUR)
|
|
|
|
yield from log
|
|
|
|
log.close() # free file handle as soon as possible
|
2022-08-07 18:28:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
@app.route('/log/<suuid:room>')
|
2024-07-01 21:47:49 +02:00
|
|
|
def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
|
2022-08-07 18:28:50 +02:00
|
|
|
room = Room.get(id=room)
|
|
|
|
if room is None:
|
|
|
|
return abort(404)
|
|
|
|
if room.owner == session["_id"]:
|
2023-04-21 15:12:43 +02:00
|
|
|
file_path = os.path.join("logs", str(room.id) + ".txt")
|
2024-07-01 21:47:49 +02:00
|
|
|
try:
|
2024-07-02 01:03:55 +02:00
|
|
|
log = open(file_path, "rb")
|
2024-07-01 21:47:49 +02:00
|
|
|
range_header = request.headers.get("Range")
|
|
|
|
if range_header:
|
|
|
|
range_type, range_values = range_header.split('=')
|
|
|
|
start, end = map(str.strip, range_values.split('-', 1))
|
|
|
|
if range_type != "bytes" or end != "":
|
|
|
|
return "Unsupported range", 500
|
|
|
|
# NOTE: we skip Content-Range in the response here, which isn't great but works for our JS
|
2024-07-02 01:03:55 +02:00
|
|
|
return Response(_read_log(log, int(start)), mimetype="text/plain", status=206)
|
|
|
|
return Response(_read_log(log), mimetype="text/plain")
|
2024-07-01 21:47:49 +02:00
|
|
|
except FileNotFoundError:
|
|
|
|
return Response(f"Logfile {file_path} does not exist. "
|
|
|
|
f"Likely a crash during spinup of multiworld instance or it is still spinning up.",
|
|
|
|
mimetype="text/plain")
|
2023-04-21 15:12:43 +02:00
|
|
|
|
2022-08-07 18:28:50 +02:00
|
|
|
return "Access Denied", 403
|
|
|
|
|
|
|
|
|
2024-09-18 00:47:26 +02:00
|
|
|
@app.post("/room/<suuid:room>")
|
|
|
|
def host_room_command(room: UUID):
|
|
|
|
room: Room = Room.get(id=room)
|
|
|
|
if room is None:
|
|
|
|
return abort(404)
|
|
|
|
|
|
|
|
if room.owner == session["_id"]:
|
|
|
|
cmd = request.form["cmd"]
|
|
|
|
if cmd:
|
|
|
|
Command(room=room, commandtext=cmd)
|
|
|
|
commit()
|
|
|
|
return redirect(url_for("host_room", room=room.id))
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/room/<suuid:room>")
|
2022-08-07 18:28:50 +02:00
|
|
|
def host_room(room: UUID):
|
2022-08-12 04:55:40 +02:00
|
|
|
room: Room = Room.get(id=room)
|
2022-08-07 18:28:50 +02:00
|
|
|
if room is None:
|
|
|
|
return abort(404)
|
|
|
|
|
2022-08-12 04:55:40 +02:00
|
|
|
now = datetime.datetime.utcnow()
|
|
|
|
# indicate that the page should reload to get the assigned port
|
2024-09-18 00:47:26 +02:00
|
|
|
should_refresh = ((not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3))
|
|
|
|
or room.last_activity < now - datetime.timedelta(seconds=room.timeout))
|
2022-08-07 18:28:50 +02:00
|
|
|
with db_session:
|
2022-08-12 04:55:40 +02:00
|
|
|
room.last_activity = now # will trigger a spinup, if it's not already running
|
2022-08-07 18:28:50 +02:00
|
|
|
|
2024-09-18 00:47:26 +02:00
|
|
|
browser_tokens = "Mozilla", "Chrome", "Safari"
|
|
|
|
automated = ("update" in request.args
|
|
|
|
or "Discordbot" in request.user_agent.string
|
|
|
|
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
|
|
|
|
|
|
|
|
def get_log(max_size: int = 0 if automated else 1024000) -> str:
|
|
|
|
if max_size == 0:
|
|
|
|
return "…"
|
2024-07-01 21:47:49 +02:00
|
|
|
try:
|
2024-07-02 01:03:55 +02:00
|
|
|
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
|
|
|
|
raw_size = 0
|
|
|
|
fragments: List[str] = []
|
|
|
|
for block in _read_log(log):
|
|
|
|
if raw_size + len(block) > max_size:
|
|
|
|
fragments.append("…")
|
|
|
|
break
|
|
|
|
raw_size += len(block)
|
|
|
|
fragments.append(block.decode("utf-8"))
|
|
|
|
return "".join(fragments)
|
2024-07-01 21:47:49 +02:00
|
|
|
except FileNotFoundError:
|
|
|
|
return ""
|
|
|
|
|
|
|
|
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
|
2022-08-07 18:28:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
@app.route('/favicon.ico')
|
|
|
|
def favicon():
|
2023-10-02 20:06:56 +02:00
|
|
|
return send_from_directory(os.path.join(app.root_path, "static", "static"),
|
2022-08-07 18:28:50 +02:00
|
|
|
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/discord')
|
|
|
|
def discord():
|
2022-09-29 23:15:12 +02:00
|
|
|
return redirect("https://discord.gg/8Z65BR2")
|
2022-08-07 18:28:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
@app.route('/datapackage')
|
|
|
|
@cache.cached()
|
2022-08-11 00:58:08 +02:00
|
|
|
def get_datapackage():
|
2022-08-07 18:28:50 +02:00
|
|
|
"""A pretty print version of /api/datapackage"""
|
|
|
|
from worlds import network_data_package
|
|
|
|
import json
|
|
|
|
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/index')
|
|
|
|
@app.route('/sitemap')
|
2023-10-02 20:06:56 +02:00
|
|
|
@cache.cached()
|
2022-08-07 18:28:50 +02:00
|
|
|
def get_sitemap():
|
2023-02-17 19:16:37 +01:00
|
|
|
available_games: List[Dict[str, Union[str, bool]]] = []
|
2022-08-07 18:28:50 +02:00
|
|
|
for game, world in AutoWorldRegister.world_types.items():
|
|
|
|
if not world.hidden:
|
2023-10-23 19:20:08 -05:00
|
|
|
has_settings: bool = isinstance(world.web.options_page, bool) and world.web.options_page
|
2023-02-17 19:16:37 +01:00
|
|
|
available_games.append({ 'title': game, 'has_settings': has_settings })
|
2022-08-07 18:28:50 +02:00
|
|
|
return render_template("siteMap.html", games=available_games)
|