import express from 'express'; import fs from 'node:fs/promises'; import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const port = Number(process.env.PORT || 3000); const dataFile = process.env.DATA_FILE || '/app/data/finance.json'; const dataDir = path.dirname(dataFile); const backupFile = `${dataFile}.bak`; const emptyData = () => ({ version: 2, accounts: [], incomes: [], expenses: [], transfers: [], }); function isFiniteNumber(value) { return typeof value === 'number' && Number.isFinite(value); } function normalizeData(input) { if (!input || typeof input !== 'object') return input; return { version: 2, accounts: Array.isArray(input.accounts) ? input.accounts.map(({ id, name }) => ({ id, name })) : input.accounts, incomes: input.incomes, expenses: input.expenses, transfers: input.transfers, }; } function validateData(input) { if (!input || typeof input !== 'object') throw new Error('Ungültiges JSON.'); for (const key of ['accounts', 'incomes', 'expenses', 'transfers']) { if (!Array.isArray(input[key])) throw new Error(`Feld '${key}' muss ein Array sein.`); } const accountIds = new Set(); for (const account of input.accounts) { if (!account.id || typeof account.id !== 'string') throw new Error('Konto ohne gültige ID.'); if (!account.name || typeof account.name !== 'string') throw new Error('Konto ohne Namen.'); if (accountIds.has(account.id)) throw new Error('Doppelte Konto-ID.'); accountIds.add(account.id); } for (const collectionName of ['incomes', 'expenses']) { for (const item of input[collectionName]) { if (!item.id || typeof item.id !== 'string') throw new Error('Eintrag ohne gültige ID.'); if (!item.name || typeof item.name !== 'string') throw new Error('Eintrag ohne Namen.'); if (!isFiniteNumber(item.amount) || item.amount < 0) throw new Error(`Ungültiger Betrag bei '${item.name}'.`); if (!accountIds.has(item.accountId)) throw new Error(`Unbekanntes Konto bei '${item.name}'.`); } } for (const transfer of input.transfers) { if (!transfer.id || typeof transfer.id !== 'string') throw new Error('Transfer ohne gültige ID.'); if (!transfer.name || typeof transfer.name !== 'string') throw new Error('Transfer ohne Namen.'); if (!isFiniteNumber(transfer.amount) || transfer.amount < 0) throw new Error(`Ungültiger Transferbetrag bei '${transfer.name}'.`); if (!accountIds.has(transfer.fromAccountId) || !accountIds.has(transfer.toAccountId)) { throw new Error(`Unbekanntes Konto beim Transfer '${transfer.name}'.`); } if (transfer.fromAccountId === transfer.toAccountId) throw new Error('Quell- und Zielkonto müssen verschieden sein.'); } } async function ensureDataFile() { await fs.mkdir(dataDir, { recursive: true }); try { await fs.access(dataFile); } catch { await atomicWrite(emptyData(), false); } } async function readData() { await ensureDataFile(); const raw = await fs.readFile(dataFile, 'utf8'); const parsed = JSON.parse(raw); const data = normalizeData(parsed); validateData(data); // Migration von v1: alte account.balance-Werte werden bewusst entfernt. const hadLegacyBalance = Array.isArray(parsed.accounts) && parsed.accounts.some(a => Object.hasOwn(a, 'balance')); if (parsed.version !== 2 || hadLegacyBalance) { await atomicWrite(data, true); } return data; } async function atomicWrite(input, makeBackup = true) { const data = normalizeData(input); validateData(data); await fs.mkdir(dataDir, { recursive: true }); const tmp = `${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`; const payload = JSON.stringify(data, null, 2) + '\n'; if (makeBackup) { try { await fs.copyFile(dataFile, backupFile); } catch (error) { if (error.code !== 'ENOENT') throw error; } } await fs.writeFile(tmp, payload, { encoding: 'utf8', mode: 0o600 }); await fs.rename(tmp, dataFile); } app.disable('x-powered-by'); app.use(express.json({ limit: '1mb' })); app.get('/api/health', (_req, res) => res.json({ ok: true })); app.get('/api/data', async (_req, res, next) => { try { res.json(await readData()); } catch (error) { next(error); } }); app.put('/api/data', async (req, res, next) => { try { await atomicWrite(req.body); res.json(await readData()); } catch (error) { next(error); } }); app.get('/api/export', async (_req, res, next) => { try { const data = await readData(); res.setHeader('Content-Disposition', 'attachment; filename="fixfin-backup.json"'); res.type('application/json').send(JSON.stringify(data, null, 2) + '\n'); } catch (error) { next(error); } }); const dist = path.resolve(__dirname, '../dist'); app.use(express.static(dist)); app.use((_req, res) => res.sendFile(path.join(dist, 'index.html'))); app.use((error, _req, res, _next) => { console.error(error); res.status(400).json({ error: error.message || 'Unbekannter Fehler' }); }); await ensureDataFile(); app.listen(port, '0.0.0.0', () => { console.log(`FixFin läuft auf Port ${port}`); console.log(`Datendatei: ${dataFile}`); });