From 1beb1ce6305c7480697fbc0511633ca214b845db Mon Sep 17 00:00:00 2001 From: ScriptSorcerer20 Date: Wed, 8 Apr 2026 11:06:37 +0200 Subject: [PATCH] added comments and fixed some wrong text. --- README.md | 9 +--- app/__init__.py | 17 ++++++++ app/routes/login_routes.py | 54 ++++++++++++++++++++++- app/routes/main_routes.py | 49 ++++++++++++++++++++- app/static/js/dashboard.js | 2 +- app/templates/dashboard.html | 83 +++++++++++++++++++++--------------- app/templates/privacy.html | 14 +++--- 7 files changed, 176 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 9ff9134..3874215 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ project-root/ │ ├── app/ │ ├── __init__.py # Flask App Factory -│ ├── config.py # Konfiguration (DB, Secrets, etc.) │ │ │ ├── models/ # Datenbankmodelle │ │ ├── __init__.py @@ -22,12 +21,7 @@ project-root/ │ │ ├── solver_routes.py │ │ └── job_routes.py │ │ -│ ├── services/ # Businesslogik (Solver etc.) -│ │ ├── __init__.py -│ │ ├── solver_service.py -│ │ └── auth_service.py -│ │ -│ ├── templates/ # HTML (Jinja Templates) +│ ├── templates/ # HTML │ │ ├── base.html │ │ ├── login.html │ │ ├── dashboard.html @@ -42,6 +36,5 @@ project-root/ │ ├── run.py # Startpunkt der Anwendung ├── requirements.txt -├── .env # DB-Zugangsdaten, Secrets └── README.md ``` \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 9abff38..9315507 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,18 +1,30 @@ +"""Application factory and shared Flask extensions. + +This file creates the Flask application, configures the database and login +manager, imports the data models so SQLAlchemy knows about them, and registers +the route blueprints used by the project. +""" + import os from flask import Flask from flask_login import LoginManager from flask_sqlalchemy import SQLAlchemy +# Shared extension instances are created once here and attached to the app db = SQLAlchemy() login_manager = LoginManager() def create_app(): + """Build and configure the Flask application instance.""" app = Flask(__name__) + # Build an absolute path to the application directory so the SQLite + # database file can be referenced reliably from any working directory. BASE_DIR = os.path.abspath(os.path.dirname(__file__)) + # Core Flask and SQLAlchemy configuration. app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(BASE_DIR, "app.db") app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-key") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False @@ -20,22 +32,27 @@ def create_app(): app.config["REMEMBER_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + # Connect the shared extension objects to this specific app instance. db.init_app(app) login_manager.init_app(app) login_manager.login_view = "login.login" login_manager.login_message_category = "info" + # Import the models before create_all() so SQLAlchemy has the table from .models.user import User from .models.gamestate import GameState from .models.login_attempt import LoginAttempt + # Create any database tables that do not exist yet. with app.app_context(): db.create_all() @login_manager.user_loader def load_user(user_id): + """Reload the logged-in user from the session-stored user id.""" return db.session.get(User, int(user_id)) + # Import and register the blueprints that contain the route handlers. from .routes.login_routes import login_bp from .routes.main_routes import main_bp diff --git a/app/routes/login_routes.py b/app/routes/login_routes.py index 9d3148d..a2b52cc 100644 --- a/app/routes/login_routes.py +++ b/app/routes/login_routes.py @@ -1,3 +1,11 @@ +"""Authentication routes and login-related helper functions. + +This module handles user registration, login, logout, password changes, +login-attempt throttling, and authenticator-app based two-factor +authentication. It also contains the utility functions that keep these flows +consistent across the different route handlers. +""" + import base64 import binascii import hashlib @@ -16,11 +24,13 @@ from .. import db from ..models.login_attempt import LoginAttempt from ..models.user import User +# Blueprint that groups all authentication and account-security routes. login_bp = Blueprint("login", __name__) +# constants MIN_PASSWORD_LENGTH = 8 MAX_PASSWORD_LENGTH = 128 -MAX_LOGIN_FAILURES = 5 +MAX_LOGIN_FAILURES = 8 LOGIN_LOCK_MINUTES = 15 TWO_FACTOR_CODE_DIGITS = 6 TWO_FACTOR_TIME_STEP_SECONDS = 30 @@ -32,10 +42,12 @@ DUMMY_PASSWORD_HASH = generate_password_hash("dummy-password-for-timing-protecti def utcnow(): + """Return the current UTC time as datetime.""" return datetime.utcnow() def get_utc_timestamp(moment=None): + """Convert a datetime into a Unix timestamp in UTC seconds.""" if moment is None: return int(datetime.now(timezone.utc).timestamp()) @@ -46,16 +58,19 @@ def get_utc_timestamp(moment=None): def normalize_username(username): + """Trim surrounding whitespace from the username field.""" return (username or "").strip() def validate_username(username): + """Return an error message if the username violates the allowed pattern.""" if not USERNAME_PATTERN.fullmatch(username): return "Username must be 3-20 characters and only use letters, numbers, dots, dashes, or underscores." return None def validate_password(password): + """Return an error message if the password length is outside the allowed range.""" if len(password) < MIN_PASSWORD_LENGTH: return f"Password must be at least {MIN_PASSWORD_LENGTH} characters long." @@ -66,6 +81,7 @@ def validate_password(password): def get_request_ip(): + """Determine the client IP address, preferring a reverse-proxy header when present.""" forwarded_for = request.headers.get("X-Forwarded-For", "") if forwarded_for: @@ -75,6 +91,11 @@ def get_request_ip(): def login_attempt_buckets(username): + """Return the lockout buckets tracked for this login attempt. + + The app tracks failures both per username+IP and per IP-only bucket to + slow down brute-force attempts against many usernames from the same client. + """ normalized_username = normalize_username(username).lower() or "__empty__" ip_address = get_request_ip() return [ @@ -84,6 +105,7 @@ def login_attempt_buckets(username): def get_or_create_attempt(ip_address, bucket_key): + """Fetch an existing login-attempt record or create a new one.""" attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first() if attempt is None: @@ -94,12 +116,14 @@ def get_or_create_attempt(ip_address, bucket_key): def clear_expired_lock(attempt, now): + """Reset the failure counter when a temporary lockout has expired.""" if attempt.locked_until and attempt.locked_until <= now: attempt.failed_attempts = 0 attempt.locked_until = None def get_active_lock(username): + """Return the latest active lockout time for this username/IP combination.""" now = utcnow() active_until = None @@ -119,6 +143,7 @@ def get_active_lock(username): def record_failed_login(username): + """Increase the failure counter and start a temporary lock when needed.""" now = utcnow() locked_until = None @@ -137,6 +162,7 @@ def record_failed_login(username): def clear_login_attempts(username): + """Remove tracked failed attempts after a successful login.""" for ip_address, bucket_key in login_attempt_buckets(username): attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first() @@ -147,6 +173,7 @@ def clear_login_attempts(username): def lookup_user_by_username(username): + """Look up a user case-insensitively so login is not case-sensitive.""" if not username: return None @@ -154,29 +181,35 @@ def lookup_user_by_username(username): def minutes_until(target_time): + """Return the remaining lockout time in rounded-up whole minutes.""" seconds = max(1, int((target_time - utcnow()).total_seconds())) return max(1, (seconds + 59) // 60) def normalize_two_factor_secret(secret): + """Normalize a Base32 secret by removing spaces and uppercasing it.""" return (secret or "").strip().replace(" ", "").upper() def generate_two_factor_secret(): + """Generate a new random Base32 secret for authenticator apps.""" return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=") def format_two_factor_secret(secret): + """Insert spaces into the secret to make manual entry easier.""" normalized = normalize_two_factor_secret(secret) return " ".join(normalized[index:index + 4] for index in range(0, len(normalized), 4)) def clear_pending_two_factor(user=None): + """Remove the session values used during the login 2FA verification step.""" session.pop("pending_2fa_user_id", None) session.pop("pending_2fa_attempts", None) def get_pending_two_factor_setup_secret(user): + """Return the temporary secret generated during 2FA setup for this user.""" if session.get("pending_2fa_setup_user_id") != user.id: return None @@ -185,11 +218,13 @@ def get_pending_two_factor_setup_secret(user): def clear_pending_two_factor_setup(): + """Remove the session values used during authenticator setup.""" session.pop("pending_2fa_setup_user_id", None) session.pop("pending_2fa_setup_secret", None) def build_totp_uri(user, secret): + """Build the otpauth URI consumed by authenticator applications.""" normalized_secret = normalize_two_factor_secret(secret) label = quote(f"{TWO_FACTOR_ISSUER}:{user.username}") issuer = quote(TWO_FACTOR_ISSUER) @@ -201,12 +236,14 @@ def build_totp_uri(user, secret): def decode_two_factor_secret(secret): + """Decode the Base32 secret into raw bytes for HMAC processing.""" normalized_secret = normalize_two_factor_secret(secret) padded_secret = normalized_secret + ("=" * (-len(normalized_secret) % 8)) return base64.b32decode(padded_secret, casefold=True) def generate_totp_code(secret, for_time=None): + """Generate a time-based one-time password from the shared secret.""" timestamp = get_utc_timestamp(for_time) counter = timestamp // TWO_FACTOR_TIME_STEP_SECONDS key = decode_two_factor_secret(secret) @@ -219,6 +256,7 @@ def generate_totp_code(secret, for_time=None): def verify_totp_code(secret, code, at_time=None): + """Validate a 2FA code, allowing a small time window for clock drift.""" normalized_code = (code or "").strip() if not normalized_code.isdigit() or len(normalized_code) != TWO_FACTOR_CODE_DIGITS: @@ -239,6 +277,7 @@ def verify_totp_code(secret, code, at_time=None): def get_pending_two_factor_user(): + """Load the user who is currently in the middle of the login 2FA step.""" user_id = session.get("pending_2fa_user_id") if not user_id: @@ -248,6 +287,7 @@ def get_pending_two_factor_user(): def render_login_settings(error=None): + """Render the login settings page with optional 2FA setup data.""" pending_secret = get_pending_two_factor_setup_secret(current_user) formatted_secret = format_two_factor_secret(pending_secret) if pending_secret else None provisioning_uri = build_totp_uri(current_user, pending_secret) if pending_secret else None @@ -262,6 +302,7 @@ def render_login_settings(error=None): @login_bp.route("/register", methods=["GET", "POST"]) def register(): + """Create a new user account after validating the submitted credentials.""" if current_user.is_authenticated: return redirect(url_for("main.dashboard")) @@ -293,6 +334,7 @@ def register(): @login_bp.route("/login", methods=["GET", "POST"]) def login(): + """Authenticate a user and start the 2FA step when required.""" if current_user.is_authenticated: return redirect(url_for("main.dashboard")) @@ -303,6 +345,7 @@ def login(): if not username or not password: return render_template("login.html", error="Username and password are required.", username=username) + # Stop early if this user or IP bucket is currently locked. locked_until = get_active_lock(username) if locked_until and locked_until > utcnow(): @@ -313,6 +356,8 @@ def login(): ) user = lookup_user_by_username(username) + # Use a dummy hash when the user does not exist so the timing stays + # similar and does not reveal valid usernames. password_hash = user.password if user else DUMMY_PASSWORD_HASH password_matches = check_password_hash(password_hash, password) @@ -330,6 +375,7 @@ def login(): clear_login_attempts(username) if user.two_factor_enabled: + # Delay the full login until the authenticator code has been verified. if not user.two_factor_secret: return render_template( "login.html", @@ -353,6 +399,7 @@ def login(): @login_bp.route("/login/verify", methods=["GET", "POST"]) def verify_two_factor(): + """Complete login by verifying the authenticator-app code.""" if current_user.is_authenticated: return redirect(url_for("main.dashboard")) @@ -373,6 +420,7 @@ def verify_two_factor(): attempts = int(session.get("pending_2fa_attempts", 0)) + # Limit repeated guesses during the second factor step as well. if attempts >= MAX_TWO_FACTOR_ATTEMPTS: clear_pending_two_factor(user) db.session.commit() @@ -413,6 +461,7 @@ def verify_two_factor(): @login_bp.route("/login/settings", methods=["GET", "POST"]) @login_required def login_settings(): + """Handle password changes and authenticator-based 2FA settings.""" if request.method == "POST": action = request.form.get("action") @@ -420,6 +469,8 @@ def login_settings(): if current_user.two_factor_enabled: return render_login_settings(error="Authenticator app 2FA is already enabled.") + # Store the generated secret in the session until the user confirms + # it with a valid authenticator code. secret = generate_two_factor_secret() session["pending_2fa_setup_user_id"] = current_user.id session["pending_2fa_setup_secret"] = secret @@ -486,6 +537,7 @@ def login_settings(): @login_bp.route("/logout", methods=["POST"]) @login_required def logout(): + """Log the user out and clear any unfinished authentication state.""" clear_pending_two_factor() clear_pending_two_factor_setup() logout_user() diff --git a/app/routes/main_routes.py b/app/routes/main_routes.py index 321b5e7..fcca6a5 100644 --- a/app/routes/main_routes.py +++ b/app/routes/main_routes.py @@ -1,3 +1,10 @@ +"""Main application routes for pages, puzzle solving, and save points. + +This module contains the public pages, the dashboard, the board validation +logic for queens and rooks, solver endpoints, and the CRUD-style endpoints +used to store and restore saved games for a logged-in user. +""" + import json import random from datetime import datetime @@ -12,10 +19,12 @@ DEFAULT_BOARD_SIZE = 8 MIN_BOARD_SIZE = 4 MAX_BOARD_SIZE = 13 +# Blueprint that groups the main site and game-related routes. main_bp = Blueprint("main", __name__) def parse_board_size(raw_size, default=DEFAULT_BOARD_SIZE): + """Convert a user-supplied board size into a safe integer range.""" try: board_size = int(raw_size) except (TypeError, ValueError): @@ -25,10 +34,12 @@ def parse_board_size(raw_size, default=DEFAULT_BOARD_SIZE): def parse_favorite_flag(raw_value): + """Interpret common truthy values from query parameters or JSON payloads.""" return str(raw_value).lower() in {"1", "true", "yes", "on"} def parse_non_negative_int(raw_value, default=0): + """Parse an integer and clamp negative values to zero.""" try: value = int(raw_value) except (TypeError, ValueError): @@ -38,14 +49,17 @@ def parse_non_negative_int(raw_value, default=0): def get_position_label(row, col, board_size): + """Return a chess-style board label such as A8 or C5.""" return f"{chr(65 + col)}{board_size - row}" def solve_queens(board_size): + """Return every valid N-Queens solution for the requested board size.""" queen_positions = [-1] * board_size queen_solutions = [] def is_queen_position_safe(row, col): + """Check whether the new queen conflicts with any earlier queen.""" for previous_row in range(row): previous_col = queen_positions[previous_row] @@ -58,6 +72,7 @@ def solve_queens(board_size): return True def place_queen(row): + """Backtracking search that fills the board row by row.""" if row == board_size: queen_solutions.append(queen_positions[:]) return @@ -73,16 +88,19 @@ def solve_queens(board_size): def solve_rooks(board_size): + """Return one valid rook arrangement with exactly one rook per row/column.""" rook_positions = list(range(board_size)) random.shuffle(rook_positions) return rook_positions def build_queens_trace(board_size): + """Build a step-by-step trace that explains the queens backtracking search.""" queen_positions = [-1] * board_size steps = [] def is_queen_position_safe(row, col): + """Reuse the same safety rules used by the queens solver.""" for previous_row in range(row): previous_col = queen_positions[previous_row] @@ -95,13 +113,14 @@ def build_queens_trace(board_size): return True def place_queen(row): + """Record every placement and backtracking step until a solution is found.""" if row == board_size: steps.append({ "type": "solution", "row": None, "col": None, "board": queen_positions[:], - "message": f"Loesung gefunden. Alle {board_size} Damen stehen ohne Konflikte." + "message": f"Lösung gefunden. Alle {board_size} Damen stehen ohne Konflikte." }) return True @@ -111,6 +130,8 @@ def build_queens_trace(board_size): queen_positions[row] = col position = get_position_label(row, col, board_size) + # Store a snapshot of the current board so the frontend can replay + # the solving process visually. steps.append({ "type": "place", "row": row, @@ -145,6 +166,7 @@ def build_queens_trace(board_size): def build_rooks_trace(board_size): + """Build a simple placement trace for the rook mode.""" rook_positions = solve_rooks(board_size) board = [-1] * board_size steps = [] @@ -152,6 +174,8 @@ def build_rooks_trace(board_size): for row, col in enumerate(rook_positions): board[row] = col position = get_position_label(row, col, board_size) + # As with the queens trace, each step includes a full board snapshot for + # straightforward frontend rendering. steps.append({ "type": "place", "row": row, @@ -178,6 +202,7 @@ def build_rooks_trace(board_size): def serialize_game_state(game_state): + """Convert a database model into JSON-friendly data for the frontend.""" board = json.loads(game_state.board) return { @@ -198,6 +223,7 @@ def serialize_game_state(game_state): def build_save_point_query(): + """Create a filtered and sorted query for the current user's save points.""" query = GameState.query.filter_by(user_id=current_user.id) selected_mode = request.args.get("mode") @@ -205,6 +231,7 @@ def build_save_point_query(): favorites_only = request.args.get("favorites_only") sort_by = request.args.get("sort", "newest") + # Apply optional filters only when the request contains a supported value. if selected_mode in {"queens", "rooks"}: query = query.filter_by(mode=selected_mode) @@ -226,6 +253,7 @@ def build_save_point_query(): @main_bp.route("/") def home(): + """Render the public landing page and choose a matching default language.""" initial_language = request.accept_languages.best_match(["de", "en"]) or "de" return render_template("index.html", initial_language=initial_language) @@ -233,27 +261,33 @@ def home(): @main_bp.route("/dashboard") @login_required def dashboard(): + """Render the logged-in dashboard.""" return render_template("dashboard.html", user=current_user) @main_bp.route("/privacy_policy") def privacy_policy(): + """Render the privacy policy page.""" return render_template("privacy.html") @main_bp.route("/imprint") def imprint(): + """Render the imprint/legal notice page.""" return render_template("impressum.html") @main_bp.route("/check", methods=["POST"]) def check(): + """Validate whether a piece can be placed on the requested square.""" data = request.json board = data["board"] row = data["row"] col = data["col"] piece_mode = data.get("mode", "queens") + # The board is stored as a list where the index is the row and the value is + # the column of the placed piece. -1 means the row is still empty. for current_row, current_col in enumerate(board): if current_col == -1 or current_row == row: continue @@ -264,6 +298,7 @@ def check(): if same_column: return jsonify({"valid": False}) + # Diagonal conflicts only matter for queens, not for rooks. if piece_mode == "queens" and same_diagonal: return jsonify({"valid": False}) @@ -272,24 +307,28 @@ def check(): @main_bp.route("/solve") def solve_queens_route(): + """Return all queens solutions for the requested board size.""" board_size = parse_board_size(request.args.get("size")) return jsonify(solve_queens(board_size)) @main_bp.route("/solve_trace") def solve_queens_trace_route(): + """Return a step-by-step queens solving trace.""" board_size = parse_board_size(request.args.get("size")) return jsonify(build_queens_trace(board_size)) @main_bp.route("/solve_rooks") def solve_rooks_route(): + """Return one rook solution for the requested board size.""" board_size = parse_board_size(request.args.get("size")) return jsonify(solve_rooks(board_size)) @main_bp.route("/solve_rooks_trace") def solve_rooks_trace_route(): + """Return a step-by-step rook placement trace.""" board_size = parse_board_size(request.args.get("size")) return jsonify(build_rooks_trace(board_size)) @@ -297,6 +336,7 @@ def solve_rooks_trace_route(): @main_bp.route("/save", methods=["POST"]) @login_required def save_game(): + """Store the current board state as a new save point for the user.""" data = request.json board = data["board"] @@ -310,6 +350,7 @@ def save_game(): is_solved = bool(data.get("isSolved")) timestamp = datetime.utcnow() + # Save the board as JSON text so it can be restored exactly as the user left it. game_state = GameState( user_id=current_user.id, board=json.dumps(board), @@ -337,6 +378,7 @@ def save_game(): @main_bp.route("/save_points") @login_required def list_save_points(): + """Return all save points for the current user after filtering and sorting.""" save_points = build_save_point_query().all() return jsonify([serialize_game_state(game_state) for game_state in save_points]) @@ -344,6 +386,7 @@ def list_save_points(): @main_bp.route("/save_points//favorite", methods=["POST"]) @login_required def toggle_save_point_favorite(game_id): + """Toggle the favorite flag for one saved game.""" game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first() if not game_state: @@ -358,6 +401,7 @@ def toggle_save_point_favorite(game_id): @main_bp.route("/save_points/", methods=["DELETE"]) @login_required def delete_save_point(game_id): + """Delete one save point owned by the current user.""" game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first() if not game_state: @@ -372,12 +416,14 @@ def delete_save_point(game_id): @main_bp.route("/load") @login_required def load_game(): + """Load the newest save point for the current user.""" latest_game = ( GameState.query.filter_by(user_id=current_user.id) .order_by(GameState.created_at.desc(), GameState.id.desc()) .first() ) + # If the user has never saved a game, return a clean default board. if not latest_game: return jsonify({"board": [-1] * DEFAULT_BOARD_SIZE, "mode": "queens"}) @@ -387,6 +433,7 @@ def load_game(): @main_bp.route("/load/") @login_required def load_specific_game(game_id): + """Load a specific save point owned by the current user.""" game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first() if not game_state: diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index ab320cc..60aaa41 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -236,7 +236,7 @@ const translations = { favoriteAddButton: "Als Favorit", favoriteRemoveButton: "Favorit entfernen", deleteButton: "Löschen", - celebrationTitle: "Richtig geloöst!", + celebrationTitle: "Richtig gelöst!", celebrationText: ({ modeLabel, duration }) => `Stark gemacht. Deine ${modeLabel.toLowerCase()}-Lösung ist konfliktfrei in ${duration}.`, settingsApplied: ({ size }) => `Brettgrösse auf ${size} x ${size} gesetzt.`, modeActivated: ({ piece }) => `${piece}-Modus aktiviert.`, diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 2db34c4..45379c1 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -24,8 +24,8 @@ -