added comments and fixed some wrong text.
This commit is contained in:
@@ -6,7 +6,6 @@ project-root/
|
|||||||
│
|
│
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── __init__.py # Flask App Factory
|
│ ├── __init__.py # Flask App Factory
|
||||||
│ ├── config.py # Konfiguration (DB, Secrets, etc.)
|
|
||||||
│ │
|
│ │
|
||||||
│ ├── models/ # Datenbankmodelle
|
│ ├── models/ # Datenbankmodelle
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
@@ -22,12 +21,7 @@ project-root/
|
|||||||
│ │ ├── solver_routes.py
|
│ │ ├── solver_routes.py
|
||||||
│ │ └── job_routes.py
|
│ │ └── job_routes.py
|
||||||
│ │
|
│ │
|
||||||
│ ├── services/ # Businesslogik (Solver etc.)
|
│ ├── templates/ # HTML
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── solver_service.py
|
|
||||||
│ │ └── auth_service.py
|
|
||||||
│ │
|
|
||||||
│ ├── templates/ # HTML (Jinja Templates)
|
|
||||||
│ │ ├── base.html
|
│ │ ├── base.html
|
||||||
│ │ ├── login.html
|
│ │ ├── login.html
|
||||||
│ │ ├── dashboard.html
|
│ │ ├── dashboard.html
|
||||||
@@ -42,6 +36,5 @@ project-root/
|
|||||||
│
|
│
|
||||||
├── run.py # Startpunkt der Anwendung
|
├── run.py # Startpunkt der Anwendung
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
├── .env # DB-Zugangsdaten, Secrets
|
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
@@ -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
|
import os
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_login import LoginManager
|
from flask_login import LoginManager
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
# Shared extension instances are created once here and attached to the app
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
login_manager = LoginManager()
|
login_manager = LoginManager()
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
|
"""Build and configure the Flask application instance."""
|
||||||
app = Flask(__name__)
|
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__))
|
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["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(BASE_DIR, "app.db")
|
||||||
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-key")
|
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-key")
|
||||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
@@ -20,22 +32,27 @@ def create_app():
|
|||||||
app.config["REMEMBER_COOKIE_HTTPONLY"] = True
|
app.config["REMEMBER_COOKIE_HTTPONLY"] = True
|
||||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||||
|
|
||||||
|
# Connect the shared extension objects to this specific app instance.
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
login_manager.login_view = "login.login"
|
login_manager.login_view = "login.login"
|
||||||
login_manager.login_message_category = "info"
|
login_manager.login_message_category = "info"
|
||||||
|
|
||||||
|
# Import the models before create_all() so SQLAlchemy has the table
|
||||||
from .models.user import User
|
from .models.user import User
|
||||||
from .models.gamestate import GameState
|
from .models.gamestate import GameState
|
||||||
from .models.login_attempt import LoginAttempt
|
from .models.login_attempt import LoginAttempt
|
||||||
|
|
||||||
|
# Create any database tables that do not exist yet.
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_id):
|
def load_user(user_id):
|
||||||
|
"""Reload the logged-in user from the session-stored user id."""
|
||||||
return db.session.get(User, int(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.login_routes import login_bp
|
||||||
from .routes.main_routes import main_bp
|
from .routes.main_routes import main_bp
|
||||||
|
|
||||||
|
|||||||
@@ -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 base64
|
||||||
import binascii
|
import binascii
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -16,11 +24,13 @@ from .. import db
|
|||||||
from ..models.login_attempt import LoginAttempt
|
from ..models.login_attempt import LoginAttempt
|
||||||
from ..models.user import User
|
from ..models.user import User
|
||||||
|
|
||||||
|
# Blueprint that groups all authentication and account-security routes.
|
||||||
login_bp = Blueprint("login", __name__)
|
login_bp = Blueprint("login", __name__)
|
||||||
|
|
||||||
|
# constants
|
||||||
MIN_PASSWORD_LENGTH = 8
|
MIN_PASSWORD_LENGTH = 8
|
||||||
MAX_PASSWORD_LENGTH = 128
|
MAX_PASSWORD_LENGTH = 128
|
||||||
MAX_LOGIN_FAILURES = 5
|
MAX_LOGIN_FAILURES = 8
|
||||||
LOGIN_LOCK_MINUTES = 15
|
LOGIN_LOCK_MINUTES = 15
|
||||||
TWO_FACTOR_CODE_DIGITS = 6
|
TWO_FACTOR_CODE_DIGITS = 6
|
||||||
TWO_FACTOR_TIME_STEP_SECONDS = 30
|
TWO_FACTOR_TIME_STEP_SECONDS = 30
|
||||||
@@ -32,10 +42,12 @@ DUMMY_PASSWORD_HASH = generate_password_hash("dummy-password-for-timing-protecti
|
|||||||
|
|
||||||
|
|
||||||
def utcnow():
|
def utcnow():
|
||||||
|
"""Return the current UTC time as datetime."""
|
||||||
return datetime.utcnow()
|
return datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
def get_utc_timestamp(moment=None):
|
def get_utc_timestamp(moment=None):
|
||||||
|
"""Convert a datetime into a Unix timestamp in UTC seconds."""
|
||||||
if moment is None:
|
if moment is None:
|
||||||
return int(datetime.now(timezone.utc).timestamp())
|
return int(datetime.now(timezone.utc).timestamp())
|
||||||
|
|
||||||
@@ -46,16 +58,19 @@ def get_utc_timestamp(moment=None):
|
|||||||
|
|
||||||
|
|
||||||
def normalize_username(username):
|
def normalize_username(username):
|
||||||
|
"""Trim surrounding whitespace from the username field."""
|
||||||
return (username or "").strip()
|
return (username or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def validate_username(username):
|
def validate_username(username):
|
||||||
|
"""Return an error message if the username violates the allowed pattern."""
|
||||||
if not USERNAME_PATTERN.fullmatch(username):
|
if not USERNAME_PATTERN.fullmatch(username):
|
||||||
return "Username must be 3-20 characters and only use letters, numbers, dots, dashes, or underscores."
|
return "Username must be 3-20 characters and only use letters, numbers, dots, dashes, or underscores."
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def validate_password(password):
|
def validate_password(password):
|
||||||
|
"""Return an error message if the password length is outside the allowed range."""
|
||||||
if len(password) < MIN_PASSWORD_LENGTH:
|
if len(password) < MIN_PASSWORD_LENGTH:
|
||||||
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters long."
|
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters long."
|
||||||
|
|
||||||
@@ -66,6 +81,7 @@ def validate_password(password):
|
|||||||
|
|
||||||
|
|
||||||
def get_request_ip():
|
def get_request_ip():
|
||||||
|
"""Determine the client IP address, preferring a reverse-proxy header when present."""
|
||||||
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
||||||
|
|
||||||
if forwarded_for:
|
if forwarded_for:
|
||||||
@@ -75,6 +91,11 @@ def get_request_ip():
|
|||||||
|
|
||||||
|
|
||||||
def login_attempt_buckets(username):
|
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__"
|
normalized_username = normalize_username(username).lower() or "__empty__"
|
||||||
ip_address = get_request_ip()
|
ip_address = get_request_ip()
|
||||||
return [
|
return [
|
||||||
@@ -84,6 +105,7 @@ def login_attempt_buckets(username):
|
|||||||
|
|
||||||
|
|
||||||
def get_or_create_attempt(ip_address, bucket_key):
|
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, username=bucket_key).first()
|
||||||
|
|
||||||
if attempt is None:
|
if attempt is None:
|
||||||
@@ -94,12 +116,14 @@ def get_or_create_attempt(ip_address, bucket_key):
|
|||||||
|
|
||||||
|
|
||||||
def clear_expired_lock(attempt, now):
|
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:
|
if attempt.locked_until and attempt.locked_until <= now:
|
||||||
attempt.failed_attempts = 0
|
attempt.failed_attempts = 0
|
||||||
attempt.locked_until = None
|
attempt.locked_until = None
|
||||||
|
|
||||||
|
|
||||||
def get_active_lock(username):
|
def get_active_lock(username):
|
||||||
|
"""Return the latest active lockout time for this username/IP combination."""
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
active_until = None
|
active_until = None
|
||||||
|
|
||||||
@@ -119,6 +143,7 @@ def get_active_lock(username):
|
|||||||
|
|
||||||
|
|
||||||
def record_failed_login(username):
|
def record_failed_login(username):
|
||||||
|
"""Increase the failure counter and start a temporary lock when needed."""
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
locked_until = None
|
locked_until = None
|
||||||
|
|
||||||
@@ -137,6 +162,7 @@ def record_failed_login(username):
|
|||||||
|
|
||||||
|
|
||||||
def clear_login_attempts(username):
|
def clear_login_attempts(username):
|
||||||
|
"""Remove tracked failed attempts after a successful login."""
|
||||||
for ip_address, bucket_key in login_attempt_buckets(username):
|
for ip_address, bucket_key in login_attempt_buckets(username):
|
||||||
attempt = LoginAttempt.query.filter_by(ip_address=ip_address, username=bucket_key).first()
|
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):
|
def lookup_user_by_username(username):
|
||||||
|
"""Look up a user case-insensitively so login is not case-sensitive."""
|
||||||
if not username:
|
if not username:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -154,29 +181,35 @@ def lookup_user_by_username(username):
|
|||||||
|
|
||||||
|
|
||||||
def minutes_until(target_time):
|
def minutes_until(target_time):
|
||||||
|
"""Return the remaining lockout time in rounded-up whole minutes."""
|
||||||
seconds = max(1, int((target_time - utcnow()).total_seconds()))
|
seconds = max(1, int((target_time - utcnow()).total_seconds()))
|
||||||
return max(1, (seconds + 59) // 60)
|
return max(1, (seconds + 59) // 60)
|
||||||
|
|
||||||
|
|
||||||
def normalize_two_factor_secret(secret):
|
def normalize_two_factor_secret(secret):
|
||||||
|
"""Normalize a Base32 secret by removing spaces and uppercasing it."""
|
||||||
return (secret or "").strip().replace(" ", "").upper()
|
return (secret or "").strip().replace(" ", "").upper()
|
||||||
|
|
||||||
|
|
||||||
def generate_two_factor_secret():
|
def generate_two_factor_secret():
|
||||||
|
"""Generate a new random Base32 secret for authenticator apps."""
|
||||||
return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=")
|
return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
def format_two_factor_secret(secret):
|
def format_two_factor_secret(secret):
|
||||||
|
"""Insert spaces into the secret to make manual entry easier."""
|
||||||
normalized = normalize_two_factor_secret(secret)
|
normalized = normalize_two_factor_secret(secret)
|
||||||
return " ".join(normalized[index:index + 4] for index in range(0, len(normalized), 4))
|
return " ".join(normalized[index:index + 4] for index in range(0, len(normalized), 4))
|
||||||
|
|
||||||
|
|
||||||
def clear_pending_two_factor(user=None):
|
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_user_id", None)
|
||||||
session.pop("pending_2fa_attempts", None)
|
session.pop("pending_2fa_attempts", None)
|
||||||
|
|
||||||
|
|
||||||
def get_pending_two_factor_setup_secret(user):
|
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:
|
if session.get("pending_2fa_setup_user_id") != user.id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -185,11 +218,13 @@ def get_pending_two_factor_setup_secret(user):
|
|||||||
|
|
||||||
|
|
||||||
def clear_pending_two_factor_setup():
|
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_user_id", None)
|
||||||
session.pop("pending_2fa_setup_secret", None)
|
session.pop("pending_2fa_setup_secret", None)
|
||||||
|
|
||||||
|
|
||||||
def build_totp_uri(user, secret):
|
def build_totp_uri(user, secret):
|
||||||
|
"""Build the otpauth URI consumed by authenticator applications."""
|
||||||
normalized_secret = normalize_two_factor_secret(secret)
|
normalized_secret = normalize_two_factor_secret(secret)
|
||||||
label = quote(f"{TWO_FACTOR_ISSUER}:{user.username}")
|
label = quote(f"{TWO_FACTOR_ISSUER}:{user.username}")
|
||||||
issuer = quote(TWO_FACTOR_ISSUER)
|
issuer = quote(TWO_FACTOR_ISSUER)
|
||||||
@@ -201,12 +236,14 @@ def build_totp_uri(user, secret):
|
|||||||
|
|
||||||
|
|
||||||
def decode_two_factor_secret(secret):
|
def decode_two_factor_secret(secret):
|
||||||
|
"""Decode the Base32 secret into raw bytes for HMAC processing."""
|
||||||
normalized_secret = normalize_two_factor_secret(secret)
|
normalized_secret = normalize_two_factor_secret(secret)
|
||||||
padded_secret = normalized_secret + ("=" * (-len(normalized_secret) % 8))
|
padded_secret = normalized_secret + ("=" * (-len(normalized_secret) % 8))
|
||||||
return base64.b32decode(padded_secret, casefold=True)
|
return base64.b32decode(padded_secret, casefold=True)
|
||||||
|
|
||||||
|
|
||||||
def generate_totp_code(secret, for_time=None):
|
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)
|
timestamp = get_utc_timestamp(for_time)
|
||||||
counter = timestamp // TWO_FACTOR_TIME_STEP_SECONDS
|
counter = timestamp // TWO_FACTOR_TIME_STEP_SECONDS
|
||||||
key = decode_two_factor_secret(secret)
|
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):
|
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()
|
normalized_code = (code or "").strip()
|
||||||
|
|
||||||
if not normalized_code.isdigit() or len(normalized_code) != TWO_FACTOR_CODE_DIGITS:
|
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():
|
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")
|
user_id = session.get("pending_2fa_user_id")
|
||||||
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
@@ -248,6 +287,7 @@ def get_pending_two_factor_user():
|
|||||||
|
|
||||||
|
|
||||||
def render_login_settings(error=None):
|
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)
|
pending_secret = get_pending_two_factor_setup_secret(current_user)
|
||||||
formatted_secret = format_two_factor_secret(pending_secret) if pending_secret else None
|
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
|
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"])
|
@login_bp.route("/register", methods=["GET", "POST"])
|
||||||
def register():
|
def register():
|
||||||
|
"""Create a new user account after validating the submitted credentials."""
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for("main.dashboard"))
|
return redirect(url_for("main.dashboard"))
|
||||||
|
|
||||||
@@ -293,6 +334,7 @@ def register():
|
|||||||
|
|
||||||
@login_bp.route("/login", methods=["GET", "POST"])
|
@login_bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
|
"""Authenticate a user and start the 2FA step when required."""
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for("main.dashboard"))
|
return redirect(url_for("main.dashboard"))
|
||||||
|
|
||||||
@@ -303,6 +345,7 @@ def login():
|
|||||||
if not username or not password:
|
if not username or not password:
|
||||||
return render_template("login.html", error="Username and password are required.", username=username)
|
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)
|
locked_until = get_active_lock(username)
|
||||||
|
|
||||||
if locked_until and locked_until > utcnow():
|
if locked_until and locked_until > utcnow():
|
||||||
@@ -313,6 +356,8 @@ def login():
|
|||||||
)
|
)
|
||||||
|
|
||||||
user = lookup_user_by_username(username)
|
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_hash = user.password if user else DUMMY_PASSWORD_HASH
|
||||||
password_matches = check_password_hash(password_hash, password)
|
password_matches = check_password_hash(password_hash, password)
|
||||||
|
|
||||||
@@ -330,6 +375,7 @@ def login():
|
|||||||
clear_login_attempts(username)
|
clear_login_attempts(username)
|
||||||
|
|
||||||
if user.two_factor_enabled:
|
if user.two_factor_enabled:
|
||||||
|
# Delay the full login until the authenticator code has been verified.
|
||||||
if not user.two_factor_secret:
|
if not user.two_factor_secret:
|
||||||
return render_template(
|
return render_template(
|
||||||
"login.html",
|
"login.html",
|
||||||
@@ -353,6 +399,7 @@ def login():
|
|||||||
|
|
||||||
@login_bp.route("/login/verify", methods=["GET", "POST"])
|
@login_bp.route("/login/verify", methods=["GET", "POST"])
|
||||||
def verify_two_factor():
|
def verify_two_factor():
|
||||||
|
"""Complete login by verifying the authenticator-app code."""
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for("main.dashboard"))
|
return redirect(url_for("main.dashboard"))
|
||||||
|
|
||||||
@@ -373,6 +420,7 @@ def verify_two_factor():
|
|||||||
|
|
||||||
attempts = int(session.get("pending_2fa_attempts", 0))
|
attempts = int(session.get("pending_2fa_attempts", 0))
|
||||||
|
|
||||||
|
# Limit repeated guesses during the second factor step as well.
|
||||||
if attempts >= MAX_TWO_FACTOR_ATTEMPTS:
|
if attempts >= MAX_TWO_FACTOR_ATTEMPTS:
|
||||||
clear_pending_two_factor(user)
|
clear_pending_two_factor(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -413,6 +461,7 @@ def verify_two_factor():
|
|||||||
@login_bp.route("/login/settings", methods=["GET", "POST"])
|
@login_bp.route("/login/settings", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def login_settings():
|
def login_settings():
|
||||||
|
"""Handle password changes and authenticator-based 2FA settings."""
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
action = request.form.get("action")
|
action = request.form.get("action")
|
||||||
|
|
||||||
@@ -420,6 +469,8 @@ def login_settings():
|
|||||||
if current_user.two_factor_enabled:
|
if current_user.two_factor_enabled:
|
||||||
return render_login_settings(error="Authenticator app 2FA is already 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()
|
secret = generate_two_factor_secret()
|
||||||
session["pending_2fa_setup_user_id"] = current_user.id
|
session["pending_2fa_setup_user_id"] = current_user.id
|
||||||
session["pending_2fa_setup_secret"] = secret
|
session["pending_2fa_setup_secret"] = secret
|
||||||
@@ -486,6 +537,7 @@ def login_settings():
|
|||||||
@login_bp.route("/logout", methods=["POST"])
|
@login_bp.route("/logout", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def logout():
|
def logout():
|
||||||
|
"""Log the user out and clear any unfinished authentication state."""
|
||||||
clear_pending_two_factor()
|
clear_pending_two_factor()
|
||||||
clear_pending_two_factor_setup()
|
clear_pending_two_factor_setup()
|
||||||
logout_user()
|
logout_user()
|
||||||
|
|||||||
@@ -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 json
|
||||||
import random
|
import random
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -12,10 +19,12 @@ DEFAULT_BOARD_SIZE = 8
|
|||||||
MIN_BOARD_SIZE = 4
|
MIN_BOARD_SIZE = 4
|
||||||
MAX_BOARD_SIZE = 13
|
MAX_BOARD_SIZE = 13
|
||||||
|
|
||||||
|
# Blueprint that groups the main site and game-related routes.
|
||||||
main_bp = Blueprint("main", __name__)
|
main_bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
|
|
||||||
def parse_board_size(raw_size, default=DEFAULT_BOARD_SIZE):
|
def parse_board_size(raw_size, default=DEFAULT_BOARD_SIZE):
|
||||||
|
"""Convert a user-supplied board size into a safe integer range."""
|
||||||
try:
|
try:
|
||||||
board_size = int(raw_size)
|
board_size = int(raw_size)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -25,10 +34,12 @@ def parse_board_size(raw_size, default=DEFAULT_BOARD_SIZE):
|
|||||||
|
|
||||||
|
|
||||||
def parse_favorite_flag(raw_value):
|
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"}
|
return str(raw_value).lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
def parse_non_negative_int(raw_value, default=0):
|
def parse_non_negative_int(raw_value, default=0):
|
||||||
|
"""Parse an integer and clamp negative values to zero."""
|
||||||
try:
|
try:
|
||||||
value = int(raw_value)
|
value = int(raw_value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -38,14 +49,17 @@ def parse_non_negative_int(raw_value, default=0):
|
|||||||
|
|
||||||
|
|
||||||
def get_position_label(row, col, board_size):
|
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}"
|
return f"{chr(65 + col)}{board_size - row}"
|
||||||
|
|
||||||
|
|
||||||
def solve_queens(board_size):
|
def solve_queens(board_size):
|
||||||
|
"""Return every valid N-Queens solution for the requested board size."""
|
||||||
queen_positions = [-1] * board_size
|
queen_positions = [-1] * board_size
|
||||||
queen_solutions = []
|
queen_solutions = []
|
||||||
|
|
||||||
def is_queen_position_safe(row, col):
|
def is_queen_position_safe(row, col):
|
||||||
|
"""Check whether the new queen conflicts with any earlier queen."""
|
||||||
for previous_row in range(row):
|
for previous_row in range(row):
|
||||||
previous_col = queen_positions[previous_row]
|
previous_col = queen_positions[previous_row]
|
||||||
|
|
||||||
@@ -58,6 +72,7 @@ def solve_queens(board_size):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def place_queen(row):
|
def place_queen(row):
|
||||||
|
"""Backtracking search that fills the board row by row."""
|
||||||
if row == board_size:
|
if row == board_size:
|
||||||
queen_solutions.append(queen_positions[:])
|
queen_solutions.append(queen_positions[:])
|
||||||
return
|
return
|
||||||
@@ -73,16 +88,19 @@ def solve_queens(board_size):
|
|||||||
|
|
||||||
|
|
||||||
def solve_rooks(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))
|
rook_positions = list(range(board_size))
|
||||||
random.shuffle(rook_positions)
|
random.shuffle(rook_positions)
|
||||||
return rook_positions
|
return rook_positions
|
||||||
|
|
||||||
|
|
||||||
def build_queens_trace(board_size):
|
def build_queens_trace(board_size):
|
||||||
|
"""Build a step-by-step trace that explains the queens backtracking search."""
|
||||||
queen_positions = [-1] * board_size
|
queen_positions = [-1] * board_size
|
||||||
steps = []
|
steps = []
|
||||||
|
|
||||||
def is_queen_position_safe(row, col):
|
def is_queen_position_safe(row, col):
|
||||||
|
"""Reuse the same safety rules used by the queens solver."""
|
||||||
for previous_row in range(row):
|
for previous_row in range(row):
|
||||||
previous_col = queen_positions[previous_row]
|
previous_col = queen_positions[previous_row]
|
||||||
|
|
||||||
@@ -95,13 +113,14 @@ def build_queens_trace(board_size):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def place_queen(row):
|
def place_queen(row):
|
||||||
|
"""Record every placement and backtracking step until a solution is found."""
|
||||||
if row == board_size:
|
if row == board_size:
|
||||||
steps.append({
|
steps.append({
|
||||||
"type": "solution",
|
"type": "solution",
|
||||||
"row": None,
|
"row": None,
|
||||||
"col": None,
|
"col": None,
|
||||||
"board": queen_positions[:],
|
"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
|
return True
|
||||||
|
|
||||||
@@ -111,6 +130,8 @@ def build_queens_trace(board_size):
|
|||||||
|
|
||||||
queen_positions[row] = col
|
queen_positions[row] = col
|
||||||
position = get_position_label(row, col, board_size)
|
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({
|
steps.append({
|
||||||
"type": "place",
|
"type": "place",
|
||||||
"row": row,
|
"row": row,
|
||||||
@@ -145,6 +166,7 @@ def build_queens_trace(board_size):
|
|||||||
|
|
||||||
|
|
||||||
def build_rooks_trace(board_size):
|
def build_rooks_trace(board_size):
|
||||||
|
"""Build a simple placement trace for the rook mode."""
|
||||||
rook_positions = solve_rooks(board_size)
|
rook_positions = solve_rooks(board_size)
|
||||||
board = [-1] * board_size
|
board = [-1] * board_size
|
||||||
steps = []
|
steps = []
|
||||||
@@ -152,6 +174,8 @@ def build_rooks_trace(board_size):
|
|||||||
for row, col in enumerate(rook_positions):
|
for row, col in enumerate(rook_positions):
|
||||||
board[row] = col
|
board[row] = col
|
||||||
position = get_position_label(row, col, board_size)
|
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({
|
steps.append({
|
||||||
"type": "place",
|
"type": "place",
|
||||||
"row": row,
|
"row": row,
|
||||||
@@ -178,6 +202,7 @@ def build_rooks_trace(board_size):
|
|||||||
|
|
||||||
|
|
||||||
def serialize_game_state(game_state):
|
def serialize_game_state(game_state):
|
||||||
|
"""Convert a database model into JSON-friendly data for the frontend."""
|
||||||
board = json.loads(game_state.board)
|
board = json.loads(game_state.board)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -198,6 +223,7 @@ def serialize_game_state(game_state):
|
|||||||
|
|
||||||
|
|
||||||
def build_save_point_query():
|
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)
|
query = GameState.query.filter_by(user_id=current_user.id)
|
||||||
|
|
||||||
selected_mode = request.args.get("mode")
|
selected_mode = request.args.get("mode")
|
||||||
@@ -205,6 +231,7 @@ def build_save_point_query():
|
|||||||
favorites_only = request.args.get("favorites_only")
|
favorites_only = request.args.get("favorites_only")
|
||||||
sort_by = request.args.get("sort", "newest")
|
sort_by = request.args.get("sort", "newest")
|
||||||
|
|
||||||
|
# Apply optional filters only when the request contains a supported value.
|
||||||
if selected_mode in {"queens", "rooks"}:
|
if selected_mode in {"queens", "rooks"}:
|
||||||
query = query.filter_by(mode=selected_mode)
|
query = query.filter_by(mode=selected_mode)
|
||||||
|
|
||||||
@@ -226,6 +253,7 @@ def build_save_point_query():
|
|||||||
|
|
||||||
@main_bp.route("/")
|
@main_bp.route("/")
|
||||||
def home():
|
def home():
|
||||||
|
"""Render the public landing page and choose a matching default language."""
|
||||||
initial_language = request.accept_languages.best_match(["de", "en"]) or "de"
|
initial_language = request.accept_languages.best_match(["de", "en"]) or "de"
|
||||||
return render_template("index.html", initial_language=initial_language)
|
return render_template("index.html", initial_language=initial_language)
|
||||||
|
|
||||||
@@ -233,27 +261,33 @@ def home():
|
|||||||
@main_bp.route("/dashboard")
|
@main_bp.route("/dashboard")
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
|
"""Render the logged-in dashboard."""
|
||||||
return render_template("dashboard.html", user=current_user)
|
return render_template("dashboard.html", user=current_user)
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/privacy_policy")
|
@main_bp.route("/privacy_policy")
|
||||||
def privacy_policy():
|
def privacy_policy():
|
||||||
|
"""Render the privacy policy page."""
|
||||||
return render_template("privacy.html")
|
return render_template("privacy.html")
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/imprint")
|
@main_bp.route("/imprint")
|
||||||
def imprint():
|
def imprint():
|
||||||
|
"""Render the imprint/legal notice page."""
|
||||||
return render_template("impressum.html")
|
return render_template("impressum.html")
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/check", methods=["POST"])
|
@main_bp.route("/check", methods=["POST"])
|
||||||
def check():
|
def check():
|
||||||
|
"""Validate whether a piece can be placed on the requested square."""
|
||||||
data = request.json
|
data = request.json
|
||||||
board = data["board"]
|
board = data["board"]
|
||||||
row = data["row"]
|
row = data["row"]
|
||||||
col = data["col"]
|
col = data["col"]
|
||||||
piece_mode = data.get("mode", "queens")
|
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):
|
for current_row, current_col in enumerate(board):
|
||||||
if current_col == -1 or current_row == row:
|
if current_col == -1 or current_row == row:
|
||||||
continue
|
continue
|
||||||
@@ -264,6 +298,7 @@ def check():
|
|||||||
if same_column:
|
if same_column:
|
||||||
return jsonify({"valid": False})
|
return jsonify({"valid": False})
|
||||||
|
|
||||||
|
# Diagonal conflicts only matter for queens, not for rooks.
|
||||||
if piece_mode == "queens" and same_diagonal:
|
if piece_mode == "queens" and same_diagonal:
|
||||||
return jsonify({"valid": False})
|
return jsonify({"valid": False})
|
||||||
|
|
||||||
@@ -272,24 +307,28 @@ def check():
|
|||||||
|
|
||||||
@main_bp.route("/solve")
|
@main_bp.route("/solve")
|
||||||
def solve_queens_route():
|
def solve_queens_route():
|
||||||
|
"""Return all queens solutions for the requested board size."""
|
||||||
board_size = parse_board_size(request.args.get("size"))
|
board_size = parse_board_size(request.args.get("size"))
|
||||||
return jsonify(solve_queens(board_size))
|
return jsonify(solve_queens(board_size))
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/solve_trace")
|
@main_bp.route("/solve_trace")
|
||||||
def solve_queens_trace_route():
|
def solve_queens_trace_route():
|
||||||
|
"""Return a step-by-step queens solving trace."""
|
||||||
board_size = parse_board_size(request.args.get("size"))
|
board_size = parse_board_size(request.args.get("size"))
|
||||||
return jsonify(build_queens_trace(board_size))
|
return jsonify(build_queens_trace(board_size))
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/solve_rooks")
|
@main_bp.route("/solve_rooks")
|
||||||
def solve_rooks_route():
|
def solve_rooks_route():
|
||||||
|
"""Return one rook solution for the requested board size."""
|
||||||
board_size = parse_board_size(request.args.get("size"))
|
board_size = parse_board_size(request.args.get("size"))
|
||||||
return jsonify(solve_rooks(board_size))
|
return jsonify(solve_rooks(board_size))
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/solve_rooks_trace")
|
@main_bp.route("/solve_rooks_trace")
|
||||||
def solve_rooks_trace_route():
|
def solve_rooks_trace_route():
|
||||||
|
"""Return a step-by-step rook placement trace."""
|
||||||
board_size = parse_board_size(request.args.get("size"))
|
board_size = parse_board_size(request.args.get("size"))
|
||||||
return jsonify(build_rooks_trace(board_size))
|
return jsonify(build_rooks_trace(board_size))
|
||||||
|
|
||||||
@@ -297,6 +336,7 @@ def solve_rooks_trace_route():
|
|||||||
@main_bp.route("/save", methods=["POST"])
|
@main_bp.route("/save", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def save_game():
|
def save_game():
|
||||||
|
"""Store the current board state as a new save point for the user."""
|
||||||
data = request.json
|
data = request.json
|
||||||
|
|
||||||
board = data["board"]
|
board = data["board"]
|
||||||
@@ -310,6 +350,7 @@ def save_game():
|
|||||||
is_solved = bool(data.get("isSolved"))
|
is_solved = bool(data.get("isSolved"))
|
||||||
timestamp = datetime.utcnow()
|
timestamp = datetime.utcnow()
|
||||||
|
|
||||||
|
# Save the board as JSON text so it can be restored exactly as the user left it.
|
||||||
game_state = GameState(
|
game_state = GameState(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
board=json.dumps(board),
|
board=json.dumps(board),
|
||||||
@@ -337,6 +378,7 @@ def save_game():
|
|||||||
@main_bp.route("/save_points")
|
@main_bp.route("/save_points")
|
||||||
@login_required
|
@login_required
|
||||||
def list_save_points():
|
def list_save_points():
|
||||||
|
"""Return all save points for the current user after filtering and sorting."""
|
||||||
save_points = build_save_point_query().all()
|
save_points = build_save_point_query().all()
|
||||||
return jsonify([serialize_game_state(game_state) for game_state in save_points])
|
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/<int:game_id>/favorite", methods=["POST"])
|
@main_bp.route("/save_points/<int:game_id>/favorite", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def toggle_save_point_favorite(game_id):
|
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()
|
game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first()
|
||||||
|
|
||||||
if not game_state:
|
if not game_state:
|
||||||
@@ -358,6 +401,7 @@ def toggle_save_point_favorite(game_id):
|
|||||||
@main_bp.route("/save_points/<int:game_id>", methods=["DELETE"])
|
@main_bp.route("/save_points/<int:game_id>", methods=["DELETE"])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_save_point(game_id):
|
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()
|
game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first()
|
||||||
|
|
||||||
if not game_state:
|
if not game_state:
|
||||||
@@ -372,12 +416,14 @@ def delete_save_point(game_id):
|
|||||||
@main_bp.route("/load")
|
@main_bp.route("/load")
|
||||||
@login_required
|
@login_required
|
||||||
def load_game():
|
def load_game():
|
||||||
|
"""Load the newest save point for the current user."""
|
||||||
latest_game = (
|
latest_game = (
|
||||||
GameState.query.filter_by(user_id=current_user.id)
|
GameState.query.filter_by(user_id=current_user.id)
|
||||||
.order_by(GameState.created_at.desc(), GameState.id.desc())
|
.order_by(GameState.created_at.desc(), GameState.id.desc())
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# If the user has never saved a game, return a clean default board.
|
||||||
if not latest_game:
|
if not latest_game:
|
||||||
return jsonify({"board": [-1] * DEFAULT_BOARD_SIZE, "mode": "queens"})
|
return jsonify({"board": [-1] * DEFAULT_BOARD_SIZE, "mode": "queens"})
|
||||||
|
|
||||||
@@ -387,6 +433,7 @@ def load_game():
|
|||||||
@main_bp.route("/load/<int:game_id>")
|
@main_bp.route("/load/<int:game_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def load_specific_game(game_id):
|
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()
|
game_state = GameState.query.filter_by(id=game_id, user_id=current_user.id).first()
|
||||||
|
|
||||||
if not game_state:
|
if not game_state:
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ const translations = {
|
|||||||
favoriteAddButton: "Als Favorit",
|
favoriteAddButton: "Als Favorit",
|
||||||
favoriteRemoveButton: "Favorit entfernen",
|
favoriteRemoveButton: "Favorit entfernen",
|
||||||
deleteButton: "Löschen",
|
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}.`,
|
celebrationText: ({ modeLabel, duration }) => `Stark gemacht. Deine ${modeLabel.toLowerCase()}-Lösung ist konfliktfrei in ${duration}.`,
|
||||||
settingsApplied: ({ size }) => `Brettgrösse auf ${size} x ${size} gesetzt.`,
|
settingsApplied: ({ size }) => `Brettgrösse auf ${size} x ${size} gesetzt.`,
|
||||||
modeActivated: ({ piece }) => `${piece}-Modus aktiviert.`,
|
modeActivated: ({ piece }) => `${piece}-Modus aktiviert.`,
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
<button class="nav-link-button lang-switch" id="langSwitch" type="button"
|
<button class="nav-link-button lang-switch" id="langSwitch" type="button"
|
||||||
aria-label="Sprache wechseln">DE</button>
|
aria-label="Sprache wechseln">DE</button>
|
||||||
|
|
||||||
<a class="account-shortcut" href="{{ url_for('login.login_settings') }}" data-i18n-aria-label="accountShortcutAria"
|
<a class="account-shortcut" href="{{ url_for('login.login_settings') }}"
|
||||||
aria-label="Konto und Login-Einstellungen öffnen">
|
data-i18n-aria-label="accountShortcutAria" aria-label="Konto und Login-Einstellungen öffnen">
|
||||||
<span class="account-avatar" aria-hidden="true">{{ current_user.username[:1]|upper }}</span>
|
<span class="account-avatar" aria-hidden="true">{{ current_user.username[:1]|upper }}</span>
|
||||||
<span class="account-copy">
|
<span class="account-copy">
|
||||||
<span class="account-label" id="accountLabel" data-i18n="accountLabel">Account</span>
|
<span class="account-label" id="accountLabel" data-i18n="accountLabel">Account</span>
|
||||||
@@ -121,13 +121,14 @@
|
|||||||
<div class="board" id="board"></div>
|
<div class="board" id="board"></div>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button class="btn secondary" onclick="resetBoard()" id="btnReset" type="button" data-i18n="btnReset">Brett
|
<button class="btn secondary" onclick="resetBoard()" id="btnReset" type="button"
|
||||||
zuruecksetzen</button>
|
data-i18n="btnReset">Brett
|
||||||
|
zurücksetzen</button>
|
||||||
<div class="controls-save-row">
|
<div class="controls-save-row">
|
||||||
<button class="btn" onclick="openSavePanel()" id="btnSave" type="button"
|
<button class="btn" onclick="openSavePanel()" id="btnSave" type="button"
|
||||||
data-i18n="btnSave">Speichern</button>
|
data-i18n="btnSave">Speichern</button>
|
||||||
<button class="btn secondary" onclick="openSavePointsPanel()" id="btnLoad"
|
<button class="btn secondary" onclick="openSavePointsPanel()" id="btnLoad" type="button"
|
||||||
type="button" data-i18n="btnLoad">Save-Points</button>
|
data-i18n="btnLoad">Save-Points</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,17 +152,17 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="solver-controls">
|
<div class="solver-controls">
|
||||||
<button class="btn" onclick="prepareSolutionTrace()" id="btnPrepareSolve"
|
<button class="btn" onclick="prepareSolutionTrace()" id="btnPrepareSolve" type="button"
|
||||||
type="button" data-i18n="btnPrepareSolve">Animation vorbereiten</button>
|
data-i18n="btnPrepareSolve">Animation vorbereiten</button>
|
||||||
<button class="btn secondary" onclick="nextSolverStep()" id="btnNextStep"
|
<button class="btn secondary" onclick="nextSolverStep()" id="btnNextStep" type="button"
|
||||||
type="button" data-i18n="btnNextStep">Nächster Schritt</button>
|
data-i18n="btnNextStep">Nächster Schritt</button>
|
||||||
<button class="btn secondary" onclick="toggleSolverPlayback()" id="btnPlayPause"
|
<button class="btn secondary" onclick="toggleSolverPlayback()" id="btnPlayPause"
|
||||||
type="button" data-i18n="btnPlayPause">Animation abspielen</button>
|
type="button" data-i18n="btnPlayPause">Animation abspielen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="solver-speed">
|
<div class="solver-speed">
|
||||||
<label class="solver-speed-label" id="solverSpeedLabel"
|
<label class="solver-speed-label" id="solverSpeedLabel" for="solverSpeedInput"
|
||||||
for="solverSpeedInput" data-i18n="solverSpeedLabel">Animationsgeschwindigkeit</label>
|
data-i18n="solverSpeedLabel">Animationsgeschwindigkeit</label>
|
||||||
<input id="solverSpeedInput" type="range" min="80" max="900" step="20" value="320"
|
<input id="solverSpeedInput" type="range" min="80" max="900" step="20" value="320"
|
||||||
oninput="updateSolverSpeed(this.value)">
|
oninput="updateSolverSpeed(this.value)">
|
||||||
<span class="solver-speed-value" id="solverSpeedValue">320 ms</span>
|
<span class="solver-speed-value" id="solverSpeedValue">320 ms</span>
|
||||||
@@ -175,8 +176,8 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="instant-solver-actions">
|
<div class="instant-solver-actions">
|
||||||
<button class="btn accent" onclick="instantSolve()" id="btnInstantSolve"
|
<button class="btn accent" onclick="instantSolve()" id="btnInstantSolve" type="button"
|
||||||
type="button" data-i18n="btnInstantSolve">Sofort lösen</button>
|
data-i18n="btnInstantSolve">Sofort lösen</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,12 +197,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="section-kicker" id="historyKicker" data-i18n="historyKicker">Save-Points</p>
|
<p class="section-kicker" id="historyKicker" data-i18n="historyKicker">Save-Points</p>
|
||||||
<h3 id="historyTitle" data-i18n="historyTitle">Gespeicherte Zeiten und Fortschritte</h3>
|
<h3 id="historyTitle" data-i18n="historyTitle">Gespeicherte Zeiten und Fortschritte</h3>
|
||||||
<p class="history-subtitle" id="historySubtitle" data-i18n="historySubtitle">Die Tabelle zeigt gespeicherte Save-Points mit
|
<p class="history-subtitle" id="historySubtitle" data-i18n="historySubtitle">Die Tabelle zeigt
|
||||||
|
gespeicherte Save-Points mit
|
||||||
Zeit, Schritten und
|
Zeit, Schritten und
|
||||||
Status.</p>
|
Status.</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn secondary" onclick="refreshTimeHistory()" id="btnRefreshHistory"
|
<button class="btn secondary" onclick="refreshTimeHistory()" id="btnRefreshHistory" type="button"
|
||||||
type="button" data-i18n="btnRefreshHistory">Aktualisieren</button>
|
data-i18n="btnRefreshHistory">Aktualisieren</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="history-table-wrapper">
|
<div class="history-table-wrapper">
|
||||||
@@ -219,7 +221,8 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="timeHistoryTableBody">
|
<tbody id="timeHistoryTableBody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="history-empty" data-i18n="historyEmpty">Noch keine gespeicherten Zeiten vorhanden.</td>
|
<td colspan="7" class="history-empty" data-i18n="historyEmpty">Noch keine gespeicherten
|
||||||
|
Zeiten vorhanden.</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -238,14 +241,17 @@
|
|||||||
|
|
||||||
<p id="settingsSidebarText" data-i18n="settingsSidebarText">Passe die Brettgrösse zwischen 4 und 13 an.</p>
|
<p id="settingsSidebarText" data-i18n="settingsSidebarText">Passe die Brettgrösse zwischen 4 und 13 an.</p>
|
||||||
|
|
||||||
<label class="sidebar-label" id="boardSizeInputLabel" for="boardSizeInput" data-i18n="boardSizeInputLabel">Grösse</label>
|
<label class="sidebar-label" id="boardSizeInputLabel" for="boardSizeInput"
|
||||||
|
data-i18n="boardSizeInputLabel">Grösse</label>
|
||||||
<input id="boardSizeInput" type="range" min="4" max="13" value="8" oninput="previewBoardSize(this.value)">
|
<input id="boardSizeInput" type="range" min="4" max="13" value="8" oninput="previewBoardSize(this.value)">
|
||||||
<div class="sidebar-size-row">
|
<div class="sidebar-size-row">
|
||||||
<strong><span id="sidebarBoardSizeValue">8</span> x <span id="sidebarBoardSizeValueMirror">8</span></strong>
|
<strong><span id="sidebarBoardSizeValue">8</span> x <span id="sidebarBoardSizeValueMirror">8</span></strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn" onclick="applyBoardSize()" id="applyBoardSizeButton" type="button" data-i18n="applyBoardSizeButton">Grösse anwenden</button>
|
<button class="btn" onclick="applyBoardSize()" id="applyBoardSizeButton" type="button"
|
||||||
<p class="sidebar-hint" id="settingsSidebarHint" data-i18n="settingsSidebarHint">Beim Ändern wird das Brett zur Sicherheit zurückgesetzt. Auf
|
data-i18n="applyBoardSizeButton">Grösse anwenden</button>
|
||||||
|
<p class="sidebar-hint" id="settingsSidebarHint" data-i18n="settingsSidebarHint">Beim Ändern wird das Brett zur
|
||||||
|
Sicherheit zurückgesetzt. Auf
|
||||||
dem Handy ist 8x8 eingeplant, grössere Bretter können abgeschnitten wirken.</p>
|
dem Handy ist 8x8 eingeplant, grössere Bretter können abgeschnitten wirken.</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -256,7 +262,8 @@
|
|||||||
type="button" data-i18n="saveSidebarClose">Schliessen</button>
|
type="button" data-i18n="saveSidebarClose">Schliessen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="saveSidebarText" data-i18n="saveSidebarText">Gib dem Spielstand einen klaren Namen und optional eine kurze Notiz.</p>
|
<p id="saveSidebarText" data-i18n="saveSidebarText">Gib dem Spielstand einen klaren Namen und optional eine
|
||||||
|
kurze Notiz.</p>
|
||||||
|
|
||||||
<label class="sidebar-label" id="saveNameLabel" for="saveNameInput" data-i18n="saveNameLabel">Name</label>
|
<label class="sidebar-label" id="saveNameLabel" for="saveNameInput" data-i18n="saveNameLabel">Name</label>
|
||||||
<input class="sidebar-input" id="saveNameInput" type="text" maxlength="80"
|
<input class="sidebar-input" id="saveNameInput" type="text" maxlength="80"
|
||||||
@@ -271,7 +278,8 @@
|
|||||||
<span id="saveFavoriteLabel" data-i18n="saveFavoriteLabel">Als Favorit markieren</span>
|
<span id="saveFavoriteLabel" data-i18n="saveFavoriteLabel">Als Favorit markieren</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button class="btn" onclick="saveGame()" id="saveNowButton" type="button" data-i18n="saveNowButton">Jetzt speichern</button>
|
<button class="btn" onclick="saveGame()" id="saveNowButton" type="button" data-i18n="saveNowButton">Jetzt
|
||||||
|
speichern</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<aside class="sidebar save-points-sidebar" id="savePointsSidebar" aria-hidden="true">
|
<aside class="sidebar save-points-sidebar" id="savePointsSidebar" aria-hidden="true">
|
||||||
@@ -284,14 +292,16 @@
|
|||||||
<p id="savePointsSidebarText" data-i18n="savePointsSidebarText">Wähle einen Speicherstand aus der Liste.</p>
|
<p id="savePointsSidebarText" data-i18n="savePointsSidebarText">Wähle einen Speicherstand aus der Liste.</p>
|
||||||
|
|
||||||
<div class="save-point-filters">
|
<div class="save-point-filters">
|
||||||
<label class="sidebar-label" id="savePointModeFilterLabel" for="savePointModeFilter" data-i18n="savePointModeFilterLabel">Modus</label>
|
<label class="sidebar-label" id="savePointModeFilterLabel" for="savePointModeFilter"
|
||||||
|
data-i18n="savePointModeFilterLabel">Modus</label>
|
||||||
<select class="sidebar-input" id="savePointModeFilter" onchange="refreshSavePoints()">
|
<select class="sidebar-input" id="savePointModeFilter" onchange="refreshSavePoints()">
|
||||||
<option value="all" data-i18n="filterAll">Alle</option>
|
<option value="all" data-i18n="filterAll">Alle</option>
|
||||||
<option value="queens" data-i18n="filterQueens">Damen</option>
|
<option value="queens" data-i18n="filterQueens">Damen</option>
|
||||||
<option value="rooks" data-i18n="filterRooks">Türme</option>
|
<option value="rooks" data-i18n="filterRooks">Türme</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label class="sidebar-label" id="savePointSizeFilterLabel" for="savePointSizeFilter" data-i18n="savePointSizeFilterLabel">Brettgrösse</label>
|
<label class="sidebar-label" id="savePointSizeFilterLabel" for="savePointSizeFilter"
|
||||||
|
data-i18n="savePointSizeFilterLabel">Brettgrösse</label>
|
||||||
<select class="sidebar-input" id="savePointSizeFilter" onchange="refreshSavePoints()">
|
<select class="sidebar-input" id="savePointSizeFilter" onchange="refreshSavePoints()">
|
||||||
<option value="all" data-i18n="filterAll">Alle</option>
|
<option value="all" data-i18n="filterAll">Alle</option>
|
||||||
{% for board_size in range(4, 14) %}
|
{% for board_size in range(4, 14) %}
|
||||||
@@ -299,7 +309,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label class="sidebar-label" id="savePointSortFilterLabel" for="savePointSortFilter" data-i18n="savePointSortFilterLabel">Sortierung</label>
|
<label class="sidebar-label" id="savePointSortFilterLabel" for="savePointSortFilter"
|
||||||
|
data-i18n="savePointSortFilterLabel">Sortierung</label>
|
||||||
<select class="sidebar-input" id="savePointSortFilter" onchange="refreshSavePoints()">
|
<select class="sidebar-input" id="savePointSortFilter" onchange="refreshSavePoints()">
|
||||||
<option value="newest" data-i18n="filterNewest">Neueste zuerst</option>
|
<option value="newest" data-i18n="filterNewest">Neueste zuerst</option>
|
||||||
<option value="oldest" data-i18n="filterOldest">älteste zuerst</option>
|
<option value="oldest" data-i18n="filterOldest">älteste zuerst</option>
|
||||||
@@ -309,12 +320,14 @@
|
|||||||
|
|
||||||
<label class="sidebar-checkbox">
|
<label class="sidebar-checkbox">
|
||||||
<input id="savePointFavoritesOnly" type="checkbox" onchange="refreshSavePoints()">
|
<input id="savePointFavoritesOnly" type="checkbox" onchange="refreshSavePoints()">
|
||||||
<span id="savePointFavoritesOnlyLabel" data-i18n="savePointFavoritesOnlyLabel">Nur Favoriten anzeigen</span>
|
<span id="savePointFavoritesOnlyLabel" data-i18n="savePointFavoritesOnlyLabel">Nur Favoriten
|
||||||
|
anzeigen</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="save-points-list" id="savePointsList">
|
<div class="save-points-list" id="savePointsList">
|
||||||
<p class="sidebar-hint" id="savePointsEmptyState" data-i18n="savePointsEmptyState">Noch keine Speicherstände gefunden.</p>
|
<p class="sidebar-hint" id="savePointsEmptyState" data-i18n="savePointsEmptyState">Noch keine Speicherstände
|
||||||
|
gefunden.</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -326,12 +339,13 @@
|
|||||||
<section class="modal" id="infoModal" aria-hidden="true" aria-labelledby="infoModalTitle">
|
<section class="modal" id="infoModal" aria-hidden="true" aria-labelledby="infoModalTitle">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="infoModalTitle">Spielregeln und Acht-Damen-Problem</h3>
|
<h3 id="infoModalTitle">Spielregeln und Acht-Damen-Problem</h3>
|
||||||
<button class="btn secondary sidebar-close" onclick="toggleInfoModal(false)"
|
<button class="btn secondary sidebar-close" onclick="toggleInfoModal(false)" type="button"
|
||||||
type="button" data-i18n="infoModalClose">Schliessen</button>
|
data-i18n="infoModalClose">Schliessen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<p data-i18n="infoModalIntro">Setze pro Reihe genau eine Figur auf das Brett. Eine gültige Lösung hat keine Konflikte.</p>
|
<p data-i18n="infoModalIntro">Setze pro Reihe genau eine Figur auf das Brett. Eine gültige Lösung hat keine
|
||||||
|
Konflikte.</p>
|
||||||
|
|
||||||
<h4 data-i18n="infoModalModeHeading">So funktioniert der aktuelle Modus</h4>
|
<h4 data-i18n="infoModalModeHeading">So funktioniert der aktuelle Modus</h4>
|
||||||
<p id="infoModeRuleText">Im Damen-Modus dürfen sich Damen weder in derselben Spalte noch diagonal
|
<p id="infoModeRuleText">Im Damen-Modus dürfen sich Damen weder in derselben Spalte noch diagonal
|
||||||
@@ -343,7 +357,8 @@
|
|||||||
kombinatorische Suche und Backtracking.</p>
|
kombinatorische Suche und Backtracking.</p>
|
||||||
|
|
||||||
<h4 data-i18n="infoModalTimeHeading">Was zeigen Zeit und Schritte?</h4>
|
<h4 data-i18n="infoModalTimeHeading">Was zeigen Zeit und Schritte?</h4>
|
||||||
<p data-i18n="infoModalTimeText">Der Schrittzähler erfasst deine Züge auf dem Brett. Die Zeit läuft für das aktuelle Spiel weiter,
|
<p data-i18n="infoModalTimeText">Der Schrittzähler erfasst deine Züge auf dem Brett. Die Zeit läuft für das
|
||||||
|
aktuelle Spiel weiter,
|
||||||
auch wenn du die Seite neu lädst. Beim Speichern werden Zeit und Schritte zusammen mit dem
|
auch wenn du die Seite neu lädst. Beim Speichern werden Zeit und Schritte zusammen mit dem
|
||||||
Save-Point abgelegt.</p>
|
Save-Point abgelegt.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="legal-card">
|
<section class="legal-card">
|
||||||
<h2 data-i18n="processingPurposeTitle">3. Zweck der Verarbeitung</h2>
|
<h2 data-i18n="processingPurposeTitle">2. Zweck der Verarbeitung</h2>
|
||||||
<p data-i18n="processingPurposeText">Die Verarbeitung erfolgt, um die Funktionen dieser Anwendung
|
<p data-i18n="processingPurposeText">Die Verarbeitung erfolgt, um die Funktionen dieser Anwendung
|
||||||
bereitzustellen. Dazu gehoeren insbesondere:
|
bereitzustellen. Dazu gehoeren insbesondere:
|
||||||
</p>
|
</p>
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="legal-card">
|
<section class="legal-card">
|
||||||
<h2 data-i18n="legalBasisTitle">4. Rechtsgrundlage</h2>
|
<h2 data-i18n="legalBasisTitle">3. Rechtsgrundlage</h2>
|
||||||
<p data-i18n="legalBasisText">
|
<p data-i18n="legalBasisText">
|
||||||
Soweit personenbezogene Daten verarbeitet werden, erfolgt dies in der Regel zur Bereitstellung der
|
Soweit personenbezogene Daten verarbeitet werden, erfolgt dies in der Regel zur Bereitstellung der
|
||||||
Anwendung und ihrer Funktionen sowie aufgrund des berechtigten Interesses an einem sicheren und
|
Anwendung und ihrer Funktionen sowie aufgrund des berechtigten Interesses an einem sicheren und
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="legal-card">
|
<section class="legal-card">
|
||||||
<h2 data-i18n="storageTitle">5. Speicherung und Löschung</h2>
|
<h2 data-i18n="storageTitle">4. Speicherung und Löschung</h2>
|
||||||
<p data-i18n="storageText">
|
<p data-i18n="storageText">
|
||||||
Personenbezogene Daten werden nur so lange gespeichert, wie sie für den jeweiligen Zweck benötigt
|
Personenbezogene Daten werden nur so lange gespeichert, wie sie für den jeweiligen Zweck benötigt
|
||||||
werden. Benutzerkonten und zugehörige Spielstände bleiben grundsätzlich gespeichert, bis sie
|
werden. Benutzerkonten und zugehörige Spielstände bleiben grundsätzlich gespeichert, bis sie
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="legal-card">
|
<section class="legal-card">
|
||||||
<h2 data-i18n="thirdPartyTitle">6. Weitergabe an Dritte</h2>
|
<h2 data-i18n="thirdPartyTitle">5. Weitergabe an Dritte</h2>
|
||||||
<p data-i18n="thirdPartyText">
|
<p data-i18n="thirdPartyText">
|
||||||
Eine Weitergabe personenbezogener Daten erfolgt nur, wenn dies für den Betrieb der Anwendung
|
Eine Weitergabe personenbezogener Daten erfolgt nur, wenn dies für den Betrieb der Anwendung
|
||||||
erforderlich ist, etwa durch einen Hosting-Anbieter oder technische Dienstleister. Ansonsten werden
|
erforderlich ist, etwa durch einen Hosting-Anbieter oder technische Dienstleister. Ansonsten werden
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="legal-card">
|
<section class="legal-card">
|
||||||
<h2 data-i18n="rightsTitle">7. Rechte betroffener Personen</h2>
|
<h2 data-i18n="rightsTitle">6. Rechte betroffener Personen</h2>
|
||||||
<p data-i18n="rightsText">Betroffene Personen haben im Rahmen des anwendbaren Datenschutzrechts insbesondere
|
<p data-i18n="rightsText">Betroffene Personen haben im Rahmen des anwendbaren Datenschutzrechts insbesondere
|
||||||
das Recht auf:</p>
|
das Recht auf:</p>
|
||||||
<ul class="legal-list">
|
<ul class="legal-list">
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="legal-card">
|
<section class="legal-card">
|
||||||
<h2 data-i18n="securityTitle">8. Datensicherheit</h2>
|
<h2 data-i18n="securityTitle">7. Datensicherheit</h2>
|
||||||
<p data-i18n="securityText">
|
<p data-i18n="securityText">
|
||||||
Es werden technische und organisatorische Massnahmen getroffen, um gespeicherte Daten vor Verlust,
|
Es werden technische und organisatorische Massnahmen getroffen, um gespeicherte Daten vor Verlust,
|
||||||
Missbrauch oder unberechtigtem Zugriff zu schuetzen. Dazu gehoeren insbesondere passwortgeschuetzte
|
Missbrauch oder unberechtigtem Zugriff zu schuetzen. Dazu gehoeren insbesondere passwortgeschuetzte
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="legal-card">
|
<section class="legal-card">
|
||||||
<h2 data-i18n="versionTitle">9. Stand</h2>
|
<h2 data-i18n="versionTitle">8. Stand</h2>
|
||||||
<p data-i18n="versionText">Stand dieser Vorlage: April 2026</p>
|
<p data-i18n="versionText">Stand dieser Vorlage: April 2026</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user