303 lines
10 KiB
JavaScript
303 lines
10 KiB
JavaScript
import { promises as fs } from 'fs'
|
|
import path from 'path'
|
|
import { randomUUID } from 'crypto'
|
|
import { encrypt, decrypt, encryptObject, decryptObject } from './encryption.js'
|
|
|
|
// Handle both dev and production paths
|
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
// filename is always a hardcoded constant (e.g., 'members.json'), never user input
|
|
const getDataPath = (filename) => {
|
|
const cwd = process.cwd()
|
|
|
|
// In production (.output/server), working dir is .output
|
|
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)
|
|
}
|
|
|
|
// In development, working dir is project root
|
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
|
return path.join(cwd, 'server/data', filename)
|
|
}
|
|
|
|
const MEMBERS_FILE = getDataPath('members.json')
|
|
|
|
// Get encryption key from environment or config
|
|
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
|
|
}
|
|
|
|
// Check if data is encrypted by checking format indicators
|
|
function isEncrypted(data) {
|
|
if (!data || typeof data !== 'string') {
|
|
return false
|
|
}
|
|
|
|
const trimmed = data.trim()
|
|
|
|
// Empty or whitespace only
|
|
if (!trimmed) {
|
|
return false
|
|
}
|
|
|
|
// Check for v2 prefix (v2:base64...)
|
|
if (trimmed.startsWith('v2:')) {
|
|
return true
|
|
}
|
|
|
|
// Try to parse as JSON - if successful, it's likely unencrypted
|
|
try {
|
|
const parsed = JSON.parse(trimmed)
|
|
// If it's an array (members list) or object with member-like structure, it's unencrypted
|
|
if (Array.isArray(parsed)) {
|
|
return false // Unencrypted array
|
|
}
|
|
// If it's an object but not encrypted format, it's unencrypted
|
|
if (typeof parsed === 'object' && parsed !== null && !parsed.encryptedData) {
|
|
return false
|
|
}
|
|
return false
|
|
} catch (_e) {
|
|
// JSON parsing failed - check if it looks like base64 (encrypted)
|
|
// Base64 strings typically don't contain spaces and have specific character set
|
|
const base64Pattern = /^[A-Za-z0-9+/=]+$/
|
|
if (base64Pattern.test(trimmed) && trimmed.length > 50) {
|
|
// Looks like base64 and long enough to be encrypted data
|
|
return true
|
|
}
|
|
// If it's not valid JSON and doesn't look like base64, assume unencrypted but malformed
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Read manual members from file (with encryption support and migration)
|
|
export async function readMembers() {
|
|
try {
|
|
const data = await fs.readFile(MEMBERS_FILE, 'utf-8')
|
|
|
|
// Check if file is empty or whitespace only
|
|
if (!data || !data.trim()) {
|
|
console.warn('Mitgliederdaten-Datei ist leer')
|
|
return []
|
|
}
|
|
|
|
// Check if data is encrypted or plain JSON
|
|
const encrypted = isEncrypted(data)
|
|
|
|
if (encrypted) {
|
|
// Versuche mit verschiedenen Schlüsseln zu entschlüsseln (für Migration)
|
|
const possibleKeys = getPossibleEncryptionKeys()
|
|
const encryptionKey = getEncryptionKey()
|
|
|
|
// Warn if using default encryption key in production
|
|
if (process.env.NODE_ENV === 'production' &&
|
|
encryptionKey === 'local_development_encryption_key_change_in_production') {
|
|
console.error('WARNUNG: Produktionsumgebung verwendet Standard-Verschlüsselungsschlüssel!')
|
|
}
|
|
|
|
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(`⚠️ Mitgliederdaten wurden mit altem Schlüssel entschlüsselt. Automatische Neuverschlüsselung...`)
|
|
try {
|
|
await writeMembers(decrypted)
|
|
console.log('✅ Mitgliederdaten 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 Mitgliederdaten:')
|
|
console.error(' Versuchte Schlüssel:', possibleKeys.length)
|
|
console.error(' Letzter Fehler:', lastError?.message || 'Unbekannter Fehler')
|
|
console.error(' Daten-Länge:', data.length)
|
|
console.error(' Daten-Präfix (erste 50 Zeichen):', data.substring(0, 50))
|
|
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 (migration scenario)
|
|
try {
|
|
const plainData = JSON.parse(data.trim())
|
|
console.warn('Entschlüsselung fehlgeschlagen, versuche als unverschlüsseltes Format zu lesen')
|
|
// Don't auto-migrate if decryption failed - might be wrong key
|
|
return plainData
|
|
} catch (parseError) {
|
|
console.error('Konnte Mitgliederdaten weder entschlüsseln noch als JSON lesen')
|
|
console.error('JSON-Parse-Fehler:', parseError.message)
|
|
// Return empty array to prevent complete failure
|
|
return []
|
|
}
|
|
} else {
|
|
// Plain JSON - migrate to encrypted format
|
|
const members = JSON.parse(data.trim())
|
|
console.log('Migriere unverschlüsselte Mitgliederdaten zu verschlüsselter Speicherung...')
|
|
|
|
// Write back encrypted
|
|
await writeMembers(members)
|
|
|
|
return members
|
|
}
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
return []
|
|
}
|
|
console.error('Fehler beim Lesen der Mitgliederdaten:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Write manual members to file (always encrypted)
|
|
export async function writeMembers(members) {
|
|
try {
|
|
const encryptionKey = getEncryptionKey()
|
|
const encryptedData = encryptObject(members, encryptionKey)
|
|
await fs.writeFile(MEMBERS_FILE, encryptedData, 'utf-8')
|
|
return true
|
|
} catch (error) {
|
|
console.error('Fehler beim Schreiben der Mitgliederdaten:', error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Get member by ID
|
|
export async function getMemberById(id) {
|
|
const members = await readMembers()
|
|
return members.find(m => m.id === id)
|
|
}
|
|
|
|
// Normalize date string for comparison (handles different date formats)
|
|
export function normalizeDate(dateString) {
|
|
if (!dateString) return ''
|
|
// Try to parse and normalize to ISO format (YYYY-MM-DD)
|
|
try {
|
|
const date = new Date(dateString)
|
|
if (isNaN(date.getTime())) return dateString.trim()
|
|
return date.toISOString().split('T')[0]
|
|
} catch (_e) {
|
|
return dateString.trim()
|
|
}
|
|
}
|
|
|
|
// Check for duplicate member based on firstName, lastName, and geburtsdatum
|
|
function findDuplicateMember(members, firstName, lastName, geburtsdatum) {
|
|
const normalizedFirstName = (firstName || '').trim().toLowerCase()
|
|
const normalizedLastName = (lastName || '').trim().toLowerCase()
|
|
const normalizedDate = normalizeDate(geburtsdatum)
|
|
|
|
return members.find(m => {
|
|
const mFirstName = (m.firstName || '').trim().toLowerCase()
|
|
const mLastName = (m.lastName || '').trim().toLowerCase()
|
|
const mDate = normalizeDate(m.geburtsdatum)
|
|
|
|
return mFirstName === normalizedFirstName &&
|
|
mLastName === normalizedLastName &&
|
|
mDate === normalizedDate &&
|
|
mDate !== '' // Only match if date is provided for both
|
|
})
|
|
}
|
|
|
|
// Add or update manual member
|
|
export async function saveMember(memberData) {
|
|
const members = await readMembers()
|
|
|
|
if (memberData.id) {
|
|
// Update existing manual member if present.
|
|
// If the ID belongs to a login-only member shown in the merged list,
|
|
// create a manual overlay with the same ID so editable fields can be stored.
|
|
const index = members.findIndex(m => m.id === memberData.id)
|
|
const duplicate = findDuplicateMember(
|
|
members.filter(m => m.id !== memberData.id),
|
|
memberData.firstName,
|
|
memberData.lastName,
|
|
memberData.geburtsdatum
|
|
)
|
|
|
|
if (duplicate) {
|
|
throw new Error('Ein Mitglied mit diesem Namen und Geburtsdatum existiert bereits.')
|
|
}
|
|
|
|
if (index !== -1) {
|
|
members[index] = { ...members[index], ...memberData }
|
|
} else {
|
|
members.push({
|
|
...memberData,
|
|
active: typeof memberData.active === 'boolean' ? memberData.active : true
|
|
})
|
|
}
|
|
} else {
|
|
// Add new - check for duplicate first
|
|
if (memberData.firstName && memberData.lastName && memberData.geburtsdatum) {
|
|
const duplicate = findDuplicateMember(
|
|
members,
|
|
memberData.firstName,
|
|
memberData.lastName,
|
|
memberData.geburtsdatum
|
|
)
|
|
|
|
if (duplicate) {
|
|
throw new Error('Ein Mitglied mit diesem Namen und Geburtsdatum existiert bereits.')
|
|
}
|
|
}
|
|
|
|
// Add new - use UUID for guaranteed uniqueness
|
|
const newMember = {
|
|
...memberData,
|
|
id: randomUUID() // Cryptographically secure unique ID
|
|
}
|
|
members.push(newMember)
|
|
}
|
|
|
|
await writeMembers(members)
|
|
return true
|
|
}
|
|
|
|
// Delete manual member
|
|
export async function deleteMember(id) {
|
|
const members = await readMembers()
|
|
const filtered = members.filter(m => m.id !== id)
|
|
await writeMembers(filtered)
|
|
return true
|
|
}
|
|
|