From 9829688a851c96c376eac4fde180fa2fa1ebc9a7 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 9 Jul 2026 07:46:07 +0200 Subject: [PATCH] Add web admin panel for accounts/characters (login, GM rights, live stats) New "webadmin" service (Flask + PyMySQL, port 8081) backing a small server-rendered admin UI: - Login gated on an existing game account: only accounts with accesslevel >= ADMIN_MIN_ACCESSLEVEL (default 900) get in. Password is MD5-hashed before comparison since the game client hashes it client-side before it ever hits the wire -- accounts.password is always an MD5 hex digest, confirmed against existing DB rows. - Accounts: list/search, create, edit accesslevel/active/password. - Characters: list/search, create (mirrors the real char-creation INSERT in Src/Char Server/charpackets.cpp -- only account_name, char_name, face, hairStyle, sex are set, everything else relies on the table's own defaults), edit core stats (level, zuly, attributes, hp/mp, stat/skill points) and isgm/isdev flags directly. All queries are parameterized (no string-formatted SQL). No CSRF protection or rate limiting yet -- acceptable for a small already-behind-a-high-accesslevel-gate private server, worth revisiting before ever exposing this port publicly. Co-Authored-By: Claude Sonnet 5 --- .env.example | 7 + docker-compose.yml | 18 ++ webadmin/Dockerfile | 12 ++ webadmin/app.py | 288 +++++++++++++++++++++++++ webadmin/requirements.txt | 3 + webadmin/templates/accounts.html | 25 +++ webadmin/templates/base.html | 96 +++++++++ webadmin/templates/characters.html | 25 +++ webadmin/templates/dashboard.html | 18 ++ webadmin/templates/edit_account.html | 33 +++ webadmin/templates/edit_character.html | 23 ++ webadmin/templates/login.html | 16 ++ webadmin/templates/new_account.html | 19 ++ webadmin/templates/new_character.html | 27 +++ 14 files changed, 610 insertions(+) create mode 100644 webadmin/Dockerfile create mode 100644 webadmin/app.py create mode 100644 webadmin/requirements.txt create mode 100644 webadmin/templates/accounts.html create mode 100644 webadmin/templates/base.html create mode 100644 webadmin/templates/characters.html create mode 100644 webadmin/templates/dashboard.html create mode 100644 webadmin/templates/edit_account.html create mode 100644 webadmin/templates/edit_character.html create mode 100644 webadmin/templates/login.html create mode 100644 webadmin/templates/new_account.html create mode 100644 webadmin/templates/new_character.html diff --git a/.env.example b/.env.example index 84bdae8..6ecc943 100644 --- a/.env.example +++ b/.env.example @@ -34,3 +34,10 @@ LOGIN_CREATE_ACCOUNT=1 SHARED_LOGIN_PASS=789456 SHARED_CHAR_PASS=456123 SHARED_WORLD_PASS=321654 + +# --- Web admin panel (http://:8081) ----------------------------------- +# Logs in with an existing game account; only accounts with +# accesslevel >= ADMIN_MIN_ACCESSLEVEL are allowed in. +# Generate your own with: python3 -c "import secrets; print(secrets.token_hex(32))" +ADMIN_SECRET_KEY=change-me-to-a-random-value +ADMIN_MIN_ACCESSLEVEL=900 diff --git a/docker-compose.yml b/docker-compose.yml index 07c4bae..4f178cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,6 +89,24 @@ services: volumes: - "./webtools:/usr/share/nginx/html:ro" + webadmin: + build: + context: ./webadmin + restart: unless-stopped + environment: + DB_HOST: db + DB_PORT: "3306" + DB_USER: ${DB_USER:-rose} + DB_PASSWORD: ${DB_PASSWORD:-change_me} + DB_NAME: ${DB_NAME:-ktrose} + ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-change-me-too} + ADMIN_MIN_ACCESSLEVEL: ${ADMIN_MIN_ACCESSLEVEL:-900} + ports: + - "8081:8080" + depends_on: + db: + condition: service_healthy + volumes: db-data: logs: diff --git a/webadmin/Dockerfile b/webadmin/Dockerfile new file mode 100644 index 0000000..994bd66 --- /dev/null +++ b/webadmin/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py ./ +COPY templates ./templates + +EXPOSE 8080 +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"] diff --git a/webadmin/app.py b/webadmin/app.py new file mode 100644 index 0000000..8ba23e1 --- /dev/null +++ b/webadmin/app.py @@ -0,0 +1,288 @@ +import os +import functools +import hashlib + +import pymysql +import pymysql.cursors +from flask import Flask, request, session, redirect, url_for, render_template, flash + +app = Flask(__name__) +app.secret_key = os.environ.get("ADMIN_SECRET_KEY", "dev-only-change-me") + + +def hash_password(plaintext): + # The game client MD5-hashes the password client-side before it ever + # hits the wire, so accounts.password is always an MD5 hex digest -- + # match that here so admin-created/changed passwords work in-game. + return hashlib.md5(plaintext.encode("utf-8")).hexdigest() + +DB_HOST = os.environ.get("DB_HOST", "db") +DB_PORT = int(os.environ.get("DB_PORT", "3306")) +DB_USER = os.environ.get("DB_USER", "rose") +DB_PASSWORD = os.environ.get("DB_PASSWORD", "change_me") +DB_NAME = os.environ.get("DB_NAME", "ktrose") + +# Only accounts with accesslevel >= this may log in to the admin panel. +ADMIN_MIN_ACCESSLEVEL = int(os.environ.get("ADMIN_MIN_ACCESSLEVEL", "900")) + +CHAR_STAT_FIELDS = [ + "level", "zuly", "str", "con", "dex", "_int", "cha", "sen", + "curHp", "curMp", "statp", "skillp", "stamina", +] + + +def get_db(): + return pymysql.connect( + host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD, + database=DB_NAME, cursorclass=pymysql.cursors.DictCursor, autocommit=True, + ) + + +def login_required(view): + @functools.wraps(view) + def wrapped(*args, **kwargs): + if not session.get("username"): + return redirect(url_for("login", next=request.path)) + return view(*args, **kwargs) + return wrapped + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + db = get_db() + try: + with db.cursor() as cur: + cur.execute( + "SELECT username, password, accesslevel FROM accounts WHERE username=%s", + (username,), + ) + row = cur.fetchone() + finally: + db.close() + if ( + row + and row["password"].lower() == hash_password(password) + and row["accesslevel"] >= ADMIN_MIN_ACCESSLEVEL + ): + session.clear() + session["username"] = row["username"] + return redirect(request.args.get("next") or url_for("dashboard")) + flash("Login fehlgeschlagen (falsches Passwort oder unzureichendes Accesslevel).") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +@app.route("/") +@login_required +def dashboard(): + db = get_db() + try: + with db.cursor() as cur: + cur.execute("SELECT COUNT(*) AS n FROM accounts") + accounts_n = cur.fetchone()["n"] + cur.execute("SELECT COUNT(*) AS n FROM characters") + chars_n = cur.fetchone()["n"] + finally: + db.close() + return render_template("dashboard.html", accounts_n=accounts_n, chars_n=chars_n) + + +@app.route("/accounts") +@login_required +def accounts(): + q = request.args.get("q", "").strip() + db = get_db() + try: + with db.cursor() as cur: + if q: + cur.execute( + "SELECT id, username, accesslevel, active, online, lastip " + "FROM accounts WHERE username LIKE %s ORDER BY id", + (f"%{q}%",), + ) + else: + cur.execute( + "SELECT id, username, accesslevel, active, online, lastip " + "FROM accounts ORDER BY id" + ) + rows = cur.fetchall() + finally: + db.close() + return render_template("accounts.html", accounts=rows, q=q) + + +@app.route("/accounts/new", methods=["GET", "POST"]) +@login_required +def new_account(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "").strip() + accesslevel = int(request.form.get("accesslevel", "100") or "100") + if not username or not password: + flash("Username und Passwort sind Pflichtfelder.") + return render_template("new_account.html") + db = get_db() + try: + with db.cursor() as cur: + cur.execute("SELECT id FROM accounts WHERE username=%s", (username,)) + if cur.fetchone(): + flash(f"Account '{username}' existiert bereits.") + return render_template("new_account.html") + cur.execute( + "INSERT INTO accounts (username, password, accesslevel, lastlogin, logtime) " + "VALUES (%s, %s, %s, '', 0)", + (username, hash_password(password), accesslevel), + ) + finally: + db.close() + flash(f"Account '{username}' angelegt.") + return redirect(url_for("accounts")) + return render_template("new_account.html") + + +@app.route("/accounts//edit", methods=["GET", "POST"]) +@login_required +def edit_account(account_id): + db = get_db() + try: + if request.method == "POST": + accesslevel = int(request.form.get("accesslevel", "100") or "100") + active = 1 if request.form.get("active") else 0 + new_password = request.form.get("password", "").strip() + with db.cursor() as cur: + if new_password: + cur.execute( + "UPDATE accounts SET accesslevel=%s, active=%s, password=%s WHERE id=%s", + (accesslevel, active, hash_password(new_password), account_id), + ) + else: + cur.execute( + "UPDATE accounts SET accesslevel=%s, active=%s WHERE id=%s", + (accesslevel, active, account_id), + ) + flash("Account aktualisiert.") + return redirect(url_for("accounts")) + with db.cursor() as cur: + cur.execute("SELECT * FROM accounts WHERE id=%s", (account_id,)) + account = cur.fetchone() + cur.execute( + "SELECT id, char_name, level FROM characters WHERE account_name=%s", + (account["username"],), + ) + chars = cur.fetchall() + finally: + db.close() + if not account: + flash("Account nicht gefunden.") + return redirect(url_for("accounts")) + return render_template("edit_account.html", account=account, chars=chars) + + +@app.route("/characters") +@login_required +def characters(): + q = request.args.get("q", "").strip() + db = get_db() + try: + with db.cursor() as cur: + if q: + cur.execute( + "SELECT id, char_name, account_name, level, isgm, isdev " + "FROM characters WHERE char_name LIKE %s OR account_name LIKE %s ORDER BY id", + (f"%{q}%", f"%{q}%"), + ) + else: + cur.execute( + "SELECT id, char_name, account_name, level, isgm, isdev " + "FROM characters ORDER BY id" + ) + rows = cur.fetchall() + finally: + db.close() + return render_template("characters.html", chars=rows, q=q) + + +@app.route("/characters/new", methods=["GET", "POST"]) +@login_required +def new_character(): + db = get_db() + try: + with db.cursor() as cur: + cur.execute("SELECT username FROM accounts ORDER BY username") + account_names = [r["username"] for r in cur.fetchall()] + if request.method == "POST": + account_name = request.form.get("account_name", "").strip() + char_name = request.form.get("char_name", "").strip() + sex = int(request.form.get("sex", "0") or "0") + face = int(request.form.get("face", "1") or "1") + hairstyle = int(request.form.get("hairstyle", "1") or "1") + if not account_name or not char_name: + flash("Account und Charaktername sind Pflichtfelder.") + return render_template("new_character.html", account_names=account_names) + with db.cursor() as cur: + cur.execute("SELECT id FROM characters WHERE char_name=%s", (char_name,)) + if cur.fetchone(): + flash(f"Charakter '{char_name}' existiert bereits.") + return render_template("new_character.html", account_names=account_names) + # Mirrors the real character-creation INSERT in + # Src/Char Server/charpackets.cpp -- every other column + # relies on its table default (level 1, zero stats, etc). + cur.execute( + "INSERT INTO characters (account_name, char_name, face, hairStyle, sex) " + "VALUES (%s, %s, %s, %s, %s)", + (account_name, char_name, face, hairstyle, sex), + ) + flash(f"Charakter '{char_name}' angelegt.") + return redirect(url_for("characters")) + finally: + db.close() + return render_template("new_character.html", account_names=account_names) + + +@app.route("/characters//edit", methods=["GET", "POST"]) +@login_required +def edit_character(char_id): + db = get_db() + try: + if request.method == "POST": + values = [] + set_clauses = [] + for field in CHAR_STAT_FIELDS: + raw = request.form.get(field, "").strip() + if raw == "": + continue + set_clauses.append(f"`{field}`=%s") + values.append(int(raw)) + isgm = 1 if request.form.get("isgm") else 0 + isdev = 1 if request.form.get("isdev") else 0 + set_clauses += ["isgm=%s", "isdev=%s"] + values += [isgm, isdev] + values.append(char_id) + with db.cursor() as cur: + cur.execute( + f"UPDATE characters SET {', '.join(set_clauses)} WHERE id=%s", + values, + ) + flash("Charakter aktualisiert.") + return redirect(url_for("characters")) + with db.cursor() as cur: + cur.execute("SELECT * FROM characters WHERE id=%s", (char_id,)) + char = cur.fetchone() + finally: + db.close() + if not char: + flash("Charakter nicht gefunden.") + return redirect(url_for("characters")) + return render_template("edit_character.html", char=char, stat_fields=CHAR_STAT_FIELDS) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8080, debug=False) diff --git a/webadmin/requirements.txt b/webadmin/requirements.txt new file mode 100644 index 0000000..34f3181 --- /dev/null +++ b/webadmin/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +PyMySQL==1.1.1 +gunicorn==22.0.0 diff --git a/webadmin/templates/accounts.html b/webadmin/templates/accounts.html new file mode 100644 index 0000000..610b295 --- /dev/null +++ b/webadmin/templates/accounts.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Accounts - ROSS Admin{% endblock %} +{% block content %} + + + + {% for a in accounts %} + + + + + + + + + + {% endfor %} +
IDUsernameAccesslevelAktivOnlineLast IP
{{ a.id }}{{ a.username }}{{ a.accesslevel }}{% if a.active %}ja{% else %}nein{% endif %}{% if a.online %}online{% else %}offline{% endif %}{{ a.lastip }}bearbeiten
+{% endblock %} diff --git a/webadmin/templates/base.html b/webadmin/templates/base.html new file mode 100644 index 0000000..ef5d262 --- /dev/null +++ b/webadmin/templates/base.html @@ -0,0 +1,96 @@ + + + + +{% block title %}ROSS Admin{% endblock %} + + + +
+ ROSS Admin + {% if session.get('username') %} + + {% endif %} +
+
+ {% with messages = get_flashed_messages() %} + {% if messages %} + {% for m in messages %}
{{ m }}
{% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/webadmin/templates/characters.html b/webadmin/templates/characters.html new file mode 100644 index 0000000..f6fea5d --- /dev/null +++ b/webadmin/templates/characters.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Charaktere - ROSS Admin{% endblock %} +{% block content %} + + + + {% for c in chars %} + + + + + + + + + + {% endfor %} +
IDNameAccountLevelGMDev
{{ c.id }}{{ c.char_name }}{{ c.account_name }}{{ c.level }}{% if c.isgm %}GM{% endif %}{% if c.isdev %}Dev{% endif %}bearbeiten
+{% endblock %} diff --git a/webadmin/templates/dashboard.html b/webadmin/templates/dashboard.html new file mode 100644 index 0000000..c087086 --- /dev/null +++ b/webadmin/templates/dashboard.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}Dashboard - ROSS Admin{% endblock %} +{% block content %} +
+
+

Accounts

+

{{ accounts_n }}

+

verwalten + + neu

+
+
+

Charaktere

+

{{ chars_n }}

+

verwalten + + neu

+
+
+{% endblock %} diff --git a/webadmin/templates/edit_account.html b/webadmin/templates/edit_account.html new file mode 100644 index 0000000..6480656 --- /dev/null +++ b/webadmin/templates/edit_account.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}{{ account.username }} - ROSS Admin{% endblock %} +{% block content %} +
+

Account: {{ account.username }}

+
+ + + + + +
+ + Zurück +
+
+
+
+

Charaktere auf diesem Account

+ + + {% for c in chars %} + + + + + + {% else %} + + {% endfor %} +
NameLevel
{{ c.char_name }}{{ c.level }}bearbeiten
Keine Charaktere.
+
+{% endblock %} diff --git a/webadmin/templates/edit_character.html b/webadmin/templates/edit_character.html new file mode 100644 index 0000000..30bfdde --- /dev/null +++ b/webadmin/templates/edit_character.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}{{ char.char_name }} - ROSS Admin{% endblock %} +{% block content %} +
+

Charakter: {{ char.char_name }} Account: {{ char.account_name }}

+
+
+ {% for field in stat_fields %} +
+ + +
+ {% endfor %} +
+ + +
+ + Zurück +
+
+
+{% endblock %} diff --git a/webadmin/templates/login.html b/webadmin/templates/login.html new file mode 100644 index 0000000..60f610e --- /dev/null +++ b/webadmin/templates/login.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}Login - ROSS Admin{% endblock %} +{% block content %} +
+

ROSS Admin Login

+
+ + + + +
+ +
+
+
+{% endblock %} diff --git a/webadmin/templates/new_account.html b/webadmin/templates/new_account.html new file mode 100644 index 0000000..ec02f6e --- /dev/null +++ b/webadmin/templates/new_account.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}Neuer Account - ROSS Admin{% endblock %} +{% block content %} +
+

Neuer Account

+
+ + + + + + +
+ + Abbrechen +
+
+
+{% endblock %} diff --git a/webadmin/templates/new_character.html b/webadmin/templates/new_character.html new file mode 100644 index 0000000..298ebef --- /dev/null +++ b/webadmin/templates/new_character.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Neuer Charakter - ROSS Admin{% endblock %} +{% block content %} +
+

Neuer Charakter

+
+ + + + + + + + + + +
+ + Abbrechen +
+
+
+{% endblock %}