From 047ac6e2394b5f77fea7b6d91183eb63b4fb418a Mon Sep 17 00:00:00 2001 From: ScriptSorcerer20 Date: Wed, 22 Apr 2026 12:39:36 +0200 Subject: [PATCH] added erd and api-concept doc files and fixed issue for deployment. --- app/models/login_attempt.py | 9 +-- app/routes/login_routes.py | 88 +++++++++--------------- app/routes/main_routes.py | 9 ++- app/static/js/dashboard.js | 35 +++++++--- app/templates/dashboard.html | 2 +- database_setup.sql | 6 +- docs/erd.vsdx | Bin 0 -> 54853 bytes docs/rest-api-schnittstellen-Konzept.md | 86 +++++++++++++++++++++++ 8 files changed, 155 insertions(+), 80 deletions(-) create mode 100644 docs/erd.vsdx create mode 100644 docs/rest-api-schnittstellen-Konzept.md diff --git a/app/models/login_attempt.py b/app/models/login_attempt.py index cb9baf2..5e9c2cf 100644 --- a/app/models/login_attempt.py +++ b/app/models/login_attempt.py @@ -5,15 +5,10 @@ class LoginAttempt(db.Model): __tablename__ = "login_attempts" id = db.Column(db.Integer, primary_key=True) - ip_address = db.Column(db.String(45), nullable=False) - username = db.Column(db.String(80), nullable=False) + ip_address = db.Column(db.String(45), unique=True, nullable=False) failed_attempts = db.Column(db.Integer, nullable=False, default=0, server_default=db.text("0")) locked_until = db.Column(db.DateTime) last_failed_at = db.Column(db.DateTime) - __table_args__ = ( - db.UniqueConstraint("ip_address", "username", name="uq_login_attempt_ip_username"), - ) - def __repr__(self): - return f"" + return f"" diff --git a/app/routes/login_routes.py b/app/routes/login_routes.py index a2b52cc..4f7bbe4 100644 --- a/app/routes/login_routes.py +++ b/app/routes/login_routes.py @@ -30,8 +30,8 @@ login_bp = Blueprint("login", __name__) # constants MIN_PASSWORD_LENGTH = 8 MAX_PASSWORD_LENGTH = 128 -MAX_LOGIN_FAILURES = 8 -LOGIN_LOCK_MINUTES = 15 +MAX_LOGIN_FAILURES = 4 +LOGIN_LOCK_MINUTES = 30 TWO_FACTOR_CODE_DIGITS = 6 TWO_FACTOR_TIME_STEP_SECONDS = 30 TWO_FACTOR_VALID_WINDOW = 1 @@ -90,26 +90,13 @@ def get_request_ip(): return (request.remote_addr or "unknown")[:45] -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__" +def get_or_create_login_attempt(): + """Fetch or create the failed-login counter for the current IP address.""" ip_address = get_request_ip() - return [ - (ip_address, normalized_username), - (ip_address, "__ip__"), - ] - - -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() + attempt = LoginAttempt.query.filter_by(ip_address=ip_address).first() if attempt is None: - attempt = LoginAttempt(ip_address=ip_address, username=bucket_key) + attempt = LoginAttempt(ip_address=ip_address) db.session.add(attempt) return attempt @@ -122,52 +109,41 @@ def clear_expired_lock(attempt, now): attempt.locked_until = None -def get_active_lock(username): - """Return the latest active lockout time for this username/IP combination.""" +def get_active_lock(): + """Return the active lockout time for the current IP address.""" now = utcnow() - active_until = None + attempt = LoginAttempt.query.filter_by(ip_address=get_request_ip()).first() - for ip_address, bucket_key in login_attempt_buckets(username): - attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first() + if not attempt: + return None - if not attempt: - continue - - clear_expired_lock(attempt, now) - - if attempt.locked_until and (active_until is None or attempt.locked_until > active_until): - active_until = attempt.locked_until + clear_expired_lock(attempt, now) db.session.commit() - return active_until + return attempt.locked_until -def record_failed_login(username): - """Increase the failure counter and start a temporary lock when needed.""" +def record_failed_login(): + """Increase the failed-login counter for this IP and lock it if needed.""" now = utcnow() - locked_until = None + attempt = get_or_create_login_attempt() + clear_expired_lock(attempt, now) + attempt.failed_attempts = (attempt.failed_attempts or 0) + 1 + attempt.last_failed_at = now - for ip_address, bucket_key in login_attempt_buckets(username): - attempt = get_or_create_attempt(ip_address, bucket_key) - clear_expired_lock(attempt, now) - attempt.failed_attempts = (attempt.failed_attempts or 0) + 1 - attempt.last_failed_at = now - - if attempt.failed_attempts >= MAX_LOGIN_FAILURES: - attempt.locked_until = now + timedelta(minutes=LOGIN_LOCK_MINUTES) - locked_until = attempt.locked_until + if attempt.failed_attempts >= MAX_LOGIN_FAILURES: + attempt.locked_until = now + timedelta(minutes=LOGIN_LOCK_MINUTES) db.session.commit() - return locked_until + return attempt.locked_until -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() +def clear_login_attempts(): + """Remove failed-login attempts for this IP after a successful login.""" + attempt = LoginAttempt.query.filter_by(ip_address=get_request_ip()).first() - if attempt: - db.session.delete(attempt) + if attempt: + db.session.delete(attempt) db.session.commit() @@ -345,8 +321,8 @@ 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) + # Stop early if this IP address is currently locked. + locked_until = get_active_lock() if locked_until and locked_until > utcnow(): return render_template( @@ -362,7 +338,7 @@ def login(): password_matches = check_password_hash(password_hash, password) if not user or not password_matches: - locked_until = record_failed_login(username) + locked_until = record_failed_login() error_message = "Invalid username or password." if locked_until: @@ -372,7 +348,7 @@ def login(): return render_template("login.html", error=error_message, username=username) - clear_login_attempts(username) + clear_login_attempts() if user.two_factor_enabled: # Delay the full login until the authenticator code has been verified. @@ -392,6 +368,7 @@ def login(): db.session.commit() login_user(user) clear_pending_two_factor() + session["start_dashboard_with_default_mode"] = True return redirect(url_for("main.dashboard")) return render_template("login.html") @@ -453,6 +430,7 @@ def verify_two_factor(): db.session.commit() login_user(user) flash("Two-factor authentication successful.", "success") + session["start_dashboard_with_default_mode"] = True return redirect(url_for("main.dashboard")) return render_template("two_factor_verify.html") diff --git a/app/routes/main_routes.py b/app/routes/main_routes.py index 1d4d84d..ed556fd 100644 --- a/app/routes/main_routes.py +++ b/app/routes/main_routes.py @@ -9,7 +9,7 @@ import json import random from datetime import datetime -from flask import Blueprint, jsonify, render_template, request +from flask import Blueprint, jsonify, render_template, request, session from flask_login import current_user, login_required from .. import db @@ -308,7 +308,12 @@ def home(): @login_required def dashboard(): """Render the logged-in dashboard.""" - return render_template("dashboard.html", user=current_user) + force_default_dashboard_mode = bool(session.pop("start_dashboard_with_default_mode", False)) + return render_template( + "dashboard.html", + user=current_user, + force_default_dashboard_mode=force_default_dashboard_mode, + ) @main_bp.route("/privacy_policy") diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index efd97f2..0bb7fff 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -5,11 +5,12 @@ const MAX_BOARD_SIZE = 13; const EMPTY_CELL = 0; const OCCUPIED_CELL = 1; const GAME_STORAGE_KEY = "eight-queens-dashboard-session-v1"; +const FORCE_DEFAULT_MODE = document.body?.dataset.forceDefaultMode === "true"; const AppLanguage = window.AppLanguage; // Mutable game and solver state -let mode = "queens"; +let mode = "rooks"; let boardSize = DEFAULT_BOARD_SIZE; let pendingBoardSize = DEFAULT_BOARD_SIZE; let board = createEmptyBoard(boardSize); @@ -1328,13 +1329,13 @@ function renderTimeHistory(savePoints) { async function refreshSavePoints() { const queryString = getSavePointFilters(); - const response = await fetch(`/save_points${queryString ? `?${queryString}` : ""}`); + const response = await fetch(`save_points${queryString ? `?${queryString}` : ""}`); const savePoints = await response.json(); renderSavePoints(savePoints); } async function refreshTimeHistory() { - const response = await fetch("/save_points?sort=updated"); + const response = await fetch("save_points?sort=updated"); if (!response.ok) { return; @@ -1466,7 +1467,7 @@ function startSolverPlayback(stepImmediately = true) { // requestId prevents older async responses from overwriting a newer solver run // when the user changes size, mode, or solver action quickly. async function prepareSolutionTrace() { - const endpoint = mode === "rooks" ? "/solve_rooks_trace" : "/solve_trace"; + const endpoint = mode === "rooks" ? "solve_rooks_trace" : "solve_trace"; const requestId = ++solverRequestId; toggleSidebar(false); @@ -1612,7 +1613,7 @@ async function instantSolve() { return; } - const endpoint = mode === "rooks" ? "/solve_rooks" : "/solve"; + const endpoint = mode === "rooks" ? "solve_rooks" : "solve"; const requestId = ++solverRequestId; toggleSidebar(false); @@ -1665,7 +1666,7 @@ async function instantSolve() { // Save point persistence actions async function saveGame() { - const response = await fetch("/save", { + const response = await fetch("save", { method: "POST", headers: { "Content-Type": "application/json" @@ -1697,13 +1698,13 @@ async function saveGame() { } async function loadGame() { - const response = await fetch("/load"); + const response = await fetch("load"); const data = await response.json(); applyLoadedGame(data); } async function loadSavePoint(savePointId) { - const response = await fetch(`/load/${savePointId}`); + const response = await fetch(`load/${savePointId}`); if (!response.ok) { setStatus(t("loadError"), "red"); @@ -1718,7 +1719,7 @@ async function loadSavePoint(savePointId) { } async function toggleSavePointFavorite(savePointId) { - const response = await fetch(`/save_points/${savePointId}/favorite`, { + const response = await fetch(`save_points/${savePointId}/favorite`, { method: "POST" }); @@ -1739,7 +1740,7 @@ async function deleteSavePoint(savePointId) { return; } - const response = await fetch(`/save_points/${savePointId}`, { + const response = await fetch(`save_points/${savePointId}`, { method: "DELETE" }); @@ -1790,6 +1791,14 @@ function restoreSessionFromStorage() { } } +function clearStoredSession() { + try { + window.localStorage.removeItem(GAME_STORAGE_KEY); + } catch (error) { + // Ignore unavailable storage so startup can continue. + } +} + // Boot order: translate UI, restore any local session, then load history data. async function initializeDashboard() { pendingBoardSize = boardSize; @@ -1799,7 +1808,11 @@ async function initializeDashboard() { updateSettingsTrigger(false); setSolverMessage(t("solverReady", { modeLabel: getModeLabel(mode) })); - if (!restoreSessionFromStorage()) { + if (FORCE_DEFAULT_MODE) { + clearStoredSession(); + } + + if (FORCE_DEFAULT_MODE || !restoreSessionFromStorage()) { resetProgress(); drawBoard(); } diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index fe6a61a..f6e139b 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -10,7 +10,7 @@ - +