Zulassen von Anonymen RFID updates verlinkung der UUID wenn spieler angelegt wurde

This commit is contained in:
2025-09-11 16:33:22 +02:00
parent d2a1bb16ea
commit 28616b3b0c
14 changed files with 2676 additions and 241 deletions

314
config/blacklist-db.js Normal file
View File

@@ -0,0 +1,314 @@
/**
* Datenbankbasierte Blacklist für unerwünschte Namen
* Lädt und verwaltet Blacklist-Einträge aus der Datenbank
*/
const { checkWithCategoryThreshold, checkWithTrigramIndex, TrigramIndex, THRESHOLDS } = require('./levenshtein');
// Datenbankverbindung direkt hier implementieren
const { Pool } = require('pg');
const pool = new Pool({
user: process.env.DB_USER || 'postgres',
host: process.env.DB_HOST || 'localhost',
database: process.env.DB_NAME || 'ninjaserver',
password: process.env.DB_PASSWORD || 'password',
port: process.env.DB_PORT || 5432,
});
// Trigram-Index für Performance-Optimierung
let trigramIndex = null;
let blacklistCache = null;
let lastCacheUpdate = 0;
const CACHE_TTL = 5 * 60 * 1000; // 5 Minuten
/**
* Lädt alle Blacklist-Einträge aus der Datenbank mit Caching
* @returns {Object} - Blacklist gruppiert nach Kategorien
*/
async function loadBlacklistFromDB() {
const now = Date.now();
// Verwende Cache falls verfügbar und nicht abgelaufen
if (blacklistCache && (now - lastCacheUpdate) < CACHE_TTL) {
return blacklistCache;
}
try {
const result = await pool.query(
'SELECT term, category FROM blacklist_terms ORDER BY category, term'
);
const blacklist = {
historical: [],
offensive: [],
titles: [],
brands: [],
inappropriate: []
};
// Erstelle neuen Trigram-Index
trigramIndex = new TrigramIndex();
result.rows.forEach(row => {
if (blacklist[row.category]) {
blacklist[row.category].push(row.term);
// Füge zum Trigram-Index hinzu
trigramIndex.addTerm(row.term, row.category);
}
});
// Cache aktualisieren
blacklistCache = blacklist;
lastCacheUpdate = now;
return blacklist;
} catch (error) {
console.error('Error loading blacklist from database:', error);
// Fallback zur statischen Blacklist
return getStaticBlacklist();
}
}
/**
* Statische Blacklist als Fallback
*/
function getStaticBlacklist() {
return {
historical: [
'adolf', 'hitler', 'adolf hitler', 'adolfhittler',
'mussolini', 'benito', 'benito mussolini',
'stalin', 'joseph stalin', 'mao', 'mao zedong'
],
offensive: [
'satan', 'luzifer', 'teufel', 'devil',
'hurensohn', 'wichser', 'fotze', 'arschloch',
'idiot', 'dummkopf', 'trottel', 'schwachsinnig',
'nazi', 'faschist', 'rassist'
],
titles: [
'lord', 'lady', 'sir', 'dame',
'prinz', 'prinzessin', 'prince', 'princess',
'könig', 'königin', 'king', 'queen',
'doktor', 'professor', 'dr', 'prof'
],
brands: [
'mcdonald', 'coca cola', 'cocacola', 'pepsi',
'nike', 'adidas', 'puma', 'reebok',
'bmw', 'mercedes', 'audi', 'volkswagen'
],
inappropriate: [
'sex', 'porn', 'porno', 'fuck', 'shit',
'bitch', 'whore', 'prostitute',
'drug', 'cocaine', 'heroin', 'marijuana'
]
};
}
/**
* Prüft ob ein Name in der Blacklist steht (exakte Übereinstimmung)
* @param {string} firstname - Vorname
* @param {string} lastname - Nachname
* @returns {Object} - {isBlocked: boolean, reason: string, category: string}
*/
async function checkNameAgainstBlacklist(firstname, lastname) {
if (!firstname || !lastname) {
return { isBlocked: false, reason: '', category: '' };
}
try {
const blacklist = await loadBlacklistFromDB();
const fullName = `${firstname.toLowerCase()} ${lastname.toLowerCase()}`;
const firstNameOnly = firstname.toLowerCase();
const lastNameOnly = lastname.toLowerCase();
// Alle Blacklist-Einträge in einem Array sammeln
const allBlacklistEntries = [];
Object.entries(blacklist).forEach(([category, entries]) => {
entries.forEach(entry => {
allBlacklistEntries.push({
term: entry,
category: category,
reason: getCategoryReason(category)
});
});
});
// 1. Exakte Übereinstimmung prüfen
for (const entry of allBlacklistEntries) {
const term = entry.term.toLowerCase();
// Vollständiger Name
if (fullName.includes(term) || term.includes(fullName)) {
return {
isBlocked: true,
reason: entry.reason,
category: entry.category,
matchedTerm: entry.term,
matchType: 'exact'
};
}
// Vorname allein
if (firstNameOnly.includes(term) || term.includes(firstNameOnly)) {
return {
isBlocked: true,
reason: entry.reason,
category: entry.category,
matchedTerm: entry.term,
matchType: 'exact'
};
}
// Nachname allein
if (lastNameOnly.includes(term) || term.includes(lastNameOnly)) {
return {
isBlocked: true,
reason: entry.reason,
category: entry.category,
matchedTerm: entry.term,
matchType: 'exact'
};
}
}
// 2. Levenshtein-Distanz prüfen (Fuzzy-Matching)
// Verwende Trigram-Index für bessere Performance bei großen Blacklists
let levenshteinResult;
if (trigramIndex && Object.values(blacklist).flat().length > 100) {
// Performance-optimierte Version für große Blacklists
levenshteinResult = checkWithTrigramIndex(firstname, lastname, blacklist, trigramIndex);
} else {
// Standard-Version für kleine Blacklists
for (const [category, entries] of Object.entries(blacklist)) {
const categoryResult = checkWithCategoryThreshold(firstname, lastname, entries, category);
if (categoryResult.hasSimilarTerms) {
levenshteinResult = categoryResult;
break; // Frühe Beendigung bei erstem Match
}
}
}
if (levenshteinResult && levenshteinResult.hasSimilarTerms) {
const bestMatch = levenshteinResult.bestMatch;
return {
isBlocked: true,
reason: `${getCategoryReason(bestMatch.category || 'unknown')} (ähnlich)`,
category: bestMatch.category || 'unknown',
matchedTerm: bestMatch.term,
matchType: 'similar',
similarity: bestMatch.distance,
levenshteinDistance: bestMatch.levenshteinDistance
};
}
return { isBlocked: false, reason: '', category: '' };
} catch (error) {
console.error('Error checking name against blacklist:', error);
return { isBlocked: false, reason: '', category: '' };
}
}
/**
* Gibt eine benutzerfreundliche Begründung für die Kategorie zurück
*/
function getCategoryReason(category) {
const reasons = {
historical: 'Historisch belasteter Name',
offensive: 'Beleidigender oder anstößiger Begriff',
titles: 'Titel oder Berufsbezeichnung',
brands: 'Markenname',
inappropriate: 'Unpassender Begriff'
};
return reasons[category] || 'Unzulässiger Begriff';
}
/**
* Fügt einen neuen Begriff zur Blacklist hinzu
* @param {string} term - Der hinzuzufügende Begriff
* @param {string} category - Die Kategorie
* @param {string} createdBy - Wer hat den Begriff hinzugefügt
*/
async function addToBlacklist(term, category, createdBy = 'admin') {
try {
await pool.query(
'INSERT INTO blacklist_terms (term, category, created_by) VALUES ($1, $2, $3) ON CONFLICT (term, category) DO NOTHING',
[term.toLowerCase(), category, createdBy]
);
// Cache invalidieren
invalidateCache();
return true;
} catch (error) {
console.error('Error adding to blacklist:', error);
throw error;
}
}
/**
* Entfernt einen Begriff aus der Blacklist
* @param {string} term - Der zu entfernende Begriff
* @param {string} category - Die Kategorie
*/
async function removeFromBlacklist(term, category) {
try {
const result = await pool.query(
'DELETE FROM blacklist_terms WHERE term = $1 AND category = $2',
[term.toLowerCase(), category]
);
// Cache invalidieren
invalidateCache();
return result.rowCount > 0;
} catch (error) {
console.error('Error removing from blacklist:', error);
throw error;
}
}
/**
* Invalidiert den Blacklist-Cache
*/
function invalidateCache() {
blacklistCache = null;
trigramIndex = null;
lastCacheUpdate = 0;
}
/**
* Gibt die komplette Blacklist zurück (für Admin-Zwecke)
*/
async function getBlacklist() {
return await loadBlacklistFromDB();
}
/**
* Synchronisiert die statische Blacklist mit der Datenbank
* (Nur für Initial-Setup oder Migration)
*/
async function syncStaticBlacklist() {
const staticBlacklist = getStaticBlacklist();
for (const [category, terms] of Object.entries(staticBlacklist)) {
for (const term of terms) {
try {
await addToBlacklist(term, category, 'system');
} catch (error) {
console.error(`Error syncing term ${term} in category ${category}:`, error);
}
}
}
}
module.exports = {
checkNameAgainstBlacklist,
addToBlacklist,
removeFromBlacklist,
getBlacklist,
loadBlacklistFromDB,
syncStaticBlacklist
};

176
config/blacklist.js Normal file
View File

@@ -0,0 +1,176 @@
/**
* Blacklist für unerwünschte Namen
* Basierend auf deutschen Namensregeln und allgemeinen Richtlinien
*/
const BLACKLIST = {
// Historisch belastete Namen
historical: [
'adolf', 'hitler', 'adolf hitler', 'adolfhittler', 'adolfhittler',
'mussolini', 'benito', 'benito mussolini',
'stalin', 'joseph stalin',
'mao', 'mao zedong',
'pol pot', 'polpot',
'saddam', 'saddam hussein',
'osama', 'osama bin laden',
'kim jong', 'kim jong il', 'kim jong un'
],
// Beleidigende/anstößige Begriffe
offensive: [
'satan', 'luzifer', 'teufel', 'devil',
'hurensohn', 'wichser', 'fotze', 'arschloch',
'idiot', 'dummkopf', 'trottel', 'schwachsinnig',
'nazi', 'faschist', 'rassist',
'terrorist', 'mörder', 'killer'
],
// Titel und Berufsbezeichnungen
titles: [
'lord', 'lady', 'sir', 'dame',
'prinz', 'prinzessin', 'prince', 'princess',
'könig', 'königin', 'king', 'queen',
'kaiser', 'kaiserin', 'emperor', 'empress',
'doktor', 'professor', 'dr', 'prof',
'pastor', 'pfarrer', 'bischof', 'priester',
'richter', 'anwalt', 'notar'
],
// Markennamen (Beispiele)
brands: [
'mcdonald', 'coca cola', 'cocacola', 'pepsi',
'nike', 'adidas', 'puma', 'reebok',
'bmw', 'mercedes', 'audi', 'volkswagen',
'apple', 'microsoft', 'google', 'facebook',
'samsung', 'sony', 'panasonic'
],
// Unpassende Begriffe
inappropriate: [
'sex', 'porn', 'porno', 'fuck', 'shit',
'bitch', 'whore', 'prostitute',
'drug', 'cocaine', 'heroin', 'marijuana',
'bomb', 'explosive', 'weapon', 'gun'
]
};
/**
* Prüft ob ein Name in der Blacklist steht
* @param {string} firstname - Vorname
* @param {string} lastname - Nachname
* @returns {Object} - {isBlocked: boolean, reason: string, category: string}
*/
function checkNameAgainstBlacklist(firstname, lastname) {
if (!firstname || !lastname) {
return { isBlocked: false, reason: '', category: '' };
}
const fullName = `${firstname.toLowerCase()} ${lastname.toLowerCase()}`;
const firstNameOnly = firstname.toLowerCase();
const lastNameOnly = lastname.toLowerCase();
// Alle Blacklist-Einträge in einem Array sammeln
const allBlacklistEntries = [];
Object.entries(BLACKLIST).forEach(([category, entries]) => {
entries.forEach(entry => {
allBlacklistEntries.push({
term: entry,
category: category,
reason: getCategoryReason(category)
});
});
});
// Prüfung durchführen
for (const entry of allBlacklistEntries) {
const term = entry.term.toLowerCase();
// Vollständiger Name
if (fullName.includes(term) || term.includes(fullName)) {
return {
isBlocked: true,
reason: entry.reason,
category: entry.category,
matchedTerm: entry.term
};
}
// Vorname allein
if (firstNameOnly.includes(term) || term.includes(firstNameOnly)) {
return {
isBlocked: true,
reason: entry.reason,
category: entry.category,
matchedTerm: entry.term
};
}
// Nachname allein
if (lastNameOnly.includes(term) || term.includes(lastNameOnly)) {
return {
isBlocked: true,
reason: entry.reason,
category: entry.category,
matchedTerm: entry.term
};
}
}
return { isBlocked: false, reason: '', category: '' };
}
/**
* Gibt eine benutzerfreundliche Begründung für die Kategorie zurück
*/
function getCategoryReason(category) {
const reasons = {
historical: 'Historisch belasteter Name',
offensive: 'Beleidigender oder anstößiger Begriff',
titles: 'Titel oder Berufsbezeichnung',
brands: 'Markenname',
inappropriate: 'Unpassender Begriff'
};
return reasons[category] || 'Unzulässiger Begriff';
}
/**
* Fügt einen neuen Begriff zur Blacklist hinzu
* @param {string} term - Der hinzuzufügende Begriff
* @param {string} category - Die Kategorie
*/
function addToBlacklist(term, category) {
if (BLACKLIST[category] && !BLACKLIST[category].includes(term.toLowerCase())) {
BLACKLIST[category].push(term.toLowerCase());
}
}
/**
* Entfernt einen Begriff aus der Blacklist
* @param {string} term - Der zu entfernende Begriff
* @param {string} category - Die Kategorie
*/
function removeFromBlacklist(term, category) {
if (BLACKLIST[category]) {
const index = BLACKLIST[category].indexOf(term.toLowerCase());
if (index > -1) {
BLACKLIST[category].splice(index, 1);
}
}
}
/**
* Gibt die komplette Blacklist zurück (für Admin-Zwecke)
*/
function getBlacklist() {
return BLACKLIST;
}
module.exports = {
checkNameAgainstBlacklist,
addToBlacklist,
removeFromBlacklist,
getBlacklist,
BLACKLIST
};

336
config/levenshtein.js Normal file
View File

@@ -0,0 +1,336 @@
/**
* Levenshtein-Distanz Algorithmus für Fuzzy-Matching
* Erkennt Abwandlungen und Tippfehler von Blacklist-Begriffen
*/
/**
* Berechnet die Levenshtein-Distanz zwischen zwei Strings
* @param {string} str1 - Erster String
* @param {string} str2 - Zweiter String
* @returns {number} - Distanz (0 = identisch, höher = unterschiedlicher)
*/
function levenshteinDistance(str1, str2) {
const len1 = str1.length;
const len2 = str2.length;
// Erstelle Matrix
const matrix = Array(len2 + 1).fill(null).map(() => Array(len1 + 1).fill(null));
// Initialisiere erste Zeile und Spalte
for (let i = 0; i <= len1; i++) {
matrix[0][i] = i;
}
for (let j = 0; j <= len2; j++) {
matrix[j][0] = j;
}
// Fülle Matrix
for (let j = 1; j <= len2; j++) {
for (let i = 1; i <= len1; i++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[j][i] = Math.min(
matrix[j][i - 1] + 1, // Deletion
matrix[j - 1][i] + 1, // Insertion
matrix[j - 1][i - 1] + cost // Substitution
);
}
}
return matrix[len2][len1];
}
/**
* Berechnet die normalisierte Levenshtein-Distanz (0-1)
* @param {string} str1 - Erster String
* @param {string} str2 - Zweiter String
* @returns {number} - Normalisierte Distanz (0 = identisch, 1 = komplett unterschiedlich)
*/
function normalizedLevenshteinDistance(str1, str2) {
const distance = levenshteinDistance(str1, str2);
const maxLength = Math.max(str1.length, str2.length);
return maxLength === 0 ? 0 : distance / maxLength;
}
/**
* Prüft ob ein String ähnlich zu einem Blacklist-Begriff ist
* @param {string} input - Eingabe-String
* @param {string} blacklistTerm - Blacklist-Begriff
* @param {number} threshold - Schwellenwert (0-1, niedriger = strenger)
* @returns {boolean} - True wenn ähnlich genug
*/
function isSimilarToBlacklistTerm(input, blacklistTerm, threshold = 0.3) {
const normalizedDistance = normalizedLevenshteinDistance(input, blacklistTerm);
return normalizedDistance <= threshold;
}
/**
* Findet ähnliche Begriffe in einer Blacklist
* @param {string} input - Eingabe-String
* @param {Array} blacklistTerms - Array von Blacklist-Begriffen
* @param {number} threshold - Schwellenwert (0-1)
* @returns {Array} - Array von ähnlichen Begriffen mit Distanz
*/
function findSimilarTerms(input, blacklistTerms, threshold = 0.3) {
const similarTerms = [];
const normalizedInput = input.toLowerCase().trim();
// Performance-Optimierung: Frühe Beendigung bei sehr kurzen Strings
if (normalizedInput.length < 2) {
return similarTerms;
}
for (const term of blacklistTerms) {
const normalizedTerm = term.toLowerCase().trim();
// Performance-Optimierung: Skip bei zu großer Längendifferenz
const lengthDiff = Math.abs(normalizedInput.length - normalizedTerm.length);
const maxLengthDiff = Math.ceil(normalizedInput.length * threshold);
if (lengthDiff > maxLengthDiff) {
continue;
}
const distance = normalizedLevenshteinDistance(normalizedInput, normalizedTerm);
if (distance <= threshold) {
similarTerms.push({
term: term,
distance: distance,
levenshteinDistance: levenshteinDistance(normalizedInput, normalizedTerm)
});
}
}
// Sortiere nach Distanz (niedrigste zuerst)
return similarTerms.sort((a, b) => a.distance - b.distance);
}
/**
* Erweiterte Blacklist-Prüfung mit Levenshtein-Distanz und Teilstring-Matching
* @param {string} firstname - Vorname
* @param {string} lastname - Nachname
* @param {Array} blacklistTerms - Array von Blacklist-Begriffen
* @param {number} threshold - Schwellenwert für Ähnlichkeit (0-1)
* @returns {Object} - Prüfungsergebnis mit ähnlichen Begriffen
*/
function checkWithLevenshtein(firstname, lastname, blacklistTerms, threshold = 0.3) {
const fullName = `${firstname.toLowerCase().trim()} ${lastname.toLowerCase().trim()}`;
const firstNameOnly = firstname.toLowerCase().trim();
const lastNameOnly = lastname.toLowerCase().trim();
// Prüfe alle Varianten
const variants = [fullName, firstNameOnly, lastNameOnly];
const allSimilarTerms = [];
for (const variant of variants) {
// 1. Direkte Levenshtein-Prüfung
const similarTerms = findSimilarTerms(variant, blacklistTerms, threshold);
allSimilarTerms.push(...similarTerms);
// 2. Teilstring-Matching: Prüfe alle Wörter im Variant gegen Blacklist
const words = variant.split(/\s+/);
for (const word of words) {
if (word.length >= 2) { // Nur Wörter mit mindestens 2 Zeichen
const wordSimilarTerms = findSimilarTerms(word, blacklistTerms, threshold);
allSimilarTerms.push(...wordSimilarTerms);
}
}
// 3. Teilstring-Matching: Prüfe Blacklist-Begriffe gegen Variant
for (const blacklistTerm of blacklistTerms) {
const normalizedTerm = blacklistTerm.toLowerCase().trim();
if (normalizedTerm.length >= 2) {
// Prüfe ob Blacklist-Begriff als Teilstring im Variant vorkommt
if (variant.includes(normalizedTerm)) {
allSimilarTerms.push({
term: blacklistTerm,
distance: 0, // Exakte Teilstring-Übereinstimmung
levenshteinDistance: 0,
matchType: 'substring'
});
} else {
// Prüfe Levenshtein für Teilstrings
const words = variant.split(/\s+/);
for (const word of words) {
if (word.length >= 2) {
const distance = normalizedLevenshteinDistance(word, normalizedTerm);
if (distance <= threshold) {
allSimilarTerms.push({
term: blacklistTerm,
distance: distance,
levenshteinDistance: levenshteinDistance(word, normalizedTerm),
matchType: 'substring-similar'
});
}
}
}
}
}
}
}
// Entferne Duplikate und sortiere nach Distanz
const uniqueSimilarTerms = allSimilarTerms.reduce((acc, current) => {
const existing = acc.find(item => item.term === current.term);
if (!existing || current.distance < existing.distance) {
return acc.filter(item => item.term !== current.term).concat(current);
}
return acc;
}, []);
return {
hasSimilarTerms: uniqueSimilarTerms.length > 0,
similarTerms: uniqueSimilarTerms.sort((a, b) => a.distance - b.distance),
bestMatch: uniqueSimilarTerms.length > 0 ? uniqueSimilarTerms[0] : null
};
}
/**
* Konfigurierbare Schwellenwerte für verschiedene Kategorien
*/
const THRESHOLDS = {
historical: 0.2, // Sehr streng für historische Begriffe
offensive: 0.25, // Streng für beleidigende Begriffe
titles: 0.3, // Normal für Titel
brands: 0.35, // Etwas lockerer für Marken
inappropriate: 0.3 // Normal für unpassende Begriffe
};
/**
* Performance-optimierte Version für große Blacklists
* Verwendet Trigram-Index für bessere Performance
*/
class TrigramIndex {
constructor() {
this.index = new Map();
}
/**
* Erstellt Trigramme aus einem String
* @param {string} str - Eingabe-String
* @returns {Array} - Array von Trigrammen
*/
createTrigrams(str) {
const normalized = str.toLowerCase().trim();
const trigrams = [];
for (let i = 0; i < normalized.length - 2; i++) {
trigrams.push(normalized.substring(i, i + 3));
}
return trigrams;
}
/**
* Fügt einen Begriff zum Index hinzu
* @param {string} term - Begriff
* @param {string} category - Kategorie
*/
addTerm(term, category) {
const trigrams = this.createTrigrams(term);
for (const trigram of trigrams) {
if (!this.index.has(trigram)) {
this.index.set(trigram, []);
}
this.index.get(trigram).push({ term, category });
}
}
/**
* Findet Kandidaten basierend auf Trigram-Übereinstimmung
* @param {string} input - Eingabe-String
* @param {number} minTrigrams - Mindestanzahl übereinstimmender Trigramme
* @returns {Array} - Array von Kandidaten
*/
findCandidates(input, minTrigrams = 1) {
const inputTrigrams = this.createTrigrams(input);
const candidateCount = new Map();
for (const trigram of inputTrigrams) {
if (this.index.has(trigram)) {
for (const candidate of this.index.get(trigram)) {
const key = `${candidate.term}|${candidate.category}`;
candidateCount.set(key, (candidateCount.get(key) || 0) + 1);
}
}
}
// Filtere Kandidaten mit mindestens minTrigrams Übereinstimmungen
const candidates = [];
for (const [key, count] of candidateCount) {
if (count >= minTrigrams) {
const [term, category] = key.split('|');
candidates.push({ term, category });
}
}
return candidates;
}
}
/**
* Performance-optimierte Blacklist-Prüfung mit Trigram-Index
* @param {string} firstname - Vorname
* @param {string} lastname - Nachname
* @param {Object} blacklist - Blacklist gruppiert nach Kategorien
* @param {TrigramIndex} trigramIndex - Trigram-Index
* @returns {Object} - Prüfungsergebnis
*/
function checkWithTrigramIndex(firstname, lastname, blacklist, trigramIndex) {
const fullName = `${firstname.toLowerCase().trim()} ${lastname.toLowerCase().trim()}`;
const firstNameOnly = firstname.toLowerCase().trim();
const lastNameOnly = lastname.toLowerCase().trim();
const variants = [fullName, firstNameOnly, lastNameOnly];
const allSimilarTerms = [];
for (const variant of variants) {
// Finde Kandidaten mit Trigram-Index
const candidates = trigramIndex.findCandidates(variant, 1);
// Prüfe nur Kandidaten mit Levenshtein
for (const candidate of candidates) {
const categoryTerms = blacklist[candidate.category] || [];
const similarTerms = findSimilarTerms(variant, categoryTerms, THRESHOLDS[candidate.category] || 0.3);
allSimilarTerms.push(...similarTerms);
}
}
// Entferne Duplikate und sortiere
const uniqueSimilarTerms = allSimilarTerms.reduce((acc, current) => {
const existing = acc.find(item => item.term === current.term);
if (!existing || current.distance < existing.distance) {
return acc.filter(item => item.term !== current.term).concat(current);
}
return acc;
}, []);
return {
hasSimilarTerms: uniqueSimilarTerms.length > 0,
similarTerms: uniqueSimilarTerms.sort((a, b) => a.distance - b.distance),
bestMatch: uniqueSimilarTerms.length > 0 ? uniqueSimilarTerms[0] : null
};
}
/**
* Kategorie-spezifische Levenshtein-Prüfung
* @param {string} firstname - Vorname
* @param {string} lastname - Nachname
* @param {Array} blacklistTerms - Array von Blacklist-Begriffen
* @param {string} category - Kategorie der Begriffe
* @returns {Object} - Prüfungsergebnis
*/
function checkWithCategoryThreshold(firstname, lastname, blacklistTerms, category) {
const threshold = THRESHOLDS[category] || 0.3;
return checkWithLevenshtein(firstname, lastname, blacklistTerms, threshold);
}
module.exports = {
levenshteinDistance,
normalizedLevenshteinDistance,
isSimilarToBlacklistTerm,
findSimilarTerms,
checkWithLevenshtein,
checkWithCategoryThreshold,
checkWithTrigramIndex,
TrigramIndex,
THRESHOLDS
};

253
config/llm-blacklist.js Normal file
View File

@@ -0,0 +1,253 @@
/**
* LLM-basierte Blacklist-Prüfung mit Ollama
* Verwendet ein lokales LLM zur intelligenten Bewertung von Namen
*/
const axios = require('axios');
// Ollama-Konfiguration
const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'llama3.2:3b'; // Schnelles, kleines Modell
/**
* Prüft einen Namen mit dem LLM
* @param {string} firstname - Vorname
* @param {string} lastname - Nachname
* @returns {Object} - {isBlocked: boolean, reason: string, confidence: number}
*/
async function checkNameWithLLM(firstname, lastname) {
if (!firstname || !lastname) {
return { isBlocked: false, reason: '', confidence: 0 };
}
try {
const fullName = `${firstname} ${lastname}`;
// Prompt für das LLM
const prompt = `Du bist ein strenger Moderator für ein Spielsystem. Prüfe ob der Name "${fullName}" für die Verwendung geeignet ist.
WICHTIG: Blockiere ALLE Namen die:
- Historisch belastet sind (Adolf Hitler, Stalin, Mussolini, etc.)
- Beleidigend oder anstößig sind (Satan, Idiot, etc.)
- Unpassende Titel sind (Dr., Professor, etc.)
- Markennamen sind (Coca-Cola, Nike, etc.)
- Andere unangemessene Inhalte haben
Antworte NUR mit "TRUE" (blockiert) oder "FALSE" (erlaubt) - keine Erklärungen.
Name: "${fullName}"
Antwort:`;
// Ollama API-Aufruf
const response = await axios.post(`${OLLAMA_BASE_URL}/api/generate`, {
model: OLLAMA_MODEL,
prompt: prompt,
stream: false,
options: {
temperature: 0.1, // Niedrige Temperatur für konsistente Antworten
top_p: 0.9,
max_tokens: 10 // Nur TRUE/FALSE erwartet
}
}, {
timeout: 10000 // 10 Sekunden Timeout
});
const llmResponse = response.data.response.trim().toUpperCase();
// Parse LLM-Antwort
let isBlocked = false;
let reason = '';
let confidence = 0.8; // Standard-Konfidenz für LLM
if (llmResponse === 'TRUE') {
isBlocked = true;
reason = 'Name wurde vom KI-Moderator als ungeeignet eingestuft';
} else if (llmResponse === 'FALSE') {
isBlocked = false;
reason = 'Name wurde vom KI-Moderator als geeignet eingestuft';
} else {
// Fallback bei unerwarteter Antwort
console.warn(`Unerwartete LLM-Antwort: "${llmResponse}" für Name: "${fullName}"`);
isBlocked = false;
reason = 'KI-Moderator konnte Name nicht eindeutig bewerten';
confidence = 0.3;
}
return {
isBlocked,
reason,
confidence,
llmResponse: llmResponse,
matchType: 'llm'
};
} catch (error) {
console.error('Error checking name with LLM:', error);
// Fallback bei LLM-Fehlern
return {
isBlocked: false,
reason: 'KI-Moderator nicht verfügbar - Name wurde erlaubt',
confidence: 0.1,
error: error.message,
matchType: 'llm-error'
};
}
}
/**
* Testet die LLM-Verbindung
* @returns {Object} - {connected: boolean, model: string, error?: string}
*/
async function testLLMConnection() {
try {
const response = await axios.post(`${OLLAMA_BASE_URL}/api/generate`, {
model: OLLAMA_MODEL,
prompt: 'Test',
stream: false,
options: {
max_tokens: 1
}
}, {
timeout: 5000
});
return {
connected: true,
model: OLLAMA_MODEL,
baseUrl: OLLAMA_BASE_URL
};
} catch (error) {
return {
connected: false,
model: OLLAMA_MODEL,
baseUrl: OLLAMA_BASE_URL,
error: error.message
};
}
}
/**
* Erweiterte LLM-Prüfung mit Kontext
* @param {string} firstname - Vorname
* @param {string} lastname - Nachname
* @param {string} context - Zusätzlicher Kontext (optional)
* @returns {Object} - Prüfungsergebnis
*/
async function checkNameWithContext(firstname, lastname, context = '') {
if (!firstname || !lastname) {
return { isBlocked: false, reason: '', confidence: 0 };
}
try {
const fullName = `${firstname} ${lastname}`;
// Erweiterter Prompt mit Kontext
const prompt = `Du bist ein Moderator für ein Spielsystem. Prüfe ob der Name "${fullName}" für die Verwendung geeignet ist.
Kontext: ${context || 'Standard-Spielname'}
Beurteile den Namen basierend auf:
- Historisch belastete Namen (z.B. Adolf Hitler, Stalin, etc.)
- Beleidigende oder anstößige Begriffe
- Unpassende Titel oder Berufsbezeichnungen
- Markennamen die nicht verwendet werden sollten
- Andere unangemessene Inhalte
Antworte NUR mit "TRUE" oder "FALSE" - keine Erklärungen.
Name: "${fullName}"
Antwort:`;
const response = await axios.post(`${OLLAMA_BASE_URL}/api/generate`, {
model: OLLAMA_MODEL,
prompt: prompt,
stream: false,
options: {
temperature: 0.1,
top_p: 0.9,
max_tokens: 10
}
}, {
timeout: 10000
});
const llmResponse = response.data.response.trim().toUpperCase();
let isBlocked = false;
let reason = '';
let confidence = 0.8;
if (llmResponse === 'TRUE') {
isBlocked = true;
reason = 'Name wurde vom KI-Moderator als ungeeignet eingestuft';
} else if (llmResponse === 'FALSE') {
isBlocked = false;
reason = 'Name wurde vom KI-Moderator als geeignet eingestuft';
} else {
console.warn(`Unerwartete LLM-Antwort: "${llmResponse}" für Name: "${fullName}"`);
isBlocked = false;
reason = 'KI-Moderator konnte Name nicht eindeutig bewerten';
confidence = 0.3;
}
return {
isBlocked,
reason,
confidence,
llmResponse: llmResponse,
matchType: 'llm-context',
context: context
};
} catch (error) {
console.error('Error checking name with LLM context:', error);
return {
isBlocked: false,
reason: 'KI-Moderator nicht verfügbar - Name wurde erlaubt',
confidence: 0.1,
error: error.message,
matchType: 'llm-error'
};
}
}
/**
* Batch-Prüfung mehrerer Namen
* @param {Array} names - Array von {firstname, lastname} Objekten
* @returns {Array} - Array von Prüfungsergebnissen
*/
async function checkNamesBatch(names) {
const results = [];
for (const name of names) {
try {
const result = await checkNameWithLLM(name.firstname, name.lastname);
results.push({
...name,
...result
});
} catch (error) {
results.push({
...name,
isBlocked: false,
reason: 'Fehler bei der Prüfung',
confidence: 0,
error: error.message,
matchType: 'error'
});
}
}
return results;
}
module.exports = {
checkNameWithLLM,
checkNameWithContext,
checkNamesBatch,
testLLMConnection,
OLLAMA_BASE_URL,
OLLAMA_MODEL
};

169
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"license": "propriatary", "license": "propriatary",
"dependencies": { "dependencies": {
"@hisma/server-puppeteer": "^0.6.5", "@hisma/server-puppeteer": "^0.6.5",
"axios": "^1.11.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"discord-oauth2": "^2.12.1", "discord-oauth2": "^2.12.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
@@ -745,6 +746,23 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/b4a": { "node_modules/b4a": {
"version": "1.6.7", "version": "1.6.7",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
@@ -1083,6 +1101,18 @@
"color-support": "bin.js" "color-support": "bin.js"
} }
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": { "node_modules/commander": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
@@ -1219,6 +1249,15 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/delegates": { "node_modules/delegates": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -1479,6 +1518,21 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1767,6 +1821,42 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -2016,6 +2106,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-unicode": { "node_modules/has-unicode": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
@@ -4964,6 +5069,21 @@
"tslib": "^2.0.1" "tslib": "^2.0.1"
} }
}, },
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"requires": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"b4a": { "b4a": {
"version": "1.6.7", "version": "1.6.7",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
@@ -5188,6 +5308,14 @@
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==" "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="
}, },
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"requires": {
"delayed-stream": "~1.0.0"
}
},
"commander": { "commander": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
@@ -5279,6 +5407,11 @@
"esprima": "^4.0.1" "esprima": "^4.0.1"
} }
}, },
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
},
"delegates": { "delegates": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -5450,6 +5583,17 @@
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
} }
}, },
"es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"requires": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
}
},
"escalade": { "escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -5647,6 +5791,23 @@
"unpipe": "~1.0.0" "unpipe": "~1.0.0"
} }
}, },
"follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="
},
"form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
}
},
"forwarded": { "forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -5810,6 +5971,14 @@
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
}, },
"has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"requires": {
"has-symbols": "^1.0.3"
}
},
"has-unicode": { "has-unicode": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",

View File

@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@hisma/server-puppeteer": "^0.6.5", "@hisma/server-puppeteer": "^0.6.5",
"axios": "^1.11.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"discord-oauth2": "^2.12.1", "discord-oauth2": "^2.12.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",

View File

@@ -99,6 +99,20 @@
<button class="btn" onclick="showPlayerManagement()">Spieler anzeigen</button> <button class="btn" onclick="showPlayerManagement()">Spieler anzeigen</button>
</div> </div>
<!-- Blacklist-Verwaltung -->
<div class="card">
<h3><span class="icon">🚫</span> Blacklist-Verwaltung</h3>
<p>Verwalte verbotene Namen und Begriffe</p>
<button class="btn" onclick="showBlacklistManagement()">Blacklist verwalten</button>
</div>
<!-- KI-Moderator -->
<div class="card">
<h3><span class="icon">🧠</span> KI-Moderator</h3>
<p>Intelligente Namensprüfung mit Ollama LLM</p>
<button class="btn" onclick="showLLMManagement()">KI-Moderator testen</button>
</div>
<!-- Läufe-Verwaltung --> <!-- Läufe-Verwaltung -->
<div class="card"> <div class="card">
<h3><span class="icon">⏱️</span> Läufe-Verwaltung</h3> <h3><span class="icon">⏱️</span> Läufe-Verwaltung</h3>
@@ -168,6 +182,47 @@
</div> </div>
</div> </div>
<!-- Blacklist Management Modal -->
<div id="blacklistModal" class="modal">
<div class="modal-content" style="max-width: 800px;">
<span class="close" onclick="closeModal('blacklistModal')">&times;</span>
<h3>🚫 Blacklist-Verwaltung</h3>
<div class="message" id="blacklistMessage"></div>
<!-- Test Name Section -->
<div style="border: 1px solid #ddd; padding: 1rem; margin-bottom: 1rem; border-radius: 5px;">
<h4>Name testen</h4>
<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
<input type="text" id="testFirstname" placeholder="Vorname" style="flex: 1; padding: 0.5rem;">
<input type="text" id="testLastname" placeholder="Nachname" style="flex: 1; padding: 0.5rem;">
<button class="btn" onclick="testNameAgainstBlacklist()">Testen</button>
</div>
<div id="testResult" style="padding: 0.5rem; border-radius: 3px; display: none;"></div>
</div>
<!-- Add New Entry Section -->
<div style="border: 1px solid #ddd; padding: 1rem; margin-bottom: 1rem; border-radius: 5px;">
<h4>Neuen Eintrag hinzufügen</h4>
<div style="display: flex; gap: 1rem; margin-bottom: 1rem;">
<input type="text" id="newTerm" placeholder="Begriff" style="flex: 1; padding: 0.5rem;">
<select id="newCategory" style="flex: 1; padding: 0.5rem;">
<option value="historical">Historisch belastet</option>
<option value="offensive">Beleidigend/anstößig</option>
<option value="titles">Titel/Berufsbezeichnung</option>
<option value="brands">Markenname</option>
<option value="inappropriate">Unpassend</option>
</select>
<button class="btn btn-success" onclick="addToBlacklist()">Hinzufügen</button>
</div>
</div>
<!-- Blacklist Content -->
<div id="blacklistContent">
<div class="loading">Lade Blacklist...</div>
</div>
</div>
</div>
<!-- Footer --> <!-- Footer -->
<footer class="footer"> <footer class="footer">
<div class="footer-content"> <div class="footer-content">
@@ -182,6 +237,50 @@
</div> </div>
</footer> </footer>
<!-- LLM-Moderator Modal -->
<div id="llmModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeLLMModal()">&times;</span>
<h2>🧠 KI-Moderator</h2>
<!-- LLM-Status -->
<div id="llmStatus" class="llm-status-section">
<h3>Status</h3>
<div id="llmStatusContent">Lade...</div>
</div>
<!-- Name testen -->
<div class="llm-test-section">
<h3>Name testen</h3>
<div class="form-group">
<label for="llmFirstname">Vorname:</label>
<input type="text" id="llmFirstname" placeholder="Vorname eingeben">
</div>
<div class="form-group">
<label for="llmLastname">Nachname:</label>
<input type="text" id="llmLastname" placeholder="Nachname eingeben">
</div>
<div class="form-group">
<label for="llmContext">Kontext (optional):</label>
<input type="text" id="llmContext" placeholder="Zusätzlicher Kontext">
</div>
<button onclick="testNameWithLLM()" class="btn btn-primary">Mit KI prüfen</button>
</div>
<!-- Ergebnis -->
<div id="llmResult" class="llm-result-section" style="display: none;">
<h3>Ergebnis</h3>
<div id="llmResultContent"></div>
</div>
<!-- Vergleich mit Blacklist -->
<div id="llmComparison" class="llm-comparison-section" style="display: none;">
<h3>Vergleich mit Blacklist</h3>
<div id="llmComparisonContent"></div>
</div>
</div>
</div>
<!-- Application JavaScript --> <!-- Application JavaScript -->
<script src="/js/cookie-consent.js"></script> <script src="/js/cookie-consent.js"></script>
<script src="/js/admin-dashboard.js"></script> <script src="/js/admin-dashboard.js"></script>

View File

@@ -204,11 +204,13 @@ body {
.modal-content { .modal-content {
background-color: rgba(26, 26, 46, 0.95); background-color: rgba(26, 26, 46, 0.95);
margin: 10% auto; margin: 5% auto;
padding: 20px; padding: 20px;
border-radius: 10px; border-radius: 10px;
width: 90%; width: 90%;
max-width: 500px; max-width: 500px;
max-height: 85vh;
overflow-y: auto;
position: relative; position: relative;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
} }
@@ -228,6 +230,37 @@ body {
color: #ffffff; color: #ffffff;
} }
/* Blacklist Modal specific styles */
#blacklistModal .modal-content {
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
}
/* Smooth scrolling for modal content */
.modal-content {
scroll-behavior: smooth;
}
/* Custom scrollbar for modal content */
.modal-content::-webkit-scrollbar {
width: 8px;
}
.modal-content::-webkit-scrollbar-track {
background: rgba(30, 41, 59, 0.3);
border-radius: 4px;
}
.modal-content::-webkit-scrollbar-thumb {
background: rgba(100, 116, 139, 0.6);
border-radius: 4px;
}
.modal-content::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.8);
}
.form-group { .form-group {
margin-bottom: 15px; margin-bottom: 15px;
} }

View File

@@ -239,6 +239,8 @@ body {
border-radius: 1rem; border-radius: 1rem;
width: 90%; width: 90%;
max-width: 500px; max-width: 500px;
max-height: 80vh;
overflow-y: auto;
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
} }
@@ -260,7 +262,36 @@ body {
font-size: 2rem; font-size: 2rem;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
transition: color 0.2s ease; }
/* RFID Modal specific styles */
#rfidModal .modal-content {
max-height: 85vh;
overflow-y: auto;
}
/* Smooth scrolling for modal content */
.modal-content {
scroll-behavior: smooth;
}
/* Custom scrollbar for modal content */
.modal-content::-webkit-scrollbar {
width: 8px;
}
.modal-content::-webkit-scrollbar-track {
background: rgba(30, 41, 59, 0.3);
border-radius: 4px;
}
.modal-content::-webkit-scrollbar-thumb {
background: rgba(100, 116, 139, 0.6);
border-radius: 4px;
}
.modal-content::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.8);
} }
.close:hover { .close:hover {

View File

@@ -85,7 +85,7 @@ body {
.dashboard-grid { .dashboard-grid {
display: grid; display: grid;
grid-template-columns: 1fr 2fr; grid-template-columns: 1fr 2fr 1fr;
gap: 2rem; gap: 2rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@@ -204,7 +204,6 @@ body {
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 0.25rem; padding: 0.25rem;
gap: 0.25rem; gap: 0.25rem;
overflow-x: auto;
} }
.time-tab { .time-tab {
@@ -255,7 +254,7 @@ body {
.refresh-btn { .refresh-btn {
width: 100%; width: 100%;
padding: 1rem; padding: 2rem;
background: linear-gradient(135deg, #00d4ff, #0891b2); background: linear-gradient(135deg, #00d4ff, #0891b2);
border: none; border: none;
border-radius: 0.75rem; border-radius: 0.75rem;
@@ -266,6 +265,7 @@ body {
transition: all 0.2s ease; transition: all 0.2s ease;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
margin-top: 1.5rem;
} }
.refresh-btn:hover { .refresh-btn:hover {
@@ -281,6 +281,65 @@ body {
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
} }
.admin-panel {
background: rgba(15, 23, 42, 0.8);
border: 1px solid #1e293b;
border-radius: 1rem;
padding: 2rem;
backdrop-filter: blur(20px);
display: flex;
flex-direction: column;
justify-content: center;
}
.admin-buttons {
display: flex;
flex-direction: column;
gap: 1rem;
}
.admin-btn {
padding: 1rem 1.5rem;
border: none;
border-radius: 0.75rem;
color: white;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-block;
text-align: center;
width: 100%;
}
.admin-login-btn {
background: linear-gradient(135deg, #ff6b35, #f7931e);
}
.admin-login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(255, 107, 53, 0.3);
}
.dashboard-btn {
background: linear-gradient(135deg, #00d4ff, #0891b2);
}
.dashboard-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(0, 212, 255, 0.3);
}
.logout-btn {
background: linear-gradient(135deg, #dc3545, #c82333);
}
.logout-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(220, 53, 69, 0.3);
}
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -459,75 +518,6 @@ body {
line-height: 1.6; line-height: 1.6;
} }
/* Admin Login Button */
.admin-login-btn {
position: fixed;
top: 2rem;
right: 2rem;
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #ff6b35, #f7931e);
border: none;
border-radius: 0.75rem;
color: white;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-block;
z-index: 1000;
}
.admin-login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(255, 107, 53, 0.3);
}
.dashboard-btn {
position: fixed;
top: 2rem;
right: 2rem;
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #00d4ff, #0891b2);
border: none;
border-radius: 0.75rem;
color: white;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-block;
z-index: 1000;
}
.dashboard-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(0, 212, 255, 0.3);
}
.logout-btn {
position: fixed;
top: 2rem;
right: 12rem;
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #dc3545, #c82333);
border: none;
border-radius: 0.75rem;
color: white;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-block;
z-index: 1000;
}
.logout-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(220, 53, 69, 0.3);
}
.pulse-animation { .pulse-animation {
animation: pulse 2s infinite; animation: pulse 2s infinite;
@@ -650,13 +640,17 @@ body {
/* Mobile First Responsive Design */ /* Mobile First Responsive Design */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.dashboard-grid { .dashboard-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr 1fr;
gap: 1.5rem; gap: 1.5rem;
} }
.stats-grid { .stats-grid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
.admin-panel {
grid-column: 1 / -1;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -676,8 +670,7 @@ body {
.header-section { .header-section {
margin-bottom: 2rem; margin-bottom: 2rem;
margin-top: 5rem; margin-top: 2rem;
/* Account for fixed buttons */
} }
.dashboard-grid { .dashboard-grid {
@@ -686,6 +679,22 @@ body {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.admin-panel {
grid-column: 1;
order: -1; /* Admin panel kommt zuerst */
}
.admin-buttons {
flex-direction: row;
gap: 0.75rem;
}
.admin-btn {
flex: 1;
padding: 0.75rem 1rem;
font-size: 0.8rem;
}
.control-panel { .control-panel {
padding: 1.5rem; padding: 1.5rem;
} }
@@ -817,44 +826,6 @@ body {
display: none; display: none;
} }
.admin-login-btn,
.dashboard-btn,
.logout-btn {
position: fixed;
top: 1rem;
right: 1rem;
padding: 0.75rem 1rem;
font-size: 0.8rem;
min-height: 44px;
touch-action: manipulation;
z-index: 1000;
}
.logout-btn {
right: 8rem;
}
/* Mobile button container for better positioning */
.mobile-nav-buttons {
position: fixed;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 1000;
}
.mobile-nav-buttons .admin-login-btn,
.mobile-nav-buttons .dashboard-btn,
.mobile-nav-buttons .logout-btn {
position: static;
right: auto;
width: 120px;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
text-align: center;
}
.notification-bubble { .notification-bubble {
top: 1rem; top: 1rem;
@@ -896,6 +867,20 @@ body {
padding: 1rem; padding: 1rem;
} }
.admin-panel {
padding: 1rem;
}
.admin-buttons {
flex-direction: column;
gap: 0.5rem;
}
.admin-btn {
padding: 0.75rem;
font-size: 0.75rem;
}
.leaderboard-header { .leaderboard-header {
padding: 1rem; padding: 1rem;
} }
@@ -947,18 +932,6 @@ body {
font-size: 0.75rem; font-size: 0.75rem;
} }
.mobile-nav-buttons {
top: 0.5rem;
right: 0.5rem;
}
.mobile-nav-buttons .admin-login-btn,
.mobile-nav-buttons .dashboard-btn,
.mobile-nav-buttons .logout-btn {
width: 100px;
padding: 0.4rem 0.5rem;
font-size: 0.7rem;
}
} }
/* Landscape orientation on mobile */ /* Landscape orientation on mobile */
@@ -972,19 +945,6 @@ body {
font-size: 2rem; font-size: 2rem;
} }
.mobile-nav-buttons {
flex-direction: row;
gap: 0.5rem;
}
.mobile-nav-buttons .admin-login-btn,
.mobile-nav-buttons .dashboard-btn,
.mobile-nav-buttons .logout-btn {
width: auto;
min-width: 80px;
padding: 0.5rem 0.75rem;
font-size: 0.7rem;
}
.time-tabs { .time-tabs {
flex-direction: row; flex-direction: row;

View File

@@ -296,6 +296,32 @@
</button> </button>
</div> </div>
<!-- Create New Player Section -->
<div id="createPlayerSection" style="border-top: 1px solid #334155; padding-top: 1.5rem; margin-top: 1.5rem;">
<p style="color: #8892b0; text-align: center; margin-bottom: 1rem; font-size: 0.9rem;" data-de="Neuen Spieler mit RFID erstellen:" data-en="Create new player with RFID:">
Neuen Spieler mit RFID erstellen:
</p>
<div class="form-group">
<label for="playerFirstname" style="color: #8892b0; font-size: 0.9rem; margin-bottom: 0.5rem; display: block;" data-de="Vorname:" data-en="First Name:">Vorname:</label>
<input type="text" id="playerFirstname" class="form-input" placeholder="Max" style="text-align: center;">
</div>
<div class="form-group">
<label for="playerLastname" style="color: #8892b0; font-size: 0.9rem; margin-bottom: 0.5rem; display: block;" data-de="Nachname:" data-en="Last Name:">Nachname:</label>
<input type="text" id="playerLastname" class="form-input" placeholder="Mustermann" style="text-align: center;">
</div>
<div class="form-group">
<label for="playerBirthdate" style="color: #8892b0; font-size: 0.9rem; margin-bottom: 0.5rem; display: block;" data-de="Geburtsdatum:" data-en="Birth Date:">Geburtsdatum:</label>
<input type="date" id="playerBirthdate" class="form-input" style="text-align: center;">
</div>
<button class="btn btn-primary" onclick="createRfidPlayerRecord()" style="width: 100%;" data-de="Spieler erstellen" data-en="Create Player">
Spieler erstellen
</button>
</div>
<!-- Scanning Status --> <!-- Scanning Status -->
<div id="scanningStatus" style="display: none; text-align: center; color: #00d4ff; margin-top: 1rem;"> <div id="scanningStatus" style="display: none; text-align: center; color: #00d4ff; margin-top: 1rem;">
<div class="spinner" style="width: 20px; height: 20px; margin: 0 auto 0.5rem;"></div> <div class="spinner" style="width: 20px; height: 20px; margin: 0 auto 0.5rem;"></div>

View File

@@ -3,14 +3,14 @@ let currentDataType = null;
let currentData = []; let currentData = [];
// Beim Laden der Seite // Beim Laden der Seite
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
checkAuth(); checkAuth();
loadStatistics(); loadStatistics();
// Add cookie settings button functionality // Add cookie settings button functionality
const cookieSettingsBtn = document.getElementById('cookie-settings-footer'); const cookieSettingsBtn = document.getElementById('cookie-settings-footer');
if (cookieSettingsBtn) { if (cookieSettingsBtn) {
cookieSettingsBtn.addEventListener('click', function() { cookieSettingsBtn.addEventListener('click', function () {
if (window.cookieConsent) { if (window.cookieConsent) {
window.cookieConsent.resetConsent(); window.cookieConsent.resetConsent();
} }
@@ -25,7 +25,7 @@ function setupEventListeners() {
document.getElementById('logoutBtn').addEventListener('click', logout); document.getElementById('logoutBtn').addEventListener('click', logout);
// Generator Button // Generator Button
document.getElementById('generatorBtn').addEventListener('click', function() { document.getElementById('generatorBtn').addEventListener('click', function () {
window.location.href = '/generator'; window.location.href = '/generator';
}); });
@@ -380,7 +380,7 @@ function filterData() {
); );
}); });
switch(currentDataType) { switch (currentDataType) {
case 'players': case 'players':
displayPlayersTable(filteredData); displayPlayersTable(filteredData);
break; break;
@@ -397,7 +397,7 @@ function filterData() {
} }
function refreshData() { function refreshData() {
switch(currentDataType) { switch (currentDataType) {
case 'players': case 'players':
loadPlayers(); loadPlayers();
break; break;
@@ -422,7 +422,7 @@ function showAddModal() {
const formFields = document.getElementById('formFields'); const formFields = document.getElementById('formFields');
// Modal-Titel basierend auf aktuellem Datentyp setzen // Modal-Titel basierend auf aktuellem Datentyp setzen
switch(currentDataType) { switch (currentDataType) {
case 'players': case 'players':
modalTitle.textContent = 'Neuen Spieler hinzufügen'; modalTitle.textContent = 'Neuen Spieler hinzufügen';
formFields.innerHTML = ` formFields.innerHTML = `
@@ -535,7 +535,7 @@ async function handleAddSubmit(e) {
let successMessage = ''; let successMessage = '';
let method = 'POST'; let method = 'POST';
switch(currentDataType) { switch (currentDataType) {
case 'players': case 'players':
endpoint = isEdit ? `/api/v1/admin/players/${editId}` : '/api/v1/admin/players'; endpoint = isEdit ? `/api/v1/admin/players/${editId}` : '/api/v1/admin/players';
successMessage = isEdit ? 'Spieler erfolgreich aktualisiert' : 'Spieler erfolgreich hinzugefügt'; successMessage = isEdit ? 'Spieler erfolgreich aktualisiert' : 'Spieler erfolgreich hinzugefügt';
@@ -806,3 +806,493 @@ function showError(message) {
messageDiv.style.display = 'none'; messageDiv.style.display = 'none';
}, 3000); }, 3000);
} }
// ============================================================================
// BLACKLIST MANAGEMENT FUNCTIONS
// ============================================================================
async function showBlacklistManagement() {
currentDataType = 'blacklist';
document.getElementById('blacklistModal').style.display = 'block';
await loadBlacklist();
await loadBlacklistStats();
}
async function loadBlacklist() {
try {
const response = await fetch('/api/v1/admin/blacklist');
const result = await response.json();
if (result.success) {
displayBlacklist(result.data);
} else {
showBlacklistMessage('Fehler beim Laden der Blacklist: ' + result.message, 'error');
}
} catch (error) {
console.error('Error loading blacklist:', error);
showBlacklistMessage('Fehler beim Laden der Blacklist', 'error');
}
}
function displayBlacklist(blacklist) {
const content = document.getElementById('blacklistContent');
let html = '<h3 style="color: #ffffff; margin-bottom: 1rem;">📋 Blacklist-Inhalte</h3>';
Object.entries(blacklist).forEach(([category, terms]) => {
const categoryName = getCategoryDisplayName(category);
const categoryIcon = getCategoryIcon(category);
html += `
<div style="border: 1px solid #334155; padding: 1rem; margin-bottom: 1rem; border-radius: 8px; background: rgba(15, 23, 42, 0.3);">
<h4 style="color: #ffffff; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem;">
${categoryIcon} ${categoryName}
<span style="background: #1e293b; color: #8892b0; padding: 0.25rem 0.5rem; border-radius: 12px; font-size: 0.8rem;">
${terms.length} Einträge
</span>
</h4>
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
`;
if (terms.length === 0) {
html += `
<div style="color: #64748b; font-style: italic; padding: 1rem; text-align: center; width: 100%;">
Keine Einträge in dieser Kategorie
</div>
`;
} else {
terms.forEach(term => {
html += `
<span style="background: #1e293b; color: #e2e8f0; padding: 0.375rem 0.75rem; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; border: 1px solid #334155;">
<span style="font-family: monospace; font-size: 0.9rem;">${term}</span>
<button onclick="removeFromBlacklist('${term}', '${category}')"
style="background: #dc2626; color: white; border: none; border-radius: 50%; width: 20px; height: 20px; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; transition: background-color 0.2s;"
onmouseover="this.style.backgroundColor='#b91c1c'"
onmouseout="this.style.backgroundColor='#dc2626'"
title="Entfernen">
×
</button>
</span>
`;
});
}
html += `
</div>
</div>
`;
});
content.innerHTML = html;
}
// Kategorie-Icons hinzufügen
function getCategoryIcon(category) {
const icons = {
historical: '🏛️',
offensive: '⚠️',
titles: '👑',
brands: '🏷️',
inappropriate: '🚫'
};
return icons[category] || '📝';
}
function getCategoryDisplayName(category) {
const names = {
historical: 'Historisch belastet',
offensive: 'Beleidigend/anstößig',
titles: 'Titel/Berufsbezeichnung',
brands: 'Markenname',
inappropriate: 'Unpassend'
};
return names[category] || category;
}
async function testNameAgainstBlacklist() {
const firstname = document.getElementById('testFirstname').value.trim();
const lastname = document.getElementById('testLastname').value.trim();
const resultDiv = document.getElementById('testResult');
if (!firstname || !lastname) {
showBlacklistMessage('Bitte gib Vorname und Nachname ein', 'error');
return;
}
try {
const response = await fetch('/api/v1/admin/blacklist/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ firstname, lastname })
});
const result = await response.json();
if (result.success) {
const testResult = result.data;
if (testResult.isBlocked) {
let matchTypeText = '';
let similarityText = '';
if (testResult.matchType === 'exact') {
matchTypeText = 'Exakte Übereinstimmung';
} else if (testResult.matchType === 'similar') {
matchTypeText = 'Ähnliche Übereinstimmung (Levenshtein)';
const similarityPercent = Math.round((1 - testResult.similarity) * 100);
similarityText = `<br>Ähnlichkeit: ${similarityPercent}% (Distanz: ${testResult.levenshteinDistance})`;
}
resultDiv.innerHTML = `
<div style="background: #ffebee; color: #c62828; padding: 0.5rem; border-radius: 3px;">
<strong>❌ Name ist blockiert</strong><br>
Grund: ${testResult.reason}<br>
Kategorie: ${getCategoryDisplayName(testResult.category)}<br>
Gefundener Begriff: "${testResult.matchedTerm}"<br>
Typ: ${matchTypeText}${similarityText}
</div>
`;
} else {
resultDiv.innerHTML = `
<div style="background: #e8f5e8; color: #2e7d32; padding: 0.5rem; border-radius: 3px;">
<strong>✅ Name ist erlaubt</strong><br>
Der Name "${firstname} ${lastname}" ist nicht in der Blacklist.
</div>
`;
}
resultDiv.style.display = 'block';
} else {
showBlacklistMessage('Fehler beim Testen: ' + result.message, 'error');
}
} catch (error) {
console.error('Error testing name:', error);
showBlacklistMessage('Fehler beim Testen des Namens', 'error');
}
}
async function addToBlacklist() {
const term = document.getElementById('newTerm').value.trim();
const category = document.getElementById('newCategory').value;
if (!term) {
showBlacklistMessage('Bitte gib einen Begriff ein', 'error');
return;
}
try {
const response = await fetch('/api/v1/admin/blacklist', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ term, category })
});
const result = await response.json();
if (result.success) {
showBlacklistMessage('Begriff erfolgreich hinzugefügt', 'success');
document.getElementById('newTerm').value = '';
await loadBlacklist();
await loadBlacklistStats();
} else {
showBlacklistMessage('Fehler beim Hinzufügen: ' + result.message, 'error');
}
} catch (error) {
console.error('Error adding to blacklist:', error);
showBlacklistMessage('Fehler beim Hinzufügen zur Blacklist', 'error');
}
}
async function removeFromBlacklist(term, category) {
if (!confirm(`Möchtest du "${term}" aus der Kategorie "${getCategoryDisplayName(category)}" entfernen?`)) {
return;
}
try {
const response = await fetch('/api/v1/admin/blacklist', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ term, category })
});
const result = await response.json();
if (result.success) {
showBlacklistMessage('Begriff erfolgreich entfernt', 'success');
await loadBlacklist();
await loadBlacklistStats();
} else {
showBlacklistMessage('Fehler beim Entfernen: ' + result.message, 'error');
}
} catch (error) {
console.error('Error removing from blacklist:', error);
showBlacklistMessage('Fehler beim Entfernen aus der Blacklist', 'error');
}
}
function showBlacklistMessage(message, type) {
const messageDiv = document.getElementById('blacklistMessage');
messageDiv.textContent = message;
messageDiv.className = `message ${type}`;
messageDiv.style.display = 'block';
setTimeout(() => {
messageDiv.style.display = 'none';
}, 3000);
}
// Blacklist-Statistiken laden
async function loadBlacklistStats() {
try {
const response = await fetch('/api/v1/admin/blacklist/stats');
const result = await response.json();
if (result.success) {
displayBlacklistStats(result.data);
} else {
console.error('Error loading blacklist stats:', result.message);
}
} catch (error) {
console.error('Error loading blacklist stats:', error);
}
}
// Blacklist-Statistiken anzeigen
function displayBlacklistStats(stats) {
// Erstelle oder aktualisiere Statistiken-Bereich
let statsDiv = document.getElementById('blacklistStats');
if (!statsDiv) {
// Erstelle Statistiken-Bereich am Anfang des Modals
const modalContent = document.querySelector('#blacklistModal .modal-content');
const firstChild = modalContent.firstChild;
statsDiv = document.createElement('div');
statsDiv.id = 'blacklistStats';
statsDiv.style.cssText = 'border: 1px solid #4ade80; padding: 1rem; margin-bottom: 1rem; border-radius: 5px; background: rgba(34, 197, 94, 0.1);';
modalContent.insertBefore(statsDiv, firstChild);
}
let html = `
<h4 style="color: #4ade80; margin-bottom: 1rem;">📊 Blacklist-Statistiken</h4>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
`;
stats.categories.forEach(category => {
const categoryName = getCategoryDisplayName(category.category);
const lastAdded = new Date(category.last_added).toLocaleDateString('de-DE');
html += `
<div style="background: rgba(15, 23, 42, 0.5); padding: 0.75rem; border-radius: 5px; text-align: center;">
<div style="font-size: 1.5rem; font-weight: bold; color: #4ade80;">${category.count}</div>
<div style="font-size: 0.9rem; color: #8892b0;">${categoryName}</div>
<div style="font-size: 0.8rem; color: #64748b;">Letzte: ${lastAdded}</div>
</div>
`;
});
html += `
</div>
<div style="text-align: center; padding: 0.5rem; background: rgba(15, 23, 42, 0.3); border-radius: 5px;">
<strong style="color: #4ade80;">Gesamt: ${stats.total} Begriffe</strong>
</div>
`;
statsDiv.innerHTML = html;
}
// LLM-Management
function showLLMManagement() {
document.getElementById('llmModal').style.display = 'block';
loadLLMStatus();
}
function closeLLMModal() {
document.getElementById('llmModal').style.display = 'none';
}
// LLM-Status laden
async function loadLLMStatus() {
try {
const response = await fetch('/api/v1/admin/llm/status', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('adminToken')}`
}
});
const result = await response.json();
const statusContent = document.getElementById('llmStatusContent');
if (result.success) {
const status = result.data;
if (status.connected) {
statusContent.innerHTML = `
<div style="background: #e8f5e8; color: #2e7d32; padding: 0.5rem; border-radius: 3px;">
<strong>✅ KI-Moderator verbunden</strong><br>
Modell: ${status.model}<br>
URL: ${status.baseUrl}
</div>
`;
} else {
statusContent.innerHTML = `
<div style="background: #ffebee; color: #c62828; padding: 0.5rem; border-radius: 3px;">
<strong>❌ KI-Moderator nicht verfügbar</strong><br>
Modell: ${status.model}<br>
URL: ${status.baseUrl}<br>
Fehler: ${status.error}
</div>
`;
}
} else {
statusContent.innerHTML = `
<div style="background: #ffebee; color: #c62828; padding: 0.5rem; border-radius: 3px;">
<strong>❌ Fehler beim Laden des Status</strong><br>
${result.message}
</div>
`;
}
} catch (error) {
console.error('Error loading LLM status:', error);
document.getElementById('llmStatusContent').innerHTML = `
<div style="background: #ffebee; color: #c62828; padding: 0.5rem; border-radius: 3px;">
<strong>❌ Fehler beim Laden des Status</strong><br>
${error.message}
</div>
`;
}
}
// Name mit LLM testen
async function testNameWithLLM() {
const firstname = document.getElementById('llmFirstname').value.trim();
const lastname = document.getElementById('llmLastname').value.trim();
const context = document.getElementById('llmContext').value.trim();
if (!firstname || !lastname) {
showLLMMessage('Bitte gib Vorname und Nachname ein', 'error');
return;
}
try {
// LLM-Test
const llmResponse = await fetch('/api/v1/admin/llm/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('adminToken')}`
},
body: JSON.stringify({ firstname, lastname, context })
});
const llmResult = await llmResponse.json();
// Blacklist-Test zum Vergleich
const blacklistResponse = await fetch('/api/v1/admin/blacklist/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('adminToken')}`
},
body: JSON.stringify({ firstname, lastname })
});
const blacklistResult = await blacklistResponse.json();
// Ergebnisse anzeigen
displayLLMResults(llmResult, blacklistResult);
} catch (error) {
console.error('Error testing name with LLM:', error);
showLLMMessage('Fehler beim Testen: ' + error.message, 'error');
}
}
// LLM-Ergebnisse anzeigen
function displayLLMResults(llmResult, blacklistResult) {
const resultDiv = document.getElementById('llmResult');
const resultContent = document.getElementById('llmResultContent');
const comparisonDiv = document.getElementById('llmComparison');
const comparisonContent = document.getElementById('llmComparisonContent');
if (llmResult.success) {
const llm = llmResult.data;
let llmStatus = '';
if (llm.isBlocked) {
llmStatus = `
<div style="background: #ffebee; color: #c62828; padding: 0.5rem; border-radius: 3px;">
<strong>❌ Name blockiert</strong><br>
Grund: ${llm.reason}<br>
Konfidenz: ${Math.round(llm.confidence * 100)}%<br>
LLM-Antwort: "${llm.llmResponse}"<br>
Typ: ${llm.matchType}
</div>
`;
} else {
llmStatus = `
<div style="background: #e8f5e8; color: #2e7d32; padding: 0.5rem; border-radius: 3px;">
<strong>✅ Name erlaubt</strong><br>
Grund: ${llm.reason}<br>
Konfidenz: ${Math.round(llm.confidence * 100)}%<br>
LLM-Antwort: "${llm.llmResponse}"<br>
Typ: ${llm.matchType}
</div>
`;
}
resultContent.innerHTML = llmStatus;
resultDiv.style.display = 'block';
// Vergleich mit Blacklist
if (blacklistResult.success) {
const blacklist = blacklistResult.data;
let comparisonStatus = '';
if (blacklist.combined.isBlocked) {
comparisonStatus = `
<div style="background: #fff3e0; color: #f57c00; padding: 0.5rem; border-radius: 3px;">
<strong>⚠️ Name blockiert (${blacklist.combined.source})</strong><br>
Grund: ${blacklist.combined.reason}
</div>
`;
} else {
comparisonStatus = `
<div style="background: #e8f5e8; color: #2e7d32; padding: 0.5rem; border-radius: 3px;">
<strong>✅ Name erlaubt</strong><br>
Sowohl KI als auch Blacklist erlauben den Namen
</div>
`;
}
comparisonContent.innerHTML = comparisonStatus;
comparisonDiv.style.display = 'block';
}
} else {
resultContent.innerHTML = `
<div style="background: #ffebee; color: #c62828; padding: 0.5rem; border-radius: 3px;">
<strong>❌ Fehler beim Testen</strong><br>
${llmResult.message}
</div>
`;
resultDiv.style.display = 'block';
}
}
// LLM-Nachricht anzeigen
function showLLMMessage(message, type) {
const resultDiv = document.getElementById('llmResult');
const resultContent = document.getElementById('llmResultContent');
const color = type === 'error' ? '#c62828' : '#2e7d32';
const bgColor = type === 'error' ? '#ffebee' : '#e8f5e8';
resultContent.innerHTML = `
<div style="background: ${bgColor}; color: ${color}; padding: 0.5rem; border-radius: 3px;">
<strong>${type === 'error' ? '❌' : '✅'} ${message}</strong>
</div>
`;
resultDiv.style.display = 'block';
}

View File

@@ -242,6 +242,53 @@ async function showRFIDSettings() {
openModal('rfidModal'); openModal('rfidModal');
// Reset scanner state // Reset scanner state
stopQRScanner(); stopQRScanner();
// Check if user is already linked and hide/show create player section
await updateCreatePlayerSectionVisibility();
}
// Update visibility of create player section based on link status
async function updateCreatePlayerSectionVisibility() {
const createPlayerSection = document.getElementById('createPlayerSection');
if (!currentUser) {
// No user logged in - hide create player section
if (createPlayerSection) {
createPlayerSection.style.display = 'none';
}
return;
}
try {
// Check if user has a linked player
const response = await fetch(`/api/v1/public/user-player/${currentUser.id}?t=${Date.now()}`);
if (response.ok) {
const result = await response.json();
if (result.success && result.data && result.data.id) {
// User is already linked - hide create player section
if (createPlayerSection) {
createPlayerSection.style.display = 'none';
}
} else {
// User is not linked - show create player section
if (createPlayerSection) {
createPlayerSection.style.display = 'block';
}
}
} else {
// Error checking link status - show create player section as fallback
if (createPlayerSection) {
createPlayerSection.style.display = 'block';
}
}
} catch (error) {
console.error('Error checking link status:', error);
// Error occurred - show create player section as fallback
if (createPlayerSection) {
createPlayerSection.style.display = 'block';
}
}
} }
// Check link status and load times // Check link status and load times
@@ -448,6 +495,117 @@ async function linkManualRfid() {
} }
} }
// Create new RFID player record
async function createRfidPlayerRecord() {
const rawUid = document.getElementById('manualRfidInput').value.trim();
const firstname = document.getElementById('playerFirstname').value.trim();
const lastname = document.getElementById('playerLastname').value.trim();
const birthdate = document.getElementById('playerBirthdate').value;
// Validation
if (!rawUid) {
const inputErrorMsg = currentLanguage === 'de' ?
'Bitte gib eine RFID UID ein' :
'Please enter a RFID UID';
showMessage('rfidMessage', inputErrorMsg, 'error');
return;
}
if (!firstname) {
const inputErrorMsg = currentLanguage === 'de' ?
'Bitte gib einen Vornamen ein' :
'Please enter a first name';
showMessage('rfidMessage', inputErrorMsg, 'error');
return;
}
if (!lastname) {
const inputErrorMsg = currentLanguage === 'de' ?
'Bitte gib einen Nachnamen ein' :
'Please enter a last name';
showMessage('rfidMessage', inputErrorMsg, 'error');
return;
}
if (!birthdate) {
const inputErrorMsg = currentLanguage === 'de' ?
'Bitte gib ein Geburtsdatum ein' :
'Please enter a birth date';
showMessage('rfidMessage', inputErrorMsg, 'error');
return;
}
try {
// Format the UID to match database format
const formattedUid = formatRfidUid(rawUid);
const formattedMsg = currentLanguage === 'de' ?
`Erstelle Spieler: ${firstname} ${lastname} (${formattedUid})` :
`Creating player: ${firstname} ${lastname} (${formattedUid})`;
showMessage('rfidMessage', formattedMsg, 'info');
// Create player record
const response = await fetch('/api/v1/public/players/create-with-rfid', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
rfiduid: formattedUid,
firstname: firstname,
lastname: lastname,
birthdate: birthdate,
supabase_user_id: currentUser?.id || null
})
});
const result = await response.json();
if (result.success) {
const successMsg = currentLanguage === 'de' ?
`Spieler erfolgreich erstellt: ${result.data.firstname} ${result.data.lastname}` :
`Player successfully created: ${result.data.firstname} ${result.data.lastname}`;
showMessage('rfidMessage', successMsg, 'success');
// Clear form
document.getElementById('manualRfidInput').value = '';
document.getElementById('playerFirstname').value = '';
document.getElementById('playerLastname').value = '';
document.getElementById('playerBirthdate').value = '';
// Hide create player section since user is now linked
const createPlayerSection = document.getElementById('createPlayerSection');
if (createPlayerSection) {
createPlayerSection.style.display = 'none';
}
// Refresh times if user is linked
if (currentUser) {
await checkLinkStatusAndLoadTimes();
}
} else {
let errorMsg = result.message;
// Erweitere Fehlermeldung um Levenshtein-Details falls vorhanden
if (result.details) {
if (result.details.matchType === 'similar') {
const similarityPercent = Math.round((1 - result.details.similarity) * 100);
errorMsg += `\n\nÄhnlichkeit: ${similarityPercent}% (Distanz: ${result.details.levenshteinDistance})`;
}
}
showMessage('rfidMessage', errorMsg, 'error');
}
} catch (error) {
console.error('Error creating RFID player record:', error);
const errorMsg = currentLanguage === 'de' ?
`Fehler beim Erstellen: ${error.message}` :
`Error creating: ${error.message}`;
showMessage('rfidMessage', errorMsg, 'error');
}
}
// Link user by RFID UID (core function) // Link user by RFID UID (core function)
async function linkUserByRfidUid(rfidUid) { async function linkUserByRfidUid(rfidUid) {
if (!currentUser) { if (!currentUser) {
@@ -549,6 +707,51 @@ function showTimesNotLinked() {
} }
} }
// Show RFID linked info with help button
function showRFIDLinkedInfo(playerData) {
// Find the RFID card and update it
const rfidCard = document.querySelector('.card[onclick="showRFIDSettings()"]');
if (rfidCard) {
const isGerman = currentLanguage === 'de';
rfidCard.innerHTML = `
<h3>${isGerman ? '🏷️ RFID Verknüpft' : '🏷️ RFID Linked'}</h3>
<div style="background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.3); border-radius: 0.5rem; padding: 1rem; margin: 1rem 0;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<span style="color: #10b981; font-weight: 600;">✅</span>
<span style="color: #10b981; font-weight: 600;">
${isGerman ? 'Erfolgreich verknüpft' : 'Successfully linked'}
</span>
</div>
<div style="font-size: 0.9rem; color: #8892b0;">
<div><strong>${isGerman ? 'Spieler:' : 'Player:'}</strong> ${playerData.firstname} ${playerData.lastname}</div>
<div><strong>RFID:</strong> <code style="background: rgba(255,255,255,0.1); padding: 0.2rem 0.4rem; border-radius: 0.25rem; font-family: monospace;">${playerData.rfiduid}</code></div>
</div>
</div>
<button class="btn btn-secondary" onclick="requestRFIDHelp()" style="margin-top: 1rem; font-size: 0.9rem; padding: 0.5rem 1rem;">
${isGerman ? '❓ Hilfe anfordern' : '❓ Request Help'}
</button>
`;
// Remove the onclick from the card since we don't want it to open the modal
rfidCard.removeAttribute('onclick');
rfidCard.style.cursor = 'default';
}
}
// Request RFID help
function requestRFIDHelp() {
const isGerman = currentLanguage === 'de';
const message = isGerman ?
'Hilfe-Anfrage gesendet! Ein Administrator wird sich bei dir melden, um bei der RFID-Änderung zu helfen.' :
'Help request sent! An administrator will contact you to help with the RFID change.';
alert(message);
// Here you could send a notification to admins or log the help request
console.log('RFID help requested by user:', currentUser?.email);
}
// Show loading state // Show loading state
function showTimesLoading() { function showTimesLoading() {
document.getElementById('timesLoading').style.display = 'block'; document.getElementById('timesLoading').style.display = 'block';
@@ -584,6 +787,9 @@ async function loadUserTimesSection(playerData) {
// Display times // Display times
displayUserTimes(times); displayUserTimes(times);
// Show RFID info and help button
showRFIDLinkedInfo(playerData);
// Show the times display // Show the times display
document.getElementById('timesLoading').style.display = 'none'; document.getElementById('timesLoading').style.display = 'none';
document.getElementById('timesNotLinked').style.display = 'none'; document.getElementById('timesNotLinked').style.display = 'none';

View File

@@ -820,21 +820,6 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => {
} }
try { try {
// Spieler anhand der RFID UID finden
const playerResult = await pool.query(
'SELECT id, firstname, lastname FROM players WHERE rfiduid = $1',
[rfiduid]
);
if (playerResult.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Spieler mit dieser RFID UID nicht gefunden'
});
}
const player = playerResult.rows[0];
const player_id = player.id;
// Location anhand des Namens finden (inklusive time_threshold) // Location anhand des Namens finden (inklusive time_threshold)
const locationResult = await pool.query( const locationResult = await pool.query(
@@ -881,25 +866,14 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => {
} }
// Zeit in Datenbank einfügen // Zeit mit RFIDUID in die Tabelle einfügen (player_id wird automatisch durch Trigger gesetzt)
const result = await pool.query( const result = await pool.query(
`INSERT INTO times (player_id, location_id, recorded_time, created_at) `INSERT INTO times (rfiduid, location_id, recorded_time, created_at)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
RETURNING id, created_at`, RETURNING id, player_id, created_at`,
[player_id, location_id, recorded_time, new Date()] [rfiduid, location_id, recorded_time, new Date()]
); );
// Achievement-Überprüfung nach Zeit-Eingabe
try {
await pool.query('SELECT check_immediate_achievements($1)', [player_id]);
console.log(`✅ Achievement-Check für Spieler ${player_id} ausgeführt`);
} catch (achievementError) {
console.error('Fehler bei Achievement-Check:', achievementError);
// Achievement-Fehler sollen die Zeit-Eingabe nicht blockieren
}
// WebSocket-Event senden für Live-Updates // WebSocket-Event senden für Live-Updates
const io = req.app.get('io'); const io = req.app.get('io');
if (io) { if (io) {
@@ -917,14 +891,13 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => {
// Sende WebSocket-Event an alle verbundenen Clients // Sende WebSocket-Event an alle verbundenen Clients
io.emit('newTime', { io.emit('newTime', {
id: result.rows[0].id, id: result.rows[0].id,
player_name: `${player.firstname} ${player.lastname}`, player_id: result.rows[0].player_id,
rfiduid: rfiduid,
location_name: location.name, location_name: location.name,
recorded_time: recorded_time, recorded_time: recorded_time,
rank: rank, rank: rank,
created_at: result.rows[0].created_at created_at: result.rows[0].created_at
}); });
} }
res.json({ res.json({
@@ -932,8 +905,7 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => {
message: 'Zeit erfolgreich gespeichert', message: 'Zeit erfolgreich gespeichert',
data: { data: {
id: result.rows[0].id, id: result.rows[0].id,
player_id: player_id, player_id: result.rows[0].player_id,
player_name: `${player.firstname} ${player.lastname}`,
rfiduid: rfiduid, rfiduid: rfiduid,
location_id: location_id, location_id: location_id,
location_name: location.name, location_name: location.name,
@@ -1028,6 +1000,130 @@ router.post('/v1/private/users/find', requireApiKey, async (req, res) => {
// RFID LINKING & USER MANAGEMENT ENDPOINTS (No API Key required for dashboard) // RFID LINKING & USER MANAGEMENT ENDPOINTS (No API Key required for dashboard)
// ============================================================================ // ============================================================================
// Import blacklist module
const { checkNameAgainstBlacklist, addToBlacklist, removeFromBlacklist, getBlacklist } = require('../config/blacklist-db');
const { checkNameWithLLM, checkNameWithContext, testLLMConnection } = require('../config/llm-blacklist');
// 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;
// Validierung
if (!rfiduid || !firstname || !lastname || !birthdate) {
return res.status(400).json({
success: false,
message: 'RFID UID, Vorname, Nachname und Geburtsdatum sind erforderlich'
});
}
try {
// LLM-basierte Blacklist-Prüfung
const llmCheck = await checkNameWithLLM(firstname, lastname);
if (llmCheck.isBlocked) {
return res.status(400).json({
success: false,
message: `Name nicht zulässig: ${llmCheck.reason}`,
details: llmCheck
});
}
// Fallback: Traditionelle Blacklist-Prüfung (optional)
const blacklistCheck = await checkNameAgainstBlacklist(firstname, lastname);
if (blacklistCheck.isBlocked) {
return res.status(400).json({
success: false,
message: `Name nicht zulässig: ${blacklistCheck.reason}`,
details: {
reason: blacklistCheck.reason,
category: blacklistCheck.category,
matchedTerm: blacklistCheck.matchedTerm
}
});
}
// Prüfen ob RFID UID bereits existiert
const existingRfid = await pool.query(
'SELECT id, firstname, lastname FROM players WHERE rfiduid = $1',
[rfiduid]
);
if (existingRfid.rows.length > 0) {
return res.status(409).json({
success: false,
message: 'RFID UID existiert bereits',
details: {
existingPlayer: {
firstname: existingRfid.rows[0].firstname,
lastname: existingRfid.rows[0].lastname
}
}
});
}
// Prüfen ob Supabase User bereits verknüpft ist (falls angegeben)
if (supabase_user_id) {
const existingUser = await pool.query(
'SELECT id FROM players WHERE supabase_user_id = $1',
[supabase_user_id]
);
if (existingUser.rows.length > 0) {
return res.status(409).json({
success: false,
message: 'Dieser Benutzer ist bereits mit einem Spieler verknüpft'
});
}
}
// Geburtsdatum validieren
const birthDate = new Date(birthdate);
if (isNaN(birthDate.getTime())) {
return res.status(400).json({
success: false,
message: 'Ungültiges Geburtsdatum'
});
}
// Alter berechnen
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
// 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]
);
const newPlayer = result.rows[0];
res.status(201).json({
success: true,
message: 'Spieler erfolgreich mit RFID erstellt',
data: {
id: newPlayer.id,
rfiduid: newPlayer.rfiduid,
firstname: newPlayer.firstname,
lastname: newPlayer.lastname,
birthdate: newPlayer.birthdate,
age: age,
created_at: newPlayer.created_at
}
});
} catch (error) {
console.error('Fehler beim Erstellen des Spielers mit RFID:', error);
res.status(500).json({
success: false,
message: 'Interner Serverfehler beim Erstellen des Spielers'
});
}
});
// Create new player with optional Supabase user linking (no auth required for dashboard) // Create new player with optional Supabase user linking (no auth required for dashboard)
router.post('/v1/public/players', async (req, res) => { router.post('/v1/public/players', async (req, res) => {
@@ -1148,10 +1244,25 @@ router.post('/v1/public/link-player', async (req, res) => {
[supabase_user_id, player_id] [supabase_user_id, player_id]
); );
const player = result.rows[0];
// Alle anonymen Zeiten (mit rfiduid aber ohne player_id) mit diesem Spieler verknüpfen
if (player.rfiduid) {
const updateTimesResult = await pool.query(
'UPDATE times SET player_id = $1 WHERE rfiduid = $2 AND player_id IS NULL',
[player_id, player.rfiduid]
);
console.log(`${updateTimesResult.rowCount} anonyme Zeiten mit Spieler ${player_id} verknüpft`);
}
res.json({ res.json({
success: true, success: true,
message: 'Spieler erfolgreich verknüpft', message: 'Spieler erfolgreich verknüpft',
data: result.rows[0] data: {
...player,
linked_times_count: player.rfiduid ? (await pool.query('SELECT COUNT(*) FROM times WHERE rfiduid = $1 AND player_id = $2', [player.rfiduid, player_id])).rows[0].count : 0
}
}); });
} catch (error) { } catch (error) {
@@ -1407,6 +1518,236 @@ router.get('/v1/web/check-session', (req, res) => {
// ADMIN DASHBOARD ROUTES (/api/v1/admin/) // ADMIN DASHBOARD ROUTES (/api/v1/admin/)
// ============================================================================ // ============================================================================
// Blacklist-Verwaltung
router.get('/v1/admin/blacklist', requireAdminAuth, async (req, res) => {
try {
const blacklist = await getBlacklist();
res.json({
success: true,
data: blacklist
});
} catch (error) {
console.error('Error loading blacklist:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Laden der Blacklist'
});
}
});
// Blacklist-Eintrag hinzufügen
router.post('/v1/admin/blacklist', requireAdminAuth, async (req, res) => {
const { term, category } = req.body;
if (!term || !category) {
return res.status(400).json({
success: false,
message: 'Begriff und Kategorie sind erforderlich'
});
}
try {
// Get admin user info for created_by field
const adminUser = req.session.username || 'admin';
await addToBlacklist(term, category, adminUser);
res.json({
success: true,
message: 'Begriff erfolgreich zur Blacklist hinzugefügt'
});
} catch (error) {
console.error('Error adding to blacklist:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Hinzufügen zur Blacklist'
});
}
});
// Blacklist-Eintrag entfernen
router.delete('/v1/admin/blacklist', requireAdminAuth, async (req, res) => {
const { term, category } = req.body;
if (!term || !category) {
return res.status(400).json({
success: false,
message: 'Begriff und Kategorie sind erforderlich'
});
}
try {
await removeFromBlacklist(term, category);
res.json({
success: true,
message: 'Begriff erfolgreich aus der Blacklist entfernt'
});
} catch (error) {
console.error('Error removing from blacklist:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Entfernen aus der Blacklist'
});
}
});
// Name gegen Blacklist testen
router.post('/v1/admin/blacklist/test', requireAdminAuth, async (req, res) => {
const { firstname, lastname } = req.body;
if (!firstname || !lastname) {
return res.status(400).json({
success: false,
message: 'Vorname und Nachname sind erforderlich'
});
}
try {
// LLM-Prüfung
const llmResult = await checkNameWithLLM(firstname, lastname);
// Traditionelle Blacklist-Prüfung
const blacklistResult = await checkNameAgainstBlacklist(firstname, lastname);
res.json({
success: true,
data: {
llm: llmResult,
blacklist: blacklistResult,
combined: {
isBlocked: llmResult.isBlocked || blacklistResult.isBlocked,
reason: llmResult.isBlocked ? llmResult.reason : blacklistResult.reason,
source: llmResult.isBlocked ? 'llm' : (blacklistResult.isBlocked ? 'blacklist' : 'none')
}
}
});
} catch (error) {
console.error('Error testing name against blacklist:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Testen des Namens'
});
}
});
// Blacklist-Statistiken
router.get('/v1/admin/blacklist/stats', requireAdminAuth, async (req, res) => {
try {
const result = await pool.query(`
SELECT
category,
COUNT(*) as count,
MAX(created_at) as last_added
FROM blacklist_terms
GROUP BY category
ORDER BY category
`);
const totalResult = await pool.query('SELECT COUNT(*) as total FROM blacklist_terms');
res.json({
success: true,
data: {
categories: result.rows,
total: parseInt(totalResult.rows[0].total)
}
});
} catch (error) {
console.error('Error loading blacklist stats:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Laden der Blacklist-Statistiken'
});
}
});
// LLM-Status prüfen
router.get('/v1/admin/llm/status', requireAdminAuth, async (req, res) => {
try {
const status = await testLLMConnection();
res.json({
success: true,
data: status
});
} catch (error) {
console.error('Error checking LLM status:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Prüfen des LLM-Status'
});
}
});
// LLM-Test mit Kontext
router.post('/v1/admin/llm/test', requireAdminAuth, async (req, res) => {
const { firstname, lastname, context } = req.body;
if (!firstname || !lastname) {
return res.status(400).json({
success: false,
message: 'Vorname und Nachname sind erforderlich'
});
}
try {
const result = await checkNameWithContext(firstname, lastname, context);
res.json({
success: true,
data: result
});
} catch (error) {
console.error('Error testing name with LLM:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Testen mit LLM'
});
}
});
// Levenshtein-Test (für Entwicklung/Debugging)
router.post('/v1/admin/blacklist/levenshtein-test', requireAdminAuth, async (req, res) => {
const { firstname, lastname, threshold } = req.body;
if (!firstname || !lastname) {
return res.status(400).json({
success: false,
message: 'Vorname und Nachname sind erforderlich'
});
}
try {
const { checkWithLevenshtein, THRESHOLDS } = require('../config/levenshtein');
const blacklist = await getBlacklist();
const testThreshold = threshold || 0.3;
const results = {};
// Teste jede Kategorie
for (const [category, terms] of Object.entries(blacklist)) {
const levenshteinResult = checkWithLevenshtein(firstname, lastname, terms, testThreshold);
results[category] = {
hasSimilarTerms: levenshteinResult.hasSimilarTerms,
similarTerms: levenshteinResult.similarTerms,
threshold: testThreshold,
categoryThreshold: THRESHOLDS[category] || 0.3
};
}
res.json({
success: true,
data: {
input: { firstname, lastname },
threshold: testThreshold,
results: results
}
});
} catch (error) {
console.error('Error testing Levenshtein:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Testen der Levenshtein-Distanz'
});
}
});
// Admin Statistiken // Admin Statistiken
router.get('/v1/admin/stats', requireAdminAuth, async (req, res) => { router.get('/v1/admin/stats', requireAdminAuth, async (req, res) => {
try { try {