Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m1s
This commit introduces a mechanism to handle multiple possible encryption keys for data decryption across various modules, including auth.js, members.js, newsletter.js, and encryption.js. It adds functions to retrieve potential old keys for migration purposes and updates the decryption logic to attempt decryption with these keys. Additionally, it includes warnings for users when old keys are used and provides guidance for re-encrypting data. This enhancement improves data migration capabilities and ensures backward compatibility with previously encrypted data.
350 lines
11 KiB
JavaScript
350 lines
11 KiB
JavaScript
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="<alter-schlüssel>"')
|
|
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
|
|
}
|
|
|