diff --git a/config/blacklist-db.js b/config/blacklist-db.js new file mode 100644 index 0000000..294c2c3 --- /dev/null +++ b/config/blacklist-db.js @@ -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 +}; diff --git a/config/blacklist.js b/config/blacklist.js new file mode 100644 index 0000000..3e3de56 --- /dev/null +++ b/config/blacklist.js @@ -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 +}; diff --git a/config/levenshtein.js b/config/levenshtein.js new file mode 100644 index 0000000..ab0e318 --- /dev/null +++ b/config/levenshtein.js @@ -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 +}; diff --git a/config/llm-blacklist.js b/config/llm-blacklist.js new file mode 100644 index 0000000..80e6d87 --- /dev/null +++ b/config/llm-blacklist.js @@ -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 +}; diff --git a/package-lock.json b/package-lock.json index c4f9bd3..d4474a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "propriatary", "dependencies": { "@hisma/server-puppeteer": "^0.6.5", + "axios": "^1.11.0", "bcrypt": "^5.1.1", "discord-oauth2": "^2.12.1", "dotenv": "^16.3.1", @@ -745,6 +746,23 @@ "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": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", @@ -1083,6 +1101,18 @@ "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": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", @@ -1219,6 +1249,15 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -1479,6 +1518,21 @@ "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": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1767,6 +1821,42 @@ "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": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2016,6 +2106,21 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -4964,6 +5069,21 @@ "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": { "version": "1.6.7", "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", "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": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", @@ -5279,6 +5407,11 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -5450,6 +5583,17 @@ "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": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5647,6 +5791,23 @@ "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": { "version": "0.2.0", "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", "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", diff --git a/package.json b/package.json index 55dbc80..ec0fcc4 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@hisma/server-puppeteer": "^0.6.5", + "axios": "^1.11.0", "bcrypt": "^5.1.1", "discord-oauth2": "^2.12.1", "dotenv": "^16.3.1", diff --git a/public/admin-dashboard.html b/public/admin-dashboard.html index 7f53f34..d17bf0b 100644 --- a/public/admin-dashboard.html +++ b/public/admin-dashboard.html @@ -99,6 +99,20 @@ + +
+

🚫 Blacklist-Verwaltung

+

Verwalte verbotene Namen und Begriffe

+ +
+ + +
+

🧠 KI-Moderator

+

Intelligente Namensprüfung mit Ollama LLM

+ +
+

⏱️ Läufe-Verwaltung

@@ -168,6 +182,47 @@
+ + + + + + diff --git a/public/css/admin-dashboard.css b/public/css/admin-dashboard.css index 5931dfb..788a37f 100644 --- a/public/css/admin-dashboard.css +++ b/public/css/admin-dashboard.css @@ -204,11 +204,13 @@ body { .modal-content { background-color: rgba(26, 26, 46, 0.95); - margin: 10% auto; + margin: 5% auto; padding: 20px; border-radius: 10px; width: 90%; max-width: 500px; + max-height: 85vh; + overflow-y: auto; position: relative; border: 1px solid rgba(255, 255, 255, 0.1); } @@ -228,6 +230,37 @@ body { 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 { margin-bottom: 15px; } diff --git a/public/css/dashboard.css b/public/css/dashboard.css index b16a516..15d8adf 100644 --- a/public/css/dashboard.css +++ b/public/css/dashboard.css @@ -239,6 +239,8 @@ body { border-radius: 1rem; width: 90%; max-width: 500px; + max-height: 80vh; + overflow-y: auto; backdrop-filter: blur(20px); } @@ -260,7 +262,36 @@ body { font-size: 2rem; font-weight: bold; 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 { diff --git a/public/css/leaderboard.css b/public/css/leaderboard.css index 76f8ea4..0f38426 100644 --- a/public/css/leaderboard.css +++ b/public/css/leaderboard.css @@ -85,7 +85,7 @@ body { .dashboard-grid { display: grid; - grid-template-columns: 1fr 2fr; + grid-template-columns: 1fr 2fr 1fr; gap: 2rem; margin-bottom: 2rem; } @@ -204,7 +204,6 @@ body { border-radius: 0.75rem; padding: 0.25rem; gap: 0.25rem; - overflow-x: auto; } .time-tab { @@ -255,7 +254,7 @@ body { .refresh-btn { width: 100%; - padding: 1rem; + padding: 2rem; background: linear-gradient(135deg, #00d4ff, #0891b2); border: none; border-radius: 0.75rem; @@ -266,6 +265,7 @@ body { transition: all 0.2s ease; text-transform: uppercase; letter-spacing: 0.05em; + margin-top: 1.5rem; } .refresh-btn:hover { @@ -281,6 +281,65 @@ body { 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 { display: grid; grid-template-columns: repeat(3, 1fr); @@ -459,75 +518,6 @@ body { 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 { animation: pulse 2s infinite; @@ -650,13 +640,17 @@ body { /* Mobile First Responsive Design */ @media (max-width: 1024px) { .dashboard-grid { - grid-template-columns: 1fr; + grid-template-columns: 1fr 1fr; gap: 1.5rem; } .stats-grid { grid-template-columns: repeat(3, 1fr); } + + .admin-panel { + grid-column: 1 / -1; + } } @media (max-width: 768px) { @@ -676,8 +670,7 @@ body { .header-section { margin-bottom: 2rem; - margin-top: 5rem; - /* Account for fixed buttons */ + margin-top: 2rem; } .dashboard-grid { @@ -685,6 +678,22 @@ body { gap: 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 { padding: 1.5rem; @@ -817,44 +826,6 @@ body { 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 { top: 1rem; @@ -895,6 +866,20 @@ body { .stats-panel { 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 { padding: 1rem; @@ -947,18 +932,6 @@ body { 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 */ @@ -972,19 +945,6 @@ body { 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 { flex-direction: row; diff --git a/public/dashboard.html b/public/dashboard.html index 5c0f7f1..54b9f6f 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -296,6 +296,32 @@ + +
+

+ Neuen Spieler mit RFID erstellen: +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ `; break; - + case 'locations': modalTitle.textContent = 'Neuen Standort hinzufügen'; formFields.innerHTML = ` @@ -462,7 +462,7 @@ function showAddModal() { `; break; - + case 'adminusers': modalTitle.textContent = 'Neuen Admin-Benutzer hinzufügen'; formFields.innerHTML = ` @@ -483,7 +483,7 @@ function showAddModal() { `; break; - + case 'runs': modalTitle.textContent = 'Neuen Lauf hinzufügen'; formFields.innerHTML = ` @@ -501,12 +501,12 @@ function showAddModal() { `; break; - + default: modalTitle.textContent = 'Element hinzufügen'; formFields.innerHTML = '

Keine Felder für diesen Datentyp verfügbar.

'; } - + modal.style.display = 'block'; } @@ -521,21 +521,21 @@ function closeModal() { async function handleAddSubmit(e) { e.preventDefault(); - + const formData = new FormData(e.target); const data = Object.fromEntries(formData.entries()); - + // Prüfen ob es sich um eine Edit-Operation handelt const isEdit = data.edit_id; const editId = data.edit_id; delete data.edit_id; // edit_id aus den Daten entfernen - + try { let endpoint = ''; let successMessage = ''; let method = 'POST'; - - switch(currentDataType) { + + switch (currentDataType) { case 'players': endpoint = isEdit ? `/api/v1/admin/players/${editId}` : '/api/v1/admin/players'; successMessage = isEdit ? 'Spieler erfolgreich aktualisiert' : 'Spieler erfolgreich hinzugefügt'; @@ -560,7 +560,7 @@ async function handleAddSubmit(e) { showError('Unbekannter Datentyp'); return; } - + const response = await fetch(endpoint, { method: method, headers: { @@ -568,9 +568,9 @@ async function handleAddSubmit(e) { }, body: JSON.stringify(data) }); - + const result = await response.json(); - + if (result.success) { showSuccess(successMessage); closeModal(); @@ -578,7 +578,7 @@ async function handleAddSubmit(e) { } else { showError(result.message || `Fehler beim ${isEdit ? 'Aktualisieren' : 'Hinzufügen'}`); } - + } catch (error) { console.error('Submit failed:', error); showError(`Fehler beim ${isEdit ? 'Aktualisieren' : 'Hinzufügen'}`); @@ -589,11 +589,11 @@ async function handleAddSubmit(e) { async function editPlayer(id) { const player = currentData.find(p => p.id == id); if (!player) return; - + const modal = document.getElementById('addModal'); const modalTitle = document.getElementById('modalTitle'); const formFields = document.getElementById('formFields'); - + modalTitle.textContent = 'Spieler bearbeiten'; formFields.innerHTML = `
@@ -610,18 +610,18 @@ async function editPlayer(id) {
`; - + modal.style.display = 'block'; } async function editLocation(id) { const location = currentData.find(l => l.id == id); if (!location) return; - + const modal = document.getElementById('addModal'); const modalTitle = document.getElementById('modalTitle'); const formFields = document.getElementById('formFields'); - + modalTitle.textContent = 'Standort bearbeiten'; formFields.innerHTML = `
@@ -642,7 +642,7 @@ async function editLocation(id) {
`; - + modal.style.display = 'block'; } @@ -651,24 +651,24 @@ async function editRun(id) { // Lade die spezifischen Lauf-Daten direkt von der API const response = await fetch(`/api/admin-runs/${id}`); const result = await response.json(); - + if (!result.success) { showError('Fehler beim Laden der Lauf-Daten: ' + (result.message || 'Unbekannter Fehler')); return; } - + const run = result.data; if (!run) { showError('Lauf nicht gefunden'); return; } - + console.log('Editing run:', run); // Debug log - + const modal = document.getElementById('addModal'); const modalTitle = document.getElementById('modalTitle'); const formFields = document.getElementById('formFields'); - + modalTitle.textContent = 'Lauf bearbeiten'; formFields.innerHTML = `
@@ -685,9 +685,9 @@ async function editRun(id) {
`; - + modal.style.display = 'block'; - + } catch (error) { console.error('Error loading run data:', error); showError('Fehler beim Laden der Lauf-Daten'); @@ -699,7 +699,7 @@ async function deletePlayer(id) { try { const response = await fetch(`/api/v1/admin/players/${id}`, { method: 'DELETE' }); const result = await response.json(); - + if (result.success) { showSuccess('Spieler erfolgreich gelöscht'); loadPlayers(); @@ -718,7 +718,7 @@ async function deleteRun(id) { try { const response = await fetch(`/api/v1/admin/runs/${id}`, { method: 'DELETE' }); const result = await response.json(); - + if (result.success) { showSuccess('Lauf erfolgreich gelöscht'); loadRuns(); @@ -737,7 +737,7 @@ async function deleteLocation(id) { try { const response = await fetch(`/api/v1/admin/locations/${id}`, { method: 'DELETE' }); const result = await response.json(); - + if (result.success) { showSuccess('Standort erfolgreich gelöscht'); loadLocations(); @@ -756,7 +756,7 @@ async function deleteAdminUser(id) { try { const response = await fetch(`/api/v1/admin/adminusers/${id}`, { method: 'DELETE' }); const result = await response.json(); - + if (result.success) { showSuccess('Admin-Benutzer erfolgreich gelöscht'); loadAdminUsers(); @@ -774,12 +774,12 @@ function confirmDelete(message) { return new Promise((resolve) => { document.getElementById('confirmMessage').textContent = message; document.getElementById('confirmModal').style.display = 'block'; - + document.getElementById('confirmYes').onclick = () => { closeModal(); resolve(true); }; - + document.getElementById('confirmNo').onclick = () => { closeModal(); resolve(false); @@ -806,3 +806,493 @@ function showError(message) { messageDiv.style.display = 'none'; }, 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 = '

📋 Blacklist-Inhalte

'; + + Object.entries(blacklist).forEach(([category, terms]) => { + const categoryName = getCategoryDisplayName(category); + const categoryIcon = getCategoryIcon(category); + + html += ` +
+

+ ${categoryIcon} ${categoryName} + + ${terms.length} Einträge + +

+
+ `; + + if (terms.length === 0) { + html += ` +
+ Keine Einträge in dieser Kategorie +
+ `; + } else { + terms.forEach(term => { + html += ` + + ${term} + + + `; + }); + } + + html += ` +
+
+ `; + }); + + 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 = `
Ähnlichkeit: ${similarityPercent}% (Distanz: ${testResult.levenshteinDistance})`; + } + + resultDiv.innerHTML = ` +
+ ❌ Name ist blockiert
+ Grund: ${testResult.reason}
+ Kategorie: ${getCategoryDisplayName(testResult.category)}
+ Gefundener Begriff: "${testResult.matchedTerm}"
+ Typ: ${matchTypeText}${similarityText} +
+ `; + } else { + resultDiv.innerHTML = ` +
+ ✅ Name ist erlaubt
+ Der Name "${firstname} ${lastname}" ist nicht in der Blacklist. +
+ `; + } + + 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 = ` +

📊 Blacklist-Statistiken

+
+ `; + + stats.categories.forEach(category => { + const categoryName = getCategoryDisplayName(category.category); + const lastAdded = new Date(category.last_added).toLocaleDateString('de-DE'); + html += ` +
+
${category.count}
+
${categoryName}
+
Letzte: ${lastAdded}
+
+ `; + }); + + html += ` +
+
+ Gesamt: ${stats.total} Begriffe +
+ `; + + 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 = ` +
+ ✅ KI-Moderator verbunden
+ Modell: ${status.model}
+ URL: ${status.baseUrl} +
+ `; + } else { + statusContent.innerHTML = ` +
+ ❌ KI-Moderator nicht verfügbar
+ Modell: ${status.model}
+ URL: ${status.baseUrl}
+ Fehler: ${status.error} +
+ `; + } + } else { + statusContent.innerHTML = ` +
+ ❌ Fehler beim Laden des Status
+ ${result.message} +
+ `; + } + } catch (error) { + console.error('Error loading LLM status:', error); + document.getElementById('llmStatusContent').innerHTML = ` +
+ ❌ Fehler beim Laden des Status
+ ${error.message} +
+ `; + } +} + +// 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 = ` +
+ ❌ Name blockiert
+ Grund: ${llm.reason}
+ Konfidenz: ${Math.round(llm.confidence * 100)}%
+ LLM-Antwort: "${llm.llmResponse}"
+ Typ: ${llm.matchType} +
+ `; + } else { + llmStatus = ` +
+ ✅ Name erlaubt
+ Grund: ${llm.reason}
+ Konfidenz: ${Math.round(llm.confidence * 100)}%
+ LLM-Antwort: "${llm.llmResponse}"
+ Typ: ${llm.matchType} +
+ `; + } + + resultContent.innerHTML = llmStatus; + resultDiv.style.display = 'block'; + + // Vergleich mit Blacklist + if (blacklistResult.success) { + const blacklist = blacklistResult.data; + let comparisonStatus = ''; + + if (blacklist.combined.isBlocked) { + comparisonStatus = ` +
+ ⚠️ Name blockiert (${blacklist.combined.source})
+ Grund: ${blacklist.combined.reason} +
+ `; + } else { + comparisonStatus = ` +
+ ✅ Name erlaubt
+ Sowohl KI als auch Blacklist erlauben den Namen +
+ `; + } + + comparisonContent.innerHTML = comparisonStatus; + comparisonDiv.style.display = 'block'; + } + + } else { + resultContent.innerHTML = ` +
+ ❌ Fehler beim Testen
+ ${llmResult.message} +
+ `; + 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 = ` +
+ ${type === 'error' ? '❌' : '✅'} ${message} +
+ `; + resultDiv.style.display = 'block'; +} diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 686ca73..446e290 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -242,6 +242,53 @@ async function showRFIDSettings() { openModal('rfidModal'); // Reset scanner state 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 @@ -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) async function linkUserByRfidUid(rfidUid) { 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 = ` +

${isGerman ? '🏷️ RFID Verknüpft' : '🏷️ RFID Linked'}

+
+
+ + + ${isGerman ? 'Erfolgreich verknüpft' : 'Successfully linked'} + +
+
+
${isGerman ? 'Spieler:' : 'Player:'} ${playerData.firstname} ${playerData.lastname}
+
RFID: ${playerData.rfiduid}
+
+
+ + `; + + // 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 function showTimesLoading() { document.getElementById('timesLoading').style.display = 'block'; @@ -584,6 +787,9 @@ async function loadUserTimesSection(playerData) { // Display times displayUserTimes(times); + // Show RFID info and help button + showRFIDLinkedInfo(playerData); + // Show the times display document.getElementById('timesLoading').style.display = 'none'; document.getElementById('timesNotLinked').style.display = 'none'; diff --git a/routes/api.js b/routes/api.js index 22c1bd2..0a3a7c6 100644 --- a/routes/api.js +++ b/routes/api.js @@ -820,21 +820,6 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => { } 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) 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( - `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) - RETURNING id, created_at`, - [player_id, location_id, recorded_time, new Date()] + RETURNING id, player_id, created_at`, + [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 const io = req.app.get('io'); if (io) { @@ -917,14 +891,13 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => { // Sende WebSocket-Event an alle verbundenen Clients io.emit('newTime', { id: result.rows[0].id, - player_name: `${player.firstname} ${player.lastname}`, + player_id: result.rows[0].player_id, + rfiduid: rfiduid, location_name: location.name, recorded_time: recorded_time, rank: rank, created_at: result.rows[0].created_at }); - - } res.json({ @@ -932,8 +905,7 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => { message: 'Zeit erfolgreich gespeichert', data: { id: result.rows[0].id, - player_id: player_id, - player_name: `${player.firstname} ${player.lastname}`, + player_id: result.rows[0].player_id, rfiduid: rfiduid, location_id: location_id, 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) // ============================================================================ +// 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) 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] ); + 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({ success: true, 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) { @@ -1407,6 +1518,236 @@ router.get('/v1/web/check-session', (req, res) => { // 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 router.get('/v1/admin/stats', requireAdminAuth, async (req, res) => { try {