This commit is contained in:
2026-08-18 08:36:21 +02:00
parent c28c98852b
commit cf05c7a733
5 changed files with 153 additions and 447 deletions
+14 -148
View File
@@ -4,152 +4,18 @@ 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 __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 validIntervals=new Set(['monthly','quarterly','semiannual','yearly']);
const emptyData=()=>({version:3,settings:{includePeriodicInBalance:true},accounts:[],incomes:[],expenses:[],transfers:[]});
const isFiniteNumber=value=>typeof value==='number'&&Number.isFinite(value);
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}`);
});
function normalizeMovement(item={}) { return { ...item, interval: validIntervals.has(item.interval)?item.interval:'monthly', dueMonth: item.interval && item.interval!=='monthly' && Number.isInteger(Number(item.dueMonth)) ? Number(item.dueMonth) : null }; }
function normalizeData(input){if(!input||typeof input!=='object')return input;return {version:3,settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false},accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts,incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeMovement):input.incomes,expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeMovement):input.expenses,transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeMovement):input.transfers};}
function validateMovement(item){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(!validIntervals.has(item.interval))throw new Error(`Ungültiges Intervall bei '${item.name}'.`);if(item.dueMonth!==null&&item.dueMonth!==undefined&&(!Number.isInteger(item.dueMonth)||item.dueMonth<1||item.dueMonth>12))throw new Error(`Ungültiger Monat bei '${item.name}'.`);}
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 a of input.accounts){if(!a.id||typeof a.id!=='string')throw new Error('Konto ohne gültige ID.');if(!a.name||typeof a.name!=='string')throw new Error('Konto ohne Namen.');if(accountIds.has(a.id))throw new Error('Doppelte Konto-ID.');accountIds.add(a.id);}for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);}for(const t of input.transfers){validateMovement(t);if(!accountIds.has(t.fromAccountId)||!accountIds.has(t.toAccountId))throw new Error(`Unbekanntes Konto beim Transfer '${t.name}'.`);if(t.fromAccountId===t.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);if(parsed.version!==3||!parsed.settings||[...parsed.incomes||[],...parsed.expenses||[],...parsed.transfers||[]].some(x=>!x.interval)||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))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(e){next(e);}});app.put('/api/data',async(req,res,next)=>{try{await atomicWrite(req.body);res.json(await readData());}catch(e){next(e);}});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(e){next(e);}});
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}`);});