This commit is contained in:
2026-08-18 06:56:00 +02:00
commit c28c98852b
16 changed files with 3552 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
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}`);
});