""" 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" QUICK_REFERENCE_FILENAME = "quick_reference.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-note metadata 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 {"version": "unknown", "items": []} if not isinstance(data, dict): return {"version": "unknown", "items": []} version = data.get("version") items = data.get("items") if not isinstance(items, list): items = [] return { "version": version.strip() if isinstance(version, str) and version.strip() else "unknown", "items": [ 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, "static_asset_url": static_asset_url } def get_static_asset_version(build_info=None): """Return the preferred cache-busting build identifier.""" info = build_info or BUILD_INFO for key in ("commit", "version"): value = info.get(key) if isinstance(info, dict) else None if isinstance(value, str) and value.strip() and value.strip() != "unknown": return value.strip() return None def static_asset_url(filename): """Build a URL for RollCalc-owned static assets with a build query.""" asset_version = get_static_asset_version() if asset_version: return url_for("serve_static", filename=filename, v=asset_version) return url_for("serve_static", filename=filename) @app.after_request def add_html_cache_policy(response): """Require revalidation for dynamic HTML while leaving static caching intact.""" if response.mimetype == "text/html": response.headers["Cache-Control"] = "no-cache" return response # ============================================================================ # 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) def render_quick_reference(language="en"): """Read and render the Markdown quick reference document.""" return render_markdown_document(QUICK_REFERENCE_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", document_actions=[ { "href": url_for("quick_reference"), "label": "Open English Quick Reference", } ], 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", document_actions=[ { "href": url_for("quick_reference"), "label": "Open English Quick Reference", } ], 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/quick-reference", methods=["GET"]) @auth.login_required def quick_reference(): """Render the English Quick Reference inside the RollCalc Help layout.""" log_access(auth.current_user(), "/help/quick-reference", "GET") try: quick_html, toc = render_quick_reference("en") return render_template( "user_manual.html", document_title="Quick Reference", document_subtitle="Concise English reference for common RollCalc workflows", document_actions=[], error_title="Quick Reference unavailable", manual_html=quick_html, toc=toc, render_error=None ) except Exception: app.logger.exception("Could not render quick reference") return render_template( "user_manual.html", document_title="Quick Reference", document_subtitle="Concise English reference for common RollCalc workflows", document_actions=[], error_title="Quick Reference unavailable", manual_html=None, toc=[], render_error=( "The quick reference 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", document_actions=[], 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", document_actions=[], 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/", 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) 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 )