""" 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 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" 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() @app.context_processor def inject_build_info(): """Make build metadata available in all templates.""" return {"build_info": BUILD_INFO} # ============================================================================ # 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}") # ============================================================================ # 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_manual_path(language=DEFAULT_DOC_LANGUAGE): """Return the Markdown path for a language-specific manual.""" return os.path.join(DOCS_DIR, language, USER_MANUAL_FILENAME) 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_user_manual(language=DEFAULT_DOC_LANGUAGE): """Read and render the Markdown user manual into HTML and a generated TOC.""" renderer = build_manual_renderer() manual_path = get_manual_path(language) with open(manual_path, "r", encoding="utf-8") as manual_file: markdown_text = manual_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 @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", 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", 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/user-manual/screenshots/", 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/", 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) print(""" ╔═══════════════════════════════════════════════════════════╗ ║ Naue Roll Calculator - V14 + QoL Update ║ ║ HTTP Basic Auth Enabled ║ ║ ║ ║ ⚠️ BEFORE PRODUCTION: Update passwords in app.py ║ ║ ║ ║ Starten auf: http://localhost:5000 ║ ╚═══════════════════════════════════════════════════════════╝ """) app.run( host="0.0.0.0", port=5000, debug=False, use_reloader=False )