From 5831d1bb9195681e15dd52bd0432ba1b07d09505 Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Tue, 16 Sep 2025 23:41:34 +0200 Subject: [PATCH] Viel Push und achivements + AGB --- pentest/enumerate.py | 187 ++++++++++++++++++ pentest/realistic_enumeration.py | 312 +++++++++++++++++++++++++++++++ public/agb.html | 239 +++++++++++++++++++++++ public/dashboard.html | 17 +- public/js/admin-dashboard.js | 12 +- public/js/dashboard.js | 125 ++++++++++--- routes/api.js | 80 ++++++-- routes/public.js | 11 +- 8 files changed, 934 insertions(+), 49 deletions(-) create mode 100644 pentest/enumerate.py create mode 100644 pentest/realistic_enumeration.py create mode 100644 public/agb.html diff --git a/pentest/enumerate.py b/pentest/enumerate.py new file mode 100644 index 0000000..7878fed --- /dev/null +++ b/pentest/enumerate.py @@ -0,0 +1,187 @@ +import requests +import uuid +import time +import json +from datetime import datetime + +def enumerate_supabase_users(): + base_url = "http://localhost:3000/api/v1/public/user-player" + found_users = [] + total_requests = 0 + + print("🔍 STARTE USER ENUMERATION ÜBER SUPABASE USER IDS") + print("=" * 60) + print(f"Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"Target: {base_url}") + print("=" * 60) + + # Teste verschiedene UUID-Patterns + test_uuids = [ + str(uuid.uuid4()) for _ in range(1000) # Zufällige UUIDs + ] + + print(f"📊 Teste {len(test_uuids)} UUIDs...") + print("-" * 60) + + for i, uuid_str in enumerate(test_uuids, 1): + try: + response = requests.get(f"{base_url}/{uuid_str}", timeout=5) + total_requests += 1 + + if response.status_code == 200: + user_data = response.json() + if user_data.get("success"): + found_users.append(user_data["data"]) + user = user_data["data"] + print(f"✅ [{i:4d}] USER GEFUNDEN!") + print(f" UUID: {uuid_str}") + print(f" Name: {user['firstname']} {user['lastname']}") + print(f" ID: {user['id']}") + print(f" RFID: {user['rfiduid']}") + print(f" Geburtsdatum: {user['birthdate']}") + print(f" Leaderboard: {user['show_in_leaderboard']}") + print("-" * 60) + else: + if i % 100 == 0: # Fortschritt alle 100 Requests + print(f"⏳ [{i:4d}] Kein User gefunden (Fortschritt: {i}/{len(test_uuids)})") + else: + if i % 100 == 0: + print(f"❌ [{i:4d}] HTTP {response.status_code} (Fortschritt: {i}/{len(test_uuids)})") + + except requests.exceptions.RequestException as e: + print(f"🔥 [{i:4d}] Fehler bei UUID {uuid_str}: {e}") + continue + + print("\n" + "=" * 60) + print("📈 ENUMERATION ABGESCHLOSSEN") + print("=" * 60) + print(f"Total Requests: {total_requests}") + print(f"Gefundene Users: {len(found_users)}") + print(f"Erfolgsrate: {(len(found_users)/total_requests*100):.2f}%" if total_requests > 0 else "0%") + + if found_users: + print("\n🎯 GEFUNDENE USERS:") + print("-" * 60) + for i, user in enumerate(found_users, 1): + print(f"{i}. {user['firstname']} {user['lastname']}") + print(f" ID: {user['id']} | RFID: {user['rfiduid']} | Geburtstag: {user['birthdate']}") + print("-" * 60) + + # Speichere Ergebnisse in Datei + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"enumerated_users_{timestamp}.json" + with open(filename, 'w', encoding='utf-8') as f: + json.dump(found_users, f, indent=2, ensure_ascii=False) + print(f"💾 Ergebnisse gespeichert in: {filename}") + else: + print("\n❌ Keine Users gefunden") + + print(f"\n⏰ Abgeschlossen um: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + return found_users + +def enumerate_rfid_uids(api_key, max_attempts=100): + """RFID UID Enumeration (benötigt gültigen API-Key)""" + base_url = "http://localhost:3000/api/v1/private/users/find" + found_rfids = [] + + print("\n🔍 STARTE RFID UID ENUMERATION") + print("=" * 60) + print(f"Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"Target: {base_url}") + print(f"API-Key: {api_key[:10]}...") + print("=" * 60) + + # Generiere RFID UIDs zum Testen + for i in range(1, max_attempts + 1): + # Generiere RFID im Format AA:BB:CC:XX + rfid_uid = f"AA:BB:CC:{i:02X}" + + try: + response = requests.post( + base_url, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + }, + json={"uid": rfid_uid}, + timeout=5 + ) + + if response.status_code == 200: + data = response.json() + if data.get("success") and data.get("data", {}).get("exists"): + found_rfids.append(data["data"]) + user = data["data"] + print(f"✅ [{i:3d}] RFID GEFUNDEN!") + print(f" RFID: {rfid_uid}") + print(f" Name: {user['firstname']} {user['lastname']}") + print(f" Alter: {user['alter']}") + print("-" * 60) + else: + if i % 20 == 0: # Fortschritt alle 20 Requests + print(f"⏳ [{i:3d}] Kein User für RFID {rfid_uid}") + else: + print(f"❌ [{i:3d}] HTTP {response.status_code} für RFID {rfid_uid}") + + except requests.exceptions.RequestException as e: + print(f"🔥 [{i:3d}] Fehler bei RFID {rfid_uid}: {e}") + continue + + print("\n📈 RFID ENUMERATION ABGESCHLOSSEN") + print(f"Gefundene RFIDs: {len(found_rfids)}") + + return found_rfids + +def test_admin_login(): + """Teste Admin Login Enumeration""" + base_url = "http://localhost:3000/api/v1/public/login" + + # Häufige Admin-Usernamen + admin_usernames = [ + "admin", "administrator", "root", "user", "test", "demo", + "admin1", "admin2", "superuser", "manager", "operator" + ] + + print("\n🔍 TESTE ADMIN LOGIN ENUMERATION") + print("=" * 60) + + for username in admin_usernames: + try: + start_time = time.time() + response = requests.post( + base_url, + json={"username": username, "password": "wrongpassword"}, + timeout=5 + ) + end_time = time.time() + response_time = (end_time - start_time) * 1000 # in ms + + print(f"👤 {username:12} | Status: {response.status_code:3d} | Zeit: {response_time:6.1f}ms") + + if response.status_code == 200: + print(f" ⚠️ MÖGLICHERWEISE GÜLTIGER USERNAME!") + + except Exception as e: + print(f"🔥 Fehler bei {username}: {e}") + +# Führe Enumeration aus +if __name__ == "__main__": + print("🚨 NINJA SERVER SECURITY AUDIT - USER ENUMERATION") + print("⚠️ WARNUNG: Nur für autorisierte Sicherheitstests!") + print() + + # 1. Supabase User ID Enumeration + found_users = enumerate_supabase_users() + + # 2. Admin Login Test + test_admin_login() + + # 3. RFID Enumeration (nur mit gültigem API-Key) + api_key = input("\n🔑 API-Key für RFID Enumeration eingeben (oder Enter zum Überspringen): ").strip() + if api_key: + enumerate_rfid_uids(api_key, 50) # Teste nur 50 RFIDs + else: + print("⏭️ RFID Enumeration übersprungen") + + print("\n🏁 AUDIT ABGESCHLOSSEN") \ No newline at end of file diff --git a/pentest/realistic_enumeration.py b/pentest/realistic_enumeration.py new file mode 100644 index 0000000..f1c2f31 --- /dev/null +++ b/pentest/realistic_enumeration.py @@ -0,0 +1,312 @@ +import requests +import time +import json +from datetime import datetime +import statistics + +def test_admin_login_timing(): + """Detaillierte Timing-Analyse für Admin Login""" + base_url = "http://localhost:3000/api/v1/public/login" + + # Erweiterte Liste von Admin-Usernamen + admin_usernames = [ + "admin", "administrator", "root", "user", "test", "demo", + "admin1", "admin2", "superuser", "manager", "operator", + "ninja", "parkour", "system", "api", "service", + "backup", "support", "helpdesk", "it", "tech" + ] + + print("🔍 DETAILLIERTE ADMIN LOGIN TIMING-ANALYSE") + print("=" * 70) + print(f"Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"Target: {base_url}") + print("=" * 70) + + results = [] + + # Teste jeden Username mehrfach für bessere Statistik + for username in admin_usernames: + times = [] + + print(f"\n👤 Testing: {username}") + print("-" * 50) + + for attempt in range(5): # 5 Versuche pro Username + try: + start_time = time.time() + response = requests.post( + base_url, + json={"username": username, "password": "wrongpassword123"}, + timeout=10 + ) + end_time = time.time() + response_time = (end_time - start_time) * 1000 # in ms + times.append(response_time) + + print(f" Attempt {attempt+1}: {response_time:6.1f}ms | Status: {response.status_code}") + + # Kleine Pause zwischen Requests + time.sleep(0.1) + + except Exception as e: + print(f" Attempt {attempt+1}: ERROR - {e}") + continue + + if times: + avg_time = statistics.mean(times) + std_dev = statistics.stdev(times) if len(times) > 1 else 0 + min_time = min(times) + max_time = max(times) + + results.append({ + 'username': username, + 'avg_time': avg_time, + 'std_dev': std_dev, + 'min_time': min_time, + 'max_time': max_time, + 'times': times, + 'suspicious': avg_time > 50 # Verdächtig wenn > 50ms + }) + + print(f" 📊 Stats: Avg={avg_time:.1f}ms, Std={std_dev:.1f}ms, Range={min_time:.1f}-{max_time:.1f}ms") + + if avg_time > 50: + print(f" ⚠️ SUSPEKT: Deutlich längere Response-Zeit!") + + # Sortiere nach durchschnittlicher Response-Zeit + results.sort(key=lambda x: x['avg_time'], reverse=True) + + print("\n" + "=" * 70) + print("📈 TIMING-ANALYSE ERGEBNISSE") + print("=" * 70) + + print(f"{'Username':<15} {'Avg(ms)':<8} {'Std(ms)':<8} {'Min(ms)':<8} {'Max(ms)':<8} {'Status'}") + print("-" * 70) + + for result in results: + status = "⚠️ SUSPEKT" if result['suspicious'] else "✅ Normal" + print(f"{result['username']:<15} {result['avg_time']:<8.1f} {result['std_dev']:<8.1f} " + f"{result['min_time']:<8.1f} {result['max_time']:<8.1f} {status}") + + # Speichere detaillierte Ergebnisse + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"timing_analysis_{timestamp}.json" + with open(filename, 'w', encoding='utf-8') as f: + json.dump(results, f, indent=2, ensure_ascii=False) + print(f"\n💾 Detaillierte Ergebnisse gespeichert in: {filename}") + + return results + +def test_rfid_creation_enumeration(): + """Teste RFID Enumeration über Spieler-Erstellung""" + base_url = "http://localhost:3000/api/v1/public/players/create-with-rfid" + + print("\n🔍 RFID ENUMERATION ÜBER SPIELER-ERSTELLUNG") + print("=" * 70) + print(f"Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"Target: {base_url}") + print("=" * 70) + + found_rfids = [] + + # Teste verschiedene RFID-Patterns + test_patterns = [ + "AA:BB:CC:DD", "AA:BB:CC:DE", "AA:BB:CC:DF", + "11:22:33:44", "11:22:33:45", "11:22:33:46", + "FF:FF:FF:FF", "00:00:00:00", "12:34:56:78", + "AB:CD:EF:12", "DE:AD:BE:EF", "CA:FE:BA:BE" + ] + + for i, rfid in enumerate(test_patterns, 1): + try: + payload = { + "rfiduid": rfid, + "firstname": "Test", + "lastname": "User", + "birthdate": "1990-01-01" + } + + response = requests.post(base_url, json=payload, timeout=5) + + print(f"[{i:2d}] RFID: {rfid:<12} | Status: {response.status_code:3d}", end="") + + if response.status_code == 400: + try: + data = response.json() + if "existiert bereits" in data.get("message", ""): + print(" | ✅ EXISTIERT!") + if "existingPlayer" in data.get("details", {}): + existing = data["details"]["existingPlayer"] + found_rfids.append({ + "rfid": rfid, + "existing_player": existing + }) + print(f" → Name: {existing.get('firstname')} {existing.get('lastname')}") + else: + print(" | ❌ Anderer Fehler") + except: + print(" | ❌ JSON Parse Error") + else: + print(" | ⚠️ Unexpected Status") + + except Exception as e: + print(f"[{i:2d}] RFID: {rfid:<12} | ERROR: {e}") + + print(f"\n📊 RFID Enumeration abgeschlossen: {len(found_rfids)} gefunden") + return found_rfids + +def test_leaderboard_data_leak(): + """Teste Leaderboard auf sensible Daten""" + base_url = "http://localhost:3000/api/v1/public/times-with-details" + + print("\n🔍 LEADERBOARD DATENLEAK-TEST") + print("=" * 70) + print(f"Zeit: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"Target: {base_url}") + print("=" * 70) + + try: + response = requests.get(base_url, timeout=10) + + print(f"Status: {response.status_code}") + + if response.status_code == 200: + data = response.json() + + if isinstance(data, list) and len(data) > 0: + print(f"✅ Leaderboard-Daten gefunden: {len(data)} Einträge") + + # Analysiere erste paar Einträge + for i, entry in enumerate(data[:3]): + print(f"\n📋 Eintrag {i+1}:") + if 'player' in entry: + player = entry['player'] + print(f" Name: {player.get('firstname')} {player.get('lastname')}") + print(f" RFID: {player.get('rfiduid')}") + print(f" ID: {player.get('id')}") + + if 'location' in entry: + location = entry['location'] + print(f" Location: {location.get('name')}") + print(f" Koordinaten: {location.get('latitude')}, {location.get('longitude')}") + + if 'recorded_time_seconds' in entry: + print(f" Zeit: {entry['recorded_time_seconds']} Sekunden") + + # Speichere alle Daten + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"leaderboard_data_{timestamp}.json" + with open(filename, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + print(f"\n💾 Leaderboard-Daten gespeichert in: {filename}") + + return data + else: + print("❌ Keine Leaderboard-Daten gefunden") + else: + print(f"❌ Fehler: HTTP {response.status_code}") + + except Exception as e: + print(f"🔥 Fehler beim Abrufen der Leaderboard-Daten: {e}") + + return None + +def test_error_message_analysis(): + """Analysiere Error Messages auf Information Leakage""" + base_url = "http://localhost:3000/api/v1/public/user-player" + + print("\n🔍 ERROR MESSAGE ANALYSE") + print("=" * 70) + + test_uuids = [ + "00000000-0000-0000-0000-000000000000", # Null UUID + "invalid-uuid-format", # Ungültiges Format + "12345678-1234-1234-1234-123456789012", # Gültiges Format, aber wahrscheinlich nicht existent + "../../../etc/passwd", # Path Traversal + "", # XSS Test + "'; DROP TABLE players; --" # SQL Injection Test + ] + + error_responses = {} + + for i, test_input in enumerate(test_uuids, 1): + try: + response = requests.get(f"{base_url}/{test_input}", timeout=5) + + status_code = response.status_code + + print(f"[{i}] Input: {test_input:<30} | Status: {status_code}") + + if status_code not in error_responses: + error_responses[status_code] = [] + + try: + json_data = response.json() + error_responses[status_code].append({ + 'input': test_input, + 'response': json_data + }) + except: + error_responses[status_code].append({ + 'input': test_input, + 'response': response.text[:200] # Erste 200 Zeichen + }) + + except Exception as e: + print(f"[{i}] Input: {test_input:<30} | ERROR: {e}") + + # Analysiere verschiedene Error-Messages + print(f"\n📊 Error-Message Analyse:") + print("-" * 50) + + for status_code, responses in error_responses.items(): + print(f"Status {status_code}: {len(responses)} Responses") + + # Prüfe auf unterschiedliche Error-Messages + unique_messages = set() + for resp in responses: + if isinstance(resp['response'], dict): + message = resp['response'].get('message', 'No message') + else: + message = str(resp['response'])[:100] + unique_messages.add(message) + + print(f" Unique messages: {len(unique_messages)}") + for msg in list(unique_messages)[:3]: # Zeige erste 3 + print(f" - {msg}") + + return error_responses + +if __name__ == "__main__": + print("🚨 NINJA SERVER - REALISTISCHE SICHERHEITSTESTS") + print("⚠️ WARNUNG: Nur für autorisierte Sicherheitstests!") + print() + + # 1. Admin Login Timing Analysis + timing_results = test_admin_login_timing() + + # 2. RFID Enumeration über Spieler-Erstellung + rfid_results = test_rfid_creation_enumeration() + + # 3. Leaderboard Datenleak-Test + leaderboard_data = test_leaderboard_data_leak() + + # 4. Error Message Analysis + error_analysis = test_error_message_analysis() + + print("\n" + "=" * 70) + print("🏁 REALISTISCHE SICHERHEITSTESTS ABGESCHLOSSEN") + print("=" * 70) + + # Zusammenfassung + suspicious_users = [r for r in timing_results if r['suspicious']] + print(f"🔍 Timing-Suspicious Users: {len(suspicious_users)}") + print(f"🔍 Gefundene RFIDs: {len(rfid_results)}") + print(f"🔍 Leaderboard-Einträge: {len(leaderboard_data) if leaderboard_data else 0}") + + if suspicious_users: + print(f"\n⚠️ SUSPEKTE USERNAMES (Timing):") + for user in suspicious_users: + print(f" - {user['username']}: {user['avg_time']:.1f}ms") + + print(f"\n⏰ Abgeschlossen um: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") diff --git a/public/agb.html b/public/agb.html new file mode 100644 index 0000000..8be8b76 --- /dev/null +++ b/public/agb.html @@ -0,0 +1,239 @@ + + + + + + Allgemeine Geschäftsbedingungen | NinjaCross + + + + +
+
+

Allgemeine Geschäftsbedingungen

+

NinjaCross Parkour System

+
+ +
+
+ Wichtig: Durch die Nutzung des NinjaCross Systems stimmen Sie zu, dass Ihre Daten + (Name, Zeiten, Standorte) im öffentlichen Leaderboard angezeigt werden können. +
+ +
+

1. Geltungsbereich

+

Diese Allgemeinen Geschäftsbedingungen (AGB) gelten für die Nutzung des NinjaCross Parkour Systems + im Schwimmbad. Mit der Registrierung und Nutzung des Systems erkennen Sie diese AGB als verbindlich an.

+
+ +
+

2. Datenverarbeitung und Datenschutz

+ +

2.1 Erhebung von Daten

+

Wir erheben folgende personenbezogene Daten:

+
    +
  • Vor- und Nachname
  • +
  • Geburtsdatum (zur Altersberechnung)
  • +
  • RFID-Kartennummer
  • +
  • Laufzeiten und Standortdaten
  • +
  • Zeitstempel der Aktivitäten
  • +
+ +

2.2 Verwendung der Daten

+

Ihre Daten werden für folgende Zwecke verwendet:

+
    +
  • Leaderboard-Anzeige: Name, Zeiten und Standorte werden im öffentlichen Leaderboard angezeigt
  • +
  • Leistungsauswertung: Erfassung und Bewertung Ihrer Parkour-Zeiten
  • +
  • System-Funktionalität: Zuordnung von Zeiten zu Ihrem Profil über RFID-Karten
  • +
  • Statistiken: Anonymisierte Auswertungen für Systemverbesserungen
  • +
+ +
+ Wichtiger Hinweis: Durch die Annahme dieser AGB stimmen Sie ausdrücklich zu, + dass Ihr Name und Ihre Laufzeiten im öffentlichen Leaderboard sichtbar sind. +
+
+ +
+

3. Leaderboard und Öffentlichkeit

+

Das NinjaCross System verfügt über ein öffentlich zugängliches Leaderboard, das folgende Informationen anzeigt:

+
    +
  • Vollständiger Name der Teilnehmer
  • +
  • Erreichte Laufzeiten
  • +
  • Standort der Aktivität
  • +
  • Datum und Uhrzeit der Aktivität
  • +
+ +

Durch die Nutzung des Systems erklären Sie sich damit einverstanden, dass diese Daten öffentlich angezeigt werden.

+
+ +
+

4. Ihre Rechte

+

4.1 Recht auf Auskunft

+

Sie haben das Recht, Auskunft über die zu Ihrer Person gespeicherten Daten zu verlangen.

+ +

4.2 Recht auf Löschung

+

Sie können jederzeit die Löschung Ihrer Daten und Ihres Profils beantragen.

+ +

4.3 Recht auf Widerspruch

+

Sie können der Verarbeitung Ihrer Daten für das Leaderboard widersprechen. + In diesem Fall werden Ihre Daten aus dem öffentlichen Leaderboard entfernt, + aber weiterhin für die Systemfunktionalität verwendet.

+
+ +
+

5. Haftung und Verantwortung

+

Die Teilnahme am NinjaCross System erfolgt auf eigene Gefahr. Wir haften nicht für:

+
    +
  • Verletzungen während der Nutzung der Parkour-Anlage
  • +
  • Verlust oder Diebstahl der RFID-Karte
  • +
  • Technische Ausfälle des Systems
  • +
+
+ +
+

6. Systemregeln

+

Bei der Nutzung des Systems sind folgende Regeln zu beachten:

+
    +
  • Keine Manipulation der Zeiterfassung
  • +
  • Respektvoller Umgang mit anderen Teilnehmern
  • +
  • Beachtung der Sicherheitshinweise der Anlage
  • +
  • Keine Verwendung falscher Identitäten
  • +
+
+ +
+

7. Änderungen der AGB

+

Wir behalten uns vor, diese AGB zu ändern. Wesentliche Änderungen werden Ihnen + mitgeteilt und erfordern Ihre erneute Zustimmung.

+
+ +
+

8. Kontakt

+

Bei Fragen zu diesen AGB oder zum Datenschutz wenden Sie sich an:

+

+ NinjaCross Team
+ Schwimmbad Ulm
+ E-Mail: info@ninjacross.de
+ Telefon: 0731-123456 +

+
+
+ +
+ Zurück +
+ +
+

Stand: September 2024

+

Diese AGB sind Teil der Registrierung und gelten ab dem Zeitpunkt der Zustimmung.

+
+
+ + diff --git a/public/dashboard.html b/public/dashboard.html index aafbe16..3a93fb3 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -703,6 +703,21 @@ + +
+
+ + +
+
+ ⚠️ Wichtig: Ohne Zustimmung zu den AGB können Sie das System nutzen, + aber Ihre Zeiten werden nicht im öffentlichen Leaderboard angezeigt. +
+
+ @@ -768,6 +783,6 @@ - + diff --git a/public/js/admin-dashboard.js b/public/js/admin-dashboard.js index 1792881..5f99405 100644 --- a/public/js/admin-dashboard.js +++ b/public/js/admin-dashboard.js @@ -400,7 +400,7 @@ function filterData() { displayAchievements(); } else { currentPlayers = filteredData; - displayPlayers(); + displayPlayersWithAchievements(); } break; } @@ -427,7 +427,7 @@ function refreshData() { if (currentAchievementMode === 'achievements') { loadAchievements(); } else { - loadPlayers(); + loadPlayersWithAchievements(); } break; } @@ -1372,7 +1372,7 @@ async function toggleAchievementMode() { currentAchievementMode = 'players'; document.getElementById('dataTitle').textContent = '👥 Spieler-Achievements'; document.getElementById('searchInput').placeholder = 'Spieler durchsuchen...'; - await loadPlayers(); + await loadPlayersWithAchievements(); } else { currentAchievementMode = 'achievements'; document.getElementById('dataTitle').textContent = '🏆 Achievement-Verwaltung'; @@ -1382,7 +1382,7 @@ async function toggleAchievementMode() { } // Load all players with achievement statistics -async function loadPlayers() { +async function loadPlayersWithAchievements() { try { const response = await fetch('/api/v1/admin/achievements/players'); const result = await response.json(); @@ -1390,7 +1390,7 @@ async function loadPlayers() { if (result.success) { currentPlayers = result.data; currentData = result.data; // Set for filtering - displayPlayers(); + displayPlayersWithAchievements(); } else { showError('Fehler beim Laden der Spieler: ' + result.message); } @@ -1401,7 +1401,7 @@ async function loadPlayers() { } // Display players in table -function displayPlayers() { +function displayPlayersWithAchievements() { const content = document.getElementById('dataContent'); if (currentPlayers.length === 0) { diff --git a/public/js/dashboard.js b/public/js/dashboard.js index a4bd23d..077292f 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -618,6 +618,7 @@ async function createRfidPlayerRecord() { const firstname = document.getElementById('playerFirstname').value.trim(); const lastname = document.getElementById('playerLastname').value.trim(); const birthdate = document.getElementById('playerBirthdate').value; + const agbAccepted = document.getElementById('agbAccepted').checked; // Validation if (!rawUid) { @@ -652,6 +653,14 @@ async function createRfidPlayerRecord() { return; } + if (!agbAccepted) { + const agbErrorMsg = currentLanguage === 'de' ? + 'Bitte stimme den Allgemeinen Geschäftsbedingungen zu' : + 'Please accept the Terms of Service'; + showMessage('rfidMessage', agbErrorMsg, 'error'); + return; + } + try { // Format the UID to match database format const formattedUid = formatRfidUid(rawUid); @@ -672,7 +681,8 @@ async function createRfidPlayerRecord() { firstname: firstname, lastname: lastname, birthdate: birthdate, - supabase_user_id: currentUser?.id || null + supabase_user_id: currentUser?.id || null, + agb_accepted: agbAccepted }) }); @@ -689,6 +699,7 @@ async function createRfidPlayerRecord() { document.getElementById('playerFirstname').value = ''; document.getElementById('playerLastname').value = ''; document.getElementById('playerBirthdate').value = ''; + document.getElementById('agbAccepted').checked = false; // Hide create player section since user is now linked const createPlayerSection = document.getElementById('createPlayerSection'); @@ -1139,7 +1150,7 @@ let currentAchievementCategory = 'all'; // Load achievements for the current player async function loadPlayerAchievements() { - if (!currentPlayerId) { + if (!currentPlayerId || currentPlayerId === 'undefined' || currentPlayerId === 'null') { showAchievementsNotAvailable(); return; } @@ -1372,7 +1383,7 @@ function showStatistics() { async function loadAnalyticsData() { try { - if (!currentPlayerId) { + if (!currentPlayerId || currentPlayerId === 'undefined' || currentPlayerId === 'null') { console.error('No player ID available - user not linked'); // Show fallback data when user is not linked displayAnalyticsFallback(); @@ -1400,7 +1411,7 @@ async function loadAnalyticsData() { async function loadStatisticsData() { try { - if (!currentPlayerId) { + if (!currentPlayerId || currentPlayerId === 'undefined' || currentPlayerId === 'null') { console.error('No player ID available - user not linked'); // Show fallback data when user is not linked displayStatisticsFallback(); @@ -1682,7 +1693,7 @@ function showAchievementsNotAvailable() { // Check achievements for current player async function checkPlayerAchievements() { - if (!currentPlayerId) return; + if (!currentPlayerId || currentPlayerId === 'undefined' || currentPlayerId === 'null') return; try { const response = await fetch(`/api/achievements/check/${currentPlayerId}?t=${Date.now()}`, { @@ -1738,8 +1749,13 @@ function showAchievementNotification(newAchievements) { // Initialize achievements when player is loaded function initializeAchievements(playerId) { - currentPlayerId = playerId; - loadPlayerAchievements(); + if (playerId && playerId !== 'undefined' && playerId !== 'null') { + currentPlayerId = playerId; + loadPlayerAchievements(); + } else { + console.warn('Invalid player ID provided to initializeAchievements:', playerId); + currentPlayerId = null; + } } // Convert VAPID key from base64url to Uint8Array @@ -1764,6 +1780,16 @@ function urlBase64ToUint8Array(base64String) { // Web Notification Functions function showWebNotification(title, message, icon = '🏆') { if ('Notification' in window && Notification.permission === 'granted') { + // Log notification details to console + console.log('🔔 Web Notification sent:', { + title: title, + message: message, + icon: icon, + playerId: currentPlayerId || 'unknown', + pushPlayerId: localStorage.getItem('pushPlayerId') || 'unknown', + timestamp: new Date().toISOString() + }); + const notification = new Notification(title, { body: message, icon: '/pictures/icon-192.png', @@ -1809,7 +1835,7 @@ async function checkBestTimeNotifications() { const { daily, weekly, monthly } = result.data; // Check if current player has best times - if (currentPlayerId) { + if (currentPlayerId && currentPlayerId !== 'undefined' && currentPlayerId !== 'null') { const now = new Date(); const isEvening = now.getHours() >= 19; @@ -1910,30 +1936,87 @@ async function checkBestTimeNotifications() { // Check for new achievements and show notifications async function checkAchievementNotifications() { try { - if (!currentPlayerId) return; - + console.log('🔍 checkAchievementNotifications() called'); + // Check if push notifications are enabled const pushPlayerId = localStorage.getItem('pushPlayerId'); if (!pushPlayerId) { console.log('🔕 Push notifications disabled, skipping achievement check'); return; } + + console.log('🔍 Push notifications enabled for player:', pushPlayerId); - const response = await fetch(`/api/achievements/player/${currentPlayerId}?t=${Date.now()}`); + // Use pushPlayerId for notifications instead of currentPlayerId + if (!pushPlayerId || pushPlayerId === 'undefined' || pushPlayerId === 'null') return; + + const response = await fetch(`/api/achievements/player/${pushPlayerId}?t=${Date.now()}`); const result = await response.json(); if (result.success && result.data) { - const newAchievements = result.data.filter(achievement => { - // Check if achievement was earned in the last 5 minutes - const earnedAt = new Date(achievement.earned_at); - const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); - return earnedAt > fiveMinutesAgo; + console.log('🔍 Checking achievements for notifications:', { + totalAchievements: result.data.length, + playerId: pushPlayerId }); + + const newAchievements = result.data.filter(achievement => { + // Only check completed achievements + if (!achievement.is_completed) { + return false; + } + + // Check if achievement was earned in the last 10 minutes (extended window) + const earnedAt = achievement.earned_at ? new Date(achievement.earned_at) : null; + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); + + // If no earned_at date, check if it was completed recently by checking completion_count + if (!earnedAt) { + // Send to server console + fetch('/api/v1/public/log', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + level: 'info', + message: `🔍 Achievement completed but no earned_at: ${achievement.name} (completion_count: ${achievement.completion_count})`, + playerId: pushPlayerId + }) + }).catch(() => {}); // Ignore errors + + // For achievements without earned_at, assume they are new if completion_count is 1 + // This is a fallback for recently completed achievements + return achievement.completion_count === 1; + } + + const isNew = earnedAt > tenMinutesAgo; + + // Send detailed info to server console + fetch('/api/v1/public/log', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + level: 'info', + message: `🔍 Achievement check: ${achievement.name} - earned_at: ${earnedAt.toISOString()}, isNew: ${isNew}, completed: ${achievement.is_completed}`, + playerId: pushPlayerId + }) + }).catch(() => {}); // Ignore errors + + return isNew; + }); + + console.log('🔍 New achievements found:', newAchievements.length); if (newAchievements.length > 0) { for (const achievement of newAchievements) { // Check if notification was already sent for this achievement - const achievementCheck = await fetch(`/api/v1/public/notification-sent/${currentPlayerId}/achievement?achievementId=${achievement.achievement_id}&locationId=${achievement.location_id || ''}`); + const achievementId = achievement.id || achievement.achievement_id; + const locationId = achievement.location_id || ''; + + if (!achievementId) { + console.warn('Achievement ID is missing for achievement:', achievement); + continue; + } + + const achievementCheck = await fetch(`/api/v1/public/notification-sent/${pushPlayerId}/achievement?achievementId=${achievementId}&locationId=${locationId}`); const achievementResult = await achievementCheck.json(); if (!achievementResult.wasSent) { @@ -1949,10 +2032,10 @@ async function checkAchievementNotifications() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - playerId: currentPlayerId, + playerId: pushPlayerId, notificationType: 'achievement', - achievementId: achievement.achievement_id, - locationId: achievement.location_id || null + achievementId: achievementId, + locationId: locationId || null }) }); console.log(`🏆 Achievement notification sent: ${achievement.name}`); @@ -2015,7 +2098,7 @@ function updateLeaderboardSetting() { async function saveSettings() { try { - if (!currentPlayerId) { + if (!currentPlayerId || currentPlayerId === 'undefined' || currentPlayerId === 'null') { console.error('No player ID available'); return; } diff --git a/routes/api.js b/routes/api.js index b846907..f519902 100644 --- a/routes/api.js +++ b/routes/api.js @@ -1045,7 +1045,7 @@ const { checkNameAgainstBlacklist, addToBlacklist, removeFromBlacklist, getBlack // Create new player with RFID and blacklist validation (no auth required for dashboard) router.post('/v1/public/players/create-with-rfid', async (req, res) => { - const { rfiduid, firstname, lastname, birthdate, supabase_user_id } = req.body; + const { rfiduid, firstname, lastname, birthdate, supabase_user_id, agb_accepted } = req.body; // Validierung if (!rfiduid || !firstname || !lastname || !birthdate) { @@ -1119,10 +1119,10 @@ router.post('/v1/public/players/create-with-rfid', async (req, res) => { // Spieler in Datenbank einfügen const result = await pool.query( - `INSERT INTO players (rfiduid, firstname, lastname, birthdate, supabase_user_id, created_at, show_in_leaderboard) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, rfiduid, firstname, lastname, birthdate, created_at`, - [rfiduid, firstname, lastname, birthdate, supabase_user_id || null, new Date(), true] + `INSERT INTO players (rfiduid, firstname, lastname, birthdate, supabase_user_id, created_at, show_in_leaderboard, agb_accepted) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, rfiduid, firstname, lastname, birthdate, created_at, agb_accepted`, + [rfiduid, firstname, lastname, birthdate, supabase_user_id || null, new Date(), !!agb_accepted, !!agb_accepted] ); const newPlayer = result.rows[0]; @@ -1824,6 +1824,12 @@ router.delete('/v1/admin/players/:id', requireAdminAuth, async (req, res) => { try { // Erst alle zugehörigen Zeiten löschen await pool.query('DELETE FROM times WHERE player_id = $1', [playerId]); + + // Alle zugehörigen Notifications löschen + await pool.query('DELETE FROM sent_notifications WHERE player_id = $1', [playerId]); + + // Alle zugehörigen Player Achievements löschen + await pool.query('DELETE FROM player_achievements WHERE player_id = $1', [playerId]); // Dann den Spieler löschen const result = await pool.query('DELETE FROM players WHERE id = $1', [playerId]); @@ -2212,22 +2218,17 @@ router.get('/v1/public/times-with-details', async (req, res) => { // Get all times with player and location details, ordered by time (fastest first) // Only show times from players who have opted into leaderboard visibility + // SECURITY: Only return data needed for leaderboard display const result = await pool.query(` SELECT - t.id, EXTRACT(EPOCH FROM t.recorded_time) as recorded_time_seconds, t.created_at, json_build_object( - 'id', p.id, 'firstname', p.firstname, - 'lastname', p.lastname, - 'rfiduid', p.rfiduid + 'lastname', p.lastname ) as player, json_build_object( - 'id', l.id, - 'name', l.name, - 'latitude', l.latitude, - 'longitude', l.longitude + 'name', l.name ) as location FROM times t LEFT JOIN players p ON t.player_id = p.id @@ -2843,6 +2844,23 @@ router.get('/v1/public/best-times', async (req, res) => { } }); +// ==================== LOGGING ENDPOINT ==================== + +// Log endpoint for client-side logging +router.post('/v1/public/log', async (req, res) => { + try { + const { level, message, playerId } = req.body; + const timestamp = new Date().toISOString(); + + console.log(`[${timestamp}] [${level?.toUpperCase() || 'INFO'}] [Player: ${playerId || 'unknown'}] ${message}`); + + res.json({ success: true }); + } catch (error) { + console.error('Error processing log:', error); + res.status(500).json({ success: false }); + } +}); + // ==================== PUSH NOTIFICATION ENDPOINTS ==================== // Subscribe to push notifications @@ -3026,6 +3044,29 @@ router.get('/v1/public/notification-sent/:playerId/:type', async (req, res) => { const { playerId, type } = req.params; const { achievementId, locationId } = req.query; + // Validate required parameters + if (!playerId || playerId === 'undefined' || playerId === 'null') { + return res.status(400).json({ + success: false, + message: 'Player ID ist erforderlich' + }); + } + + if (!type) { + return res.status(400).json({ + success: false, + message: 'Notification Type ist erforderlich' + }); + } + + // Validate achievementId if provided + if (achievementId && (achievementId === 'undefined' || achievementId === 'null')) { + return res.status(400).json({ + success: false, + message: 'Ungültige Achievement ID' + }); + } + const today = new Date().toISOString().split('T')[0]; const result = await pool.query(` @@ -3058,6 +3099,19 @@ router.post('/v1/public/notification-sent', async (req, res) => { try { const { playerId, notificationType, achievementId, locationId } = req.body; + // Log notification details to server console + const logData = { + playerId: playerId, + notificationType: notificationType, + timestamp: new Date().toISOString() + }; + + // Only add achievementId and locationId if they exist + if (achievementId) logData.achievementId = achievementId; + if (locationId) logData.locationId = locationId; + + console.log('📱 Push Notification marked as sent:', logData); + await pool.query(` INSERT INTO sent_notifications (player_id, notification_type, achievement_id, location_id) VALUES ($1, $2, $3, $4) diff --git a/routes/public.js b/routes/public.js index c08aaf6..8e0f29d 100644 --- a/routes/public.js +++ b/routes/public.js @@ -97,22 +97,17 @@ router.get('/times-with-details', async (req, res) => { } // Get all times with player and location details, ordered by time (fastest first) + // SECURITY: Only return data needed for leaderboard display const result = await pool.query(` SELECT - t.id, EXTRACT(EPOCH FROM t.recorded_time) as recorded_time_seconds, t.created_at, json_build_object( - 'id', p.id, 'firstname', p.firstname, - 'lastname', p.lastname, - 'rfiduid', p.rfiduid + 'lastname', p.lastname ) as player, json_build_object( - 'id', l.id, - 'name', l.name, - 'latitude', l.latitude, - 'longitude', l.longitude + 'name', l.name ) as location FROM times t LEFT JOIN players p ON t.player_id = p.id