diff --git a/README.md b/README.md index 7e9d212..abe669f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,18 @@ wordle-helper/ ## Für Sysadmins (Betrieb) +### Environment-Variablen + +Für das Admin-Dashboard müssen folgende Variablen gesetzt werden: + +```bash +# Flask Secret Key (für Sessions) +export FLASK_SECRET_KEY="your-super-secret-key-change-this-in-production" + +# Admin-Passwort für das Statistik-Dashboard +export ADMIN_PASSWORD="your-secure-admin-password" +``` + ### Docker (empfohlen) - Build: @@ -55,6 +67,7 @@ docker run --rm -p 8000:8000 wordle-cheater ``` - Health‑Check (lokal): `http://localhost:8000/` +- Admin‑Dashboard: `http://localhost:8000/stats` (passwortgeschützt) Hinweise: diff --git a/app.py b/app.py index 77b9239..5c092a4 100644 --- a/app.py +++ b/app.py @@ -4,12 +4,16 @@ import logging import os from datetime import datetime, timedelta from typing import Tuple, Dict, List -from flask import Flask, render_template, request, send_from_directory +from flask import Flask, render_template, request, send_from_directory, session, redirect, url_for, flash +from functools import wraps app = Flask(__name__) +app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production') + +# Admin-Passwort aus Environment-Variable +ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'admin123') # Logging konfigurieren -import os # Logs-Verzeichnis erstellen, falls es nicht existiert logs_dir = Path(__file__).parent / "logs" @@ -48,6 +52,84 @@ def log_search_query(search_params: dict, user_agent: str = None): logger.info(f"SEARCH: pos='{pos_str}' includes='{includes}' excludes='{excludes}' sources={sources} | User-Agent: {user_agent_clean}") +def login_required(f): + """Decorator für passwortgeschützte Routen""" + @wraps(f) + def decorated_function(*args, **kwargs): + if not session.get('logged_in'): + return redirect(url_for('login')) + return f(*args, **kwargs) + return decorated_function + +def get_statistics(): + """Liest und analysiert die Log-Dateien für Statistiken""" + stats = { + 'total_page_views': 0, + 'total_searches': 0, + 'page_views_by_page': {}, + 'searches_by_source': {'OT': 0, 'WF': 0, 'Both': 0}, + 'recent_activity': [], + 'top_search_patterns': {} + } + + try: + # Aktuelle Log-Datei lesen + log_file = logs_dir / 'app.log' + if log_file.exists(): + with open(log_file, 'r', encoding='utf-8') as f: + for line in f: + if 'PAGE_VIEW:' in line: + stats['total_page_views'] += 1 + # Seite extrahieren + if 'PAGE_VIEW: ' in line: + page = line.split('PAGE_VIEW: ')[1].split(' |')[0] + stats['page_views_by_page'][page] = stats['page_views_by_page'].get(page, 0) + 1 + + elif 'SEARCH:' in line: + stats['total_searches'] += 1 + # Quellen extrahieren + if 'sources=[' in line: + sources_part = line.split('sources=')[1].split(']')[0] + if 'OT' in sources_part and 'WF' in sources_part: + stats['searches_by_source']['Both'] += 1 + elif 'OT' in sources_part: + stats['searches_by_source']['OT'] += 1 + elif 'WF' in sources_part: + stats['searches_by_source']['WF'] += 1 + + # Suchmuster extrahieren + if 'pos=' in line: + pos_part = line.split('pos=\'')[1].split('\'')[0] + if pos_part: + stats['top_search_patterns'][pos_part] = stats['top_search_patterns'].get(pos_part, 0) + 1 + + # Letzte 10 Aktivitäten + if len(stats['recent_activity']) < 10: + timestamp = line.split(' - ')[0] if ' - ' in line else '' + if timestamp: + stats['recent_activity'].append({ + 'timestamp': timestamp, + 'line': line.strip() + }) + + # Backup-Dateien auch durchsuchen + for backup_file in logs_dir.glob("app_*.log.gz"): + try: + import gzip + with gzip.open(backup_file, 'rt', encoding='utf-8') as f: + for line in f: + if 'PAGE_VIEW:' in line: + stats['total_page_views'] += 1 + elif 'SEARCH:' in line: + stats['total_searches'] += 1 + except Exception as e: + logger.error(f"Fehler beim Lesen der Backup-Datei {backup_file}: {e}") + + except Exception as e: + logger.error(f"Fehler beim Lesen der Statistiken: {e}") + + return stats + def cleanup_old_logs(): """Bereinigt Log-Dateien älter als 7 Tage""" try: @@ -201,5 +283,41 @@ def service_worker(): return send_from_directory(Path(__file__).parent / 'static', 'sw.js', mimetype='application/javascript') +@app.route('/login', methods=['GET', 'POST']) +def login(): + """Login-Seite für das Admin-Dashboard""" + if request.method == 'POST': + password = request.form.get('password') + logger.info(f"Login-Versuch: Passwort-Länge: {len(password) if password else 0}, ADMIN_PASSWORD gesetzt: {bool(ADMIN_PASSWORD)}") + if password == ADMIN_PASSWORD: + session['logged_in'] = True + flash('Erfolgreich angemeldet!', 'success') + logger.info("Login erfolgreich") + return redirect(url_for('stats')) + else: + flash('Falsches Passwort!', 'error') + logger.warning(f"Login fehlgeschlagen - eingegebenes Passwort: '{password}', erwartetes: '{ADMIN_PASSWORD}'") + + return render_template('login.html') + + +@app.route('/logout') +def logout(): + """Logout-Funktion""" + session.pop('logged_in', None) + flash('Erfolgreich abgemeldet!', 'success') + return redirect(url_for('index')) + + +@app.route('/stats') +@login_required +def stats(): + """Statistik-Dashboard (passwortgeschützt)""" + log_page_view("stats", request.headers.get('User-Agent')) + statistics = get_statistics() + return render_template('stats.html', stats=statistics) + + if __name__ == "__main__": + logger.info(f"App gestartet - ADMIN_PASSWORD gesetzt: {bool(ADMIN_PASSWORD)}, Länge: {len(ADMIN_PASSWORD) if ADMIN_PASSWORD else 0}") app.run(debug=True) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d477e61 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + wordle-helper: + build: . + ports: + - "8000:8000" + volumes: + # Logs-Verzeichnis als Volume mounten + - ./logs:/app/logs + # Optional: Statische Dateien für Entwicklung + - ./static:/app/static:ro + environment: + - PYTHONUNBUFFERED=1 + - FLASK_ENV=production + - FLASK_SECRET_KEY=${FLASK_SECRET_KEY:-your-secret-key-change-this} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} + restart: unless-stopped + # Container läuft als nicht-root User für bessere Sicherheit + user: "1000:1000" + # Healthcheck für Container-Status + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +volumes: + # Explizite Volume-Definition für Logs + logs: + driver: local + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..6044635 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,117 @@ + + + + + + Admin Login - Wordle‑Cheater + + + + +
+
+

🔐 Admin Login

+

Statistik-Dashboard

+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+
+ + +
+ + +
+ + +
+ + diff --git a/templates/stats.html b/templates/stats.html new file mode 100644 index 0000000..3042bb7 --- /dev/null +++ b/templates/stats.html @@ -0,0 +1,244 @@ + + + + + + Statistiken - Wordle‑Cheater + + + + +
+
+

📊 Statistik-Dashboard

+ +
+ +
+
+

📈 Gesamte Seitenaufrufe

+
{{ stats.total_page_views }}
+
Alle protokollierten Seitenaufrufe
+
+ +
+

🔍 Gesamte Suchvorgänge

+
{{ stats.total_searches }}
+
Alle durchgeführten Wortsuche
+
+ +
+

📱 Seitenaufrufe pro Seite

+ {% if stats.page_views_by_page %} + {% for page, count in stats.page_views_by_page.items() %} +
+ {{ page }}: {{ count }} +
+ {% endfor %} + {% else %} +
Keine Daten verfügbar
+ {% endif %} +
+
+ +
+

🔍 Suchvorgänge nach Quellen

+ {% if stats.searches_by_source and (stats.searches_by_source.OT > 0 or stats.searches_by_source.WF > 0 or stats.searches_by_source.Both > 0) %} +
+ {% set max_count = [stats.searches_by_source.OT, stats.searches_by_source.WF, stats.searches_by_source.Both] | max %} + {% if stats.searches_by_source.OT > 0 %} +
+
{{ stats.searches_by_source.OT }}
+
OpenThesaurus
+
+ {% endif %} + {% if stats.searches_by_source.WF > 0 %} +
+
{{ stats.searches_by_source.WF }}
+
Wordfreq
+
+ {% endif %} + {% if stats.searches_by_source.Both > 0 %} +
+
{{ stats.searches_by_source.Both }}
+
Beide
+
+ {% endif %} +
+ {% else %} +
Keine Suchdaten verfügbar
+ {% endif %} +
+ + {% if stats.top_search_patterns %} +
+

🎯 Häufigste Suchmuster (Positionen)

+ {% for pattern, count in stats.top_search_patterns.items() | sort(attribute='1', reverse=true) %} +
+ {{ pattern or '(leer)' }} + {{ count }} +
+ {% endfor %} +
+ {% endif %} + +
+

⏰ Letzte Aktivitäten

+ {% if stats.recent_activity %} + {% for activity in stats.recent_activity %} +
+ {{ activity.timestamp }} + {{ activity.line.split(' - ', 2)[2] if ' - ' in activity.line else activity.line }} +
+ {% endfor %} + {% else %} +
Keine Aktivitäten verfügbar
+ {% endif %} +
+
+ +