Files
harheimertc/server/utils/members.js

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
}