import fs from 'fs/promises' import path from 'path' import { readMembers } from './members.js' import { readUsers } from './auth.js' import { encryptObject, decryptObject } from './encryption.js' import crypto from 'crypto' // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // filename is always a hardcoded constant (e.g., 'newsletter-subscribers.json'), never user input const getDataPath = (filename) => { const cwd = process.cwd() if (cwd.endsWith('.output')) { // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal return path.join(cwd, '../server/data', filename) } // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal return path.join(cwd, 'server/data', filename) } const NEWSLETTER_SUBSCRIBERS_FILE = getDataPath('newsletter-subscribers.json') function getEncryptionKey() { return process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production' } // Liste möglicher alter Verschlüsselungsschlüssel (für Migration) function getPossibleEncryptionKeys() { const currentKey = getEncryptionKey() const oldKeys = [ 'default-key-change-in-production', 'local_development_encryption_key_change_in_production' ] // Aktueller Schlüssel zuerst, dann alte Schlüssel const keys = [currentKey] for (const oldKey of oldKeys) { if (oldKey !== currentKey) { keys.push(oldKey) } } // Optional: Alter Schlüssel aus Environment-Variable if (process.env.OLD_ENCRYPTION_KEY && process.env.OLD_ENCRYPTION_KEY !== currentKey) { keys.push(process.env.OLD_ENCRYPTION_KEY) } return keys } // Prüft ob Daten verschlüsselt sind function isEncrypted(data) { try { const parsed = JSON.parse(data.trim()) if (Array.isArray(parsed)) { return false } if (typeof parsed === 'object' && parsed !== null && !parsed.encryptedData) { return false } return false } catch (_e) { return true } } export async function readSubscribers() { try { const data = await fs.readFile(NEWSLETTER_SUBSCRIBERS_FILE, 'utf-8') const encrypted = isEncrypted(data) if (encrypted) { // Versuche mit verschiedenen Schlüsseln zu entschlüsseln (für Migration) const possibleKeys = getPossibleEncryptionKeys() const encryptionKey = getEncryptionKey() let lastError = null for (let i = 0; i < possibleKeys.length; i++) { const key = possibleKeys[i] try { const decrypted = decryptObject(data, key) // Wenn mit altem Schlüssel entschlüsselt wurde, warnen und neu verschlüsseln if (i > 0 && key !== encryptionKey) { console.warn(`⚠️ Newsletter-Abonnenten wurden mit altem Schlüssel entschlüsselt. Automatische Neuverschlüsselung...`) try { await writeSubscribers(decrypted) console.log('✅ Newsletter-Abonnenten erfolgreich mit neuem Schlüssel neu verschlüsselt') } catch (writeError) { console.error('❌ Fehler beim Neuverschlüsseln:', writeError.message) console.error(' Bitte führen Sie manuell aus: node scripts/re-encrypt-data.js') } } return decrypted } catch (decryptError) { lastError = decryptError // Versuche nächsten Schlüssel continue } } // Alle Schlüssel fehlgeschlagen console.error('Fehler beim Entschlüsseln der Newsletter-Abonnenten:') console.error(' Versuchte Schlüssel:', possibleKeys.length) console.error(' Letzter Fehler:', lastError?.message || 'Unbekannter Fehler') console.error('') console.error('💡 Lösung: Führen Sie das Re-Encrypt-Skript aus:') console.error(' node scripts/re-encrypt-data.js --old-key=""') console.error(' Oder setzen Sie OLD_ENCRYPTION_KEY als Environment-Variable') // Fallback: try to read as plain JSON try { const plainData = JSON.parse(data) console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen') return plainData } catch (_parseError) { console.error('Konnte Newsletter-Abonnenten weder entschlüsseln noch als JSON lesen') return [] } } else { // Plain JSON - migriere zu verschlüsselter Speicherung const subscribers = JSON.parse(data) console.log('Migriere unverschlüsselte Newsletter-Abonnenten zu verschlüsselter Speicherung...') await writeSubscribers(subscribers) return subscribers } } catch (error) { if (error.code === 'ENOENT') { return [] } throw error } } export async function writeSubscribers(subscribers) { try { const encryptionKey = getEncryptionKey() const encryptedData = encryptObject(subscribers, encryptionKey) await fs.writeFile(NEWSLETTER_SUBSCRIBERS_FILE, encryptedData, 'utf-8') return true } catch (error) { console.error('Fehler beim Schreiben der Newsletter-Abonnenten:', error) return false } } // Berechnet Alter aus Geburtsdatum function calculateAge(geburtsdatum) { if (!geburtsdatum) return null try { const birthDate = new Date(geburtsdatum) 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-- } return age } catch { return null } } // Filtert den Admin-User aus Empfängerliste heraus function filterAdminUser(recipients) { return recipients.filter(r => { const email = (r.email || '').toLowerCase().trim() return email !== 'admin@harheimertc.de' }) } // Filtert Mitglieder nach Zielgruppe export async function getRecipientsByGroup(targetGroup) { const members = await readMembers() const users = await readUsers() let recipients = [] switch (targetGroup) { case 'alle': // Alle Mitglieder mit E-Mail recipients = members .filter(m => m.email && m.email.trim() !== '') .map(m => ({ email: m.email, name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || '' })) // Auch alle aktiven Benutzer hinzufügen users .filter(u => u.active && u.email) .forEach(u => { if (!recipients.find(r => r.email.toLowerCase() === u.email.toLowerCase())) { recipients.push({ email: u.email, name: u.name || '' }) } }) break case 'erwachsene': // Mitglieder über 18 Jahre recipients = members .filter(m => { if (!m.email || !m.email.trim()) return false const age = calculateAge(m.geburtsdatum) return age !== null && age >= 18 }) .map(m => ({ email: m.email.trim(), name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || '' })) // Auch aktive Benutzer hinzufügen (als Erwachsene behandelt, wenn kein Geburtsdatum) users .filter(u => u.active && u.email && u.email.trim()) .forEach(u => { // Prüfe ob bereits vorhanden if (!recipients.find(r => r.email.toLowerCase() === u.email.toLowerCase().trim())) { recipients.push({ email: u.email.trim(), name: u.name || '' }) } }) break case 'nachwuchs': // Mitglieder unter 18 Jahre recipients = members .filter(m => { if (!m.email || !m.email.trim()) return false const age = calculateAge(m.geburtsdatum) return age !== null && age < 18 }) .map(m => ({ email: m.email, name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || '' })) break case 'mannschaftsspieler': // Mitglieder die in einer Mannschaft spielen recipients = members .filter(m => { if (!m.email || !m.email.trim()) return false // Prüfe ob als Mannschaftsspieler markiert if (m.isMannschaftsspieler === true) { return true } // Fallback: Prüfe ob in notes etwas über Mannschaft steht (für Rückwärtskompatibilität) const notes = (m.notes || '').toLowerCase() return notes.includes('mannschaft') || notes.includes('spieler') }) .map(m => ({ email: m.email, name: `${m.firstName || ''} ${m.lastName || ''}`.trim() || m.name || '' })) break case 'vorstand': // Nur Vorstand (aus users.json) recipients = users .filter(u => { if (!u.active || !u.email) return false const roles = Array.isArray(u.roles) ? u.roles : (u.role ? [u.role] : []) return roles.includes('admin') || roles.includes('vorstand') }) .map(u => ({ email: u.email, name: u.name || '' })) break default: recipients = [] } // Admin-User herausfiltern return filterAdminUser(recipients) } // Holt Newsletter-Abonnenten (bestätigt und nicht abgemeldet) export async function getNewsletterSubscribers(internalOnly = false, groupId = null) { const subscribers = await readSubscribers() let confirmedSubscribers = subscribers.filter(s => { if (!s.confirmed || s.unsubscribedAt) { return false } // Wenn groupId angegeben ist, prüfe ob Subscriber für diese Gruppe angemeldet ist if (groupId) { // Stelle sicher, dass groupIds existiert (für Rückwärtskompatibilität) if (!s.groupIds || !Array.isArray(s.groupIds)) { return false } return s.groupIds.includes(groupId) } // Wenn keine groupId angegeben, prüfe ob Subscriber für mindestens eine Gruppe angemeldet ist // (für Rückwärtskompatibilität: wenn keine groupIds vorhanden, als abonniert behandeln) if (s.groupIds && Array.isArray(s.groupIds)) { return s.groupIds.length > 0 } // Rückwärtskompatibilität: alte Subscriber ohne groupIds werden als abonniert behandelt return true }) if (internalOnly) { // Nur interne Abonnenten (die auch Mitglieder sind) const members = await readMembers() const memberEmails = new Set( members .filter(m => m.email) .map(m => m.email.toLowerCase()) ) confirmedSubscribers = confirmedSubscribers.filter(s => memberEmails.has(s.email.toLowerCase()) ) } const result = confirmedSubscribers.map(s => ({ email: s.email, name: s.name || '' })) // Admin-User herausfiltern return filterAdminUser(result) } // Generiert Abmelde-Token für Abonnenten export async function generateUnsubscribeToken(email) { const subscribers = await readSubscribers() const subscriber = subscribers.find(s => s.email.toLowerCase() === email.toLowerCase()) if (!subscriber) { return null } if (!subscriber.unsubscribeToken) { subscriber.unsubscribeToken = crypto.randomBytes(32).toString('hex') await writeSubscribers(subscribers) } return subscriber.unsubscribeToken }