156 lines
5.2 KiB
JavaScript
156 lines
5.2 KiB
JavaScript
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}`);
|
|
});
|