470 lines
16 KiB
Python
470 lines
16 KiB
Python
"""
|
|
Naue Roll Calculator - Beta
|
|
Flask app with HTTP Basic Authentication
|
|
"""
|
|
|
|
from flask import (
|
|
Flask,
|
|
abort,
|
|
jsonify,
|
|
render_template,
|
|
request,
|
|
send_file,
|
|
send_from_directory,
|
|
url_for,
|
|
)
|
|
from flask_httpauth import HTTPBasicAuth
|
|
from markdown_it import MarkdownIt
|
|
from markupsafe import Markup
|
|
from werkzeug.security import check_password_hash
|
|
from functools import wraps
|
|
import os
|
|
from datetime import datetime
|
|
import json
|
|
import re
|
|
import socket
|
|
import unicodedata
|
|
|
|
app = Flask(__name__)
|
|
auth = HTTPBasicAuth()
|
|
|
|
# ============================================================================
|
|
# CONFIGURATION
|
|
# ============================================================================
|
|
|
|
LOG_FILE = "access_log.json"
|
|
BUILD_INFO_FILE = "build_info.json"
|
|
USER_CONFIG_FILE = os.path.join(app.root_path, "config", "users.json")
|
|
DOCS_DIR = os.path.join(app.root_path, "docs")
|
|
DEFAULT_DOC_LANGUAGE = "de"
|
|
USER_MANUAL_FILENAME = "user_manual.md"
|
|
RECENT_CHANGES_FILENAME = "recent_changes.md"
|
|
WHATS_NEW_SUMMARY_FILE = os.path.join(app.root_path, "static", "recent_changes.json")
|
|
UNKNOWN_BUILD_INFO = {
|
|
"version": "unknown",
|
|
"branch": "unknown",
|
|
"commit": "unknown",
|
|
"timestamp": "unknown"
|
|
}
|
|
|
|
# ============================================================================
|
|
# BUILD INFO
|
|
# ============================================================================
|
|
|
|
def load_build_info():
|
|
"""Load deployment/build metadata for templates."""
|
|
try:
|
|
with open(BUILD_INFO_FILE, "r") as f:
|
|
data = json.load(f)
|
|
except Exception as e:
|
|
print(f"[Build Info Error] {e}")
|
|
return UNKNOWN_BUILD_INFO.copy()
|
|
|
|
if not isinstance(data, dict):
|
|
return UNKNOWN_BUILD_INFO.copy()
|
|
|
|
build_info = UNKNOWN_BUILD_INFO.copy()
|
|
for key in build_info:
|
|
value = data.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
build_info[key] = value.strip()
|
|
return build_info
|
|
|
|
BUILD_INFO = load_build_info()
|
|
|
|
|
|
def load_whats_new_summary():
|
|
"""Load compact release notes for the What's New dialog."""
|
|
try:
|
|
with open(WHATS_NEW_SUMMARY_FILE, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except Exception as e:
|
|
print(f"[What's New Error] {e}")
|
|
return []
|
|
|
|
items = data.get("items") if isinstance(data, dict) else None
|
|
if not isinstance(items, list):
|
|
return []
|
|
|
|
return [
|
|
item.strip()
|
|
for item in items
|
|
if isinstance(item, str) and item.strip()
|
|
][:5]
|
|
|
|
|
|
WHATS_NEW_SUMMARY = load_whats_new_summary()
|
|
|
|
@app.context_processor
|
|
def inject_build_info():
|
|
"""Make build metadata and release-note summary available in templates."""
|
|
return {
|
|
"build_info": BUILD_INFO,
|
|
"whats_new_summary": WHATS_NEW_SUMMARY
|
|
}
|
|
|
|
# ============================================================================
|
|
# AUTHENTICATION
|
|
# ============================================================================
|
|
|
|
class UserConfigError(RuntimeError):
|
|
"""Raised when the user configuration is missing or invalid."""
|
|
|
|
|
|
def load_user_hashes(path=USER_CONFIG_FILE):
|
|
"""Load user password hashes from config/users.json."""
|
|
command_hint = "Run python3 scripts/manage_users.py to initialize or repair it."
|
|
|
|
if not os.path.exists(path):
|
|
raise UserConfigError(
|
|
f"User config file is missing: {path}. {command_hint}"
|
|
)
|
|
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
raise UserConfigError(
|
|
f"Invalid JSON in user config file {path}: {e}. {command_hint}"
|
|
) from e
|
|
except Exception as e:
|
|
raise UserConfigError(
|
|
f"Could not read user config file {path}: {e}. {command_hint}"
|
|
) from e
|
|
|
|
if not isinstance(data, dict):
|
|
raise UserConfigError(
|
|
f"Invalid user config file {path}: root element must be an object. "
|
|
f"{command_hint}"
|
|
)
|
|
|
|
if not data:
|
|
raise UserConfigError(
|
|
f"User config file {path} does not contain any users. {command_hint}"
|
|
)
|
|
|
|
users = {}
|
|
for username, value in data.items():
|
|
if not isinstance(username, str) or not username.strip():
|
|
raise UserConfigError(
|
|
f"Invalid user config file {path}: usernames must be strings. "
|
|
f"{command_hint}"
|
|
)
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise UserConfigError(
|
|
f"Invalid user config file {path}: password hash for "
|
|
f"user {username!r} must be a string. {command_hint}"
|
|
)
|
|
users[username.strip()] = value.strip()
|
|
return users
|
|
|
|
|
|
USER_PASSWORD_HASHES = load_user_hashes()
|
|
|
|
@auth.verify_password
|
|
def verify_password(username, password):
|
|
"""Verify HTTP Basic Auth credentials"""
|
|
password_hash = USER_PASSWORD_HASHES.get(username)
|
|
if not password_hash:
|
|
return None
|
|
|
|
if password_hash and check_password_hash(password_hash, password):
|
|
return username
|
|
return None
|
|
|
|
def log_access(username, endpoint, method, status=200):
|
|
"""Log all access attempts for audit trail"""
|
|
log_entry = {
|
|
"timestamp": datetime.now().isoformat(),
|
|
"username": username,
|
|
"endpoint": endpoint,
|
|
"method": method,
|
|
"status": status
|
|
}
|
|
try:
|
|
logs = []
|
|
if os.path.exists(LOG_FILE):
|
|
with open(LOG_FILE, "r") as f:
|
|
logs = json.load(f)
|
|
logs.append(log_entry)
|
|
with open(LOG_FILE, "w") as f:
|
|
json.dump(logs, f, indent=2)
|
|
except Exception as e:
|
|
print(f"[Logging Error] {e}")
|
|
|
|
|
|
def find_free_port(start_port=5000, max_attempts=10, host="0.0.0.0"):
|
|
"""Find the first available TCP port at or above start_port."""
|
|
for port in range(start_port, start_port + max_attempts):
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
try:
|
|
sock.bind((host, port))
|
|
except OSError:
|
|
continue
|
|
return port
|
|
|
|
raise RuntimeError(
|
|
f"No free port found from {start_port} to "
|
|
f"{start_port + max_attempts - 1}"
|
|
)
|
|
|
|
# ============================================================================
|
|
# ROUTES
|
|
# ============================================================================
|
|
|
|
def slugify_heading(text):
|
|
"""Create stable, URL-friendly heading ids for rendered Markdown."""
|
|
normalized = unicodedata.normalize("NFKD", text)
|
|
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
|
|
slug = re.sub(r"[^a-zA-Z0-9]+", "-", ascii_text).strip("-").lower()
|
|
return slug or "section"
|
|
|
|
|
|
def build_manual_renderer():
|
|
"""Build a Markdown renderer with raw HTML disabled."""
|
|
renderer = MarkdownIt("commonmark", {"html": False})
|
|
renderer.enable("table")
|
|
return renderer
|
|
|
|
|
|
def get_document_path(filename, language=DEFAULT_DOC_LANGUAGE):
|
|
"""Return the Markdown path for a language-specific help document."""
|
|
return os.path.join(DOCS_DIR, language, filename)
|
|
|
|
|
|
def get_manual_path(language=DEFAULT_DOC_LANGUAGE):
|
|
"""Return the Markdown path for a language-specific manual."""
|
|
return get_document_path(USER_MANUAL_FILENAME, language)
|
|
|
|
|
|
def get_manual_screenshot_dir(language=DEFAULT_DOC_LANGUAGE):
|
|
"""Return the screenshot directory for a language-specific manual."""
|
|
return os.path.join(DOCS_DIR, language, "screenshots")
|
|
|
|
|
|
def rewrite_manual_asset_paths(markdown_text, language=DEFAULT_DOC_LANGUAGE):
|
|
"""Route relative manual screenshot links through the protected docs route."""
|
|
screenshot_url = url_for("manual_screenshot", filename="")
|
|
return markdown_text.replace("](screenshots/", f"]({screenshot_url}")
|
|
|
|
|
|
def add_heading_ids_and_toc(tokens):
|
|
"""Attach ids to headings and create a compact table of contents."""
|
|
toc = []
|
|
used_slugs = {}
|
|
|
|
for index, token in enumerate(tokens):
|
|
if token.type != "heading_open":
|
|
continue
|
|
|
|
level = int(token.tag[1])
|
|
inline_token = tokens[index + 1] if index + 1 < len(tokens) else None
|
|
title = (
|
|
inline_token.content
|
|
if inline_token and inline_token.type == "inline"
|
|
else ""
|
|
)
|
|
base_slug = slugify_heading(title)
|
|
count = used_slugs.get(base_slug, 0)
|
|
used_slugs[base_slug] = count + 1
|
|
slug = base_slug if count == 0 else f"{base_slug}-{count + 1}"
|
|
token.attrSet("id", slug)
|
|
|
|
if level <= 2:
|
|
toc.append({
|
|
"level": level,
|
|
"title": title,
|
|
"id": slug
|
|
})
|
|
|
|
return toc
|
|
|
|
|
|
def render_markdown_document(filename, language=DEFAULT_DOC_LANGUAGE):
|
|
"""Read and render a Markdown help document into HTML and a generated TOC."""
|
|
renderer = build_manual_renderer()
|
|
document_path = get_document_path(filename, language)
|
|
|
|
with open(document_path, "r", encoding="utf-8") as document_file:
|
|
markdown_text = document_file.read()
|
|
|
|
markdown_text = rewrite_manual_asset_paths(markdown_text, language)
|
|
tokens = renderer.parse(markdown_text)
|
|
toc = add_heading_ids_and_toc(tokens)
|
|
html = renderer.renderer.render(tokens, renderer.options, {})
|
|
return Markup(html), toc
|
|
|
|
|
|
def render_user_manual(language=DEFAULT_DOC_LANGUAGE):
|
|
"""Read and render the Markdown user manual."""
|
|
return render_markdown_document(USER_MANUAL_FILENAME, language)
|
|
|
|
|
|
def render_recent_changes(language=DEFAULT_DOC_LANGUAGE):
|
|
"""Read and render the Markdown recent changes document."""
|
|
return render_markdown_document(RECENT_CHANGES_FILENAME, language)
|
|
|
|
@app.route("/", methods=["GET"])
|
|
@auth.login_required
|
|
def index():
|
|
"""Main calculator page - requires authentication"""
|
|
log_access(auth.current_user(), "/", "GET")
|
|
return render_template("roll_calculator.html")
|
|
|
|
@app.route("/help", methods=["GET"])
|
|
@auth.login_required
|
|
def help_index():
|
|
"""Help landing page - requires authentication."""
|
|
log_access(auth.current_user(), "/help", "GET")
|
|
return render_template("help_index.html")
|
|
|
|
@app.route("/help/user-manual", methods=["GET"])
|
|
@auth.login_required
|
|
def user_manual():
|
|
"""Render the Markdown user manual inside the RollCalc layout."""
|
|
log_access(auth.current_user(), "/help/user-manual", "GET")
|
|
try:
|
|
manual_html, toc = render_user_manual(DEFAULT_DOC_LANGUAGE)
|
|
return render_template(
|
|
"user_manual.html",
|
|
document_title="User Manual",
|
|
document_subtitle="RollCalc technical user documentation",
|
|
error_title="User Manual unavailable",
|
|
manual_html=manual_html,
|
|
toc=toc,
|
|
render_error=None
|
|
)
|
|
except Exception:
|
|
app.logger.exception("Could not render user manual")
|
|
return render_template(
|
|
"user_manual.html",
|
|
document_title="User Manual",
|
|
document_subtitle="RollCalc technical user documentation",
|
|
error_title="User Manual unavailable",
|
|
manual_html=None,
|
|
toc=[],
|
|
render_error=(
|
|
"The user manual could not be loaded. "
|
|
"Please contact the RollCalc maintainer if the problem persists."
|
|
)
|
|
), 500
|
|
|
|
|
|
@app.route("/help/recent-changes", methods=["GET"])
|
|
@auth.login_required
|
|
def recent_changes():
|
|
"""Render recent changes inside the RollCalc Help layout."""
|
|
log_access(auth.current_user(), "/help/recent-changes", "GET")
|
|
try:
|
|
changes_html, toc = render_recent_changes(DEFAULT_DOC_LANGUAGE)
|
|
return render_template(
|
|
"user_manual.html",
|
|
document_title="Recent Changes",
|
|
document_subtitle="RollCalc release notes",
|
|
error_title="Recent Changes unavailable",
|
|
manual_html=changes_html,
|
|
toc=toc,
|
|
render_error=None
|
|
)
|
|
except Exception:
|
|
app.logger.exception("Could not render recent changes")
|
|
return render_template(
|
|
"user_manual.html",
|
|
document_title="Recent Changes",
|
|
document_subtitle="RollCalc release notes",
|
|
error_title="Recent Changes unavailable",
|
|
manual_html=None,
|
|
toc=[],
|
|
render_error=(
|
|
"Recent changes could not be loaded. "
|
|
"Please contact the RollCalc maintainer if the problem persists."
|
|
)
|
|
), 500
|
|
|
|
|
|
@app.route("/help/user-manual/screenshots/<path:filename>", methods=["GET"])
|
|
@auth.login_required
|
|
def manual_screenshot(filename, language=DEFAULT_DOC_LANGUAGE):
|
|
"""Serve user-manual screenshots from the selected language directory."""
|
|
log_access(
|
|
auth.current_user(),
|
|
f"/help/user-manual/screenshots/{filename}",
|
|
"GET"
|
|
)
|
|
try:
|
|
return send_from_directory(get_manual_screenshot_dir(language), filename)
|
|
except Exception:
|
|
app.logger.warning("Missing or inaccessible manual screenshot: %s", filename)
|
|
abort(404)
|
|
|
|
@app.route("/static/<path:filename>", methods=["GET"])
|
|
@auth.login_required
|
|
def serve_static(filename):
|
|
"""Serve static files (CSS, JS, JSON) - protected"""
|
|
log_access(auth.current_user(), f"/static/{filename}", "GET")
|
|
return send_from_directory("static", filename)
|
|
|
|
@app.route("/api/health", methods=["GET"])
|
|
@auth.login_required
|
|
def health_check():
|
|
"""Simple health check endpoint"""
|
|
return jsonify({"status": "ok", "version": "14.1"}), 200
|
|
|
|
@app.route("/api/user", methods=["GET"])
|
|
@auth.login_required
|
|
def get_user():
|
|
"""Get current authenticated user info"""
|
|
user = auth.current_user()
|
|
return jsonify({
|
|
"username": user,
|
|
"authenticated": True,
|
|
"timestamp": datetime.now().isoformat()
|
|
}), 200
|
|
|
|
# ============================================================================
|
|
# ERROR HANDLERS
|
|
# ============================================================================
|
|
|
|
@app.errorhandler(401)
|
|
def unauthorized(e):
|
|
"""Handle 401 Unauthorized - browser will prompt for credentials"""
|
|
return jsonify({"error": "Unauthorized - please provide valid credentials"}), 401
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(e):
|
|
"""Handle 404 Not Found"""
|
|
return jsonify({"error": "Not found"}), 404
|
|
|
|
@app.errorhandler(500)
|
|
def internal_error(e):
|
|
"""Handle 500 Internal Server Error"""
|
|
return jsonify({"error": "Internal server error"}), 500
|
|
|
|
# ============================================================================
|
|
# STARTUP
|
|
# ============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
os.makedirs("templates", exist_ok=True)
|
|
os.makedirs("static", exist_ok=True)
|
|
port = find_free_port(start_port=5000, max_attempts=10, host="0.0.0.0")
|
|
|
|
print(f"""
|
|
╔═══════════════════════════════════════════════════════════╗
|
|
║ Naue Roll Calculator - V14 + QoL Update ║
|
|
║ HTTP Basic Auth Enabled ║
|
|
║ ║
|
|
║ ⚠️ BEFORE PRODUCTION: Update passwords in app.py ║
|
|
║ ║
|
|
║ Starten auf: http://localhost:{port:<5} ║
|
|
╚═══════════════════════════════════════════════════════════╝
|
|
""")
|
|
|
|
app.run(
|
|
host="0.0.0.0",
|
|
port=port,
|
|
debug=False,
|
|
use_reloader=False
|
|
)
|