Files
harheimertc/server/utils/newsletter.js
Torsten Schulz (local) 01cf0e58cb
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 1m1s
Add support for multiple encryption keys in data handling
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.
2026-01-09 09:05:05 +01:00

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
}