Files
RollCalcPython/app.py
T

439 lines
15 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
# ============================================================================
# Benutzer für Beta-Phase (in Produktion aus env-Variablen laden!)
BETA_USERS = {
"beta": {
"password_hash": "pbkdf2:sha256:600000$HNtF3VdZKdmtg8Vw$8f976a99a457c5e924916dc6f735dd631042c8c126af8d4bda9abcd7532d904f"
},
"naue": {
"password_hash": "pbkdf2:sha256:600000$RVPiW2mYXJJIp3d7$0f838b8619d386e2da57e60ae8ccb944bb0f9f63d81ce3d174ec3034b0abcd19"
},
"cniehues": {
"password_hash": "pbkdf2:sha256:600000$iNmhakf34xk26xrz$ae78846461370c52b7f56fad5ac0ae8e33860d6f00075ac78e3a5b8c31d51a3d"
},
"lvollmert": {
"password_hash": "pbkdf2:sha256:600000$8FGop5ymmHpuIqfK$8ba223e20c514cf6bc1297435a2e7e2be81668951297f0e01ad6a931718ad7de"
},
"mtazl": {
"password_hash": "pbkdf2:sha256:600000$H19skoalhWxlLnY5$d30bfeae38470b19900648fd110978b3677607c3a161d663a7e5d5c2167a3710"
},
"controlling": {
"password_hash": "pbkdf2:sha256:600000$ksj86lrKz6nnSpXz$5161e0b5c76bd72d6b2cd04866ef49e69f463f474d7e47075171894bf3366f29"
}
}
# Logging für Auditing
LOG_FILE = "access_log.json"
BUILD_INFO_FILE = "build_info.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
# ============================================================================
@auth.verify_password
def verify_password(username, password):
"""Verify HTTP Basic Auth credentials"""
user_config = BETA_USERS.get(username)
if not user_config:
return None
password_hash = user_config.get("password_hash")
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
)