cve fixes

This commit is contained in:
Jordy Hoogeveen
2026-09-18 13:38:22 +02:00
parent 2420a00777
commit e9192a8aba
11 changed files with 645 additions and 30 deletions
+354
View File
@@ -0,0 +1,354 @@
/**
* Export naar één zelfdragende HTML-pagina.
*
* Bedoeld als laatste vangnet: als de app niet meer draait, de database weg is
* en er geen backup ligt, moet dit ene bestand nog steeds al je gegevens tonen.
* Daarom zit alles erin — opmaak inline, geen scripts, geen lettertypen of
* afbeeldingen van buiten. Het bestand is op een computer zonder internet, over
* tien jaar, in elke browser nog te openen.
*
* Het is bovendien tegelijk een backup. Onderaan de pagina staat de complete
* ruwe data in een `<script type="application/json">`-blok dat de browser niet
* uitvoert en niet toont. "Backup herstellen" leest dat blok, dus je kunt dit
* bestand net zo goed terugzetten als een backup-ZIP. Eén bestand dat zowel
* leesbaar is voor jou als bruikbaar voor de app.
*/
// ── Formatters ────────────────────────────────────────────────────────────────
// Bewust met twee decimalen, anders dan de `fmt` op het scherm. Dit is een
// archiefstuk: hier hoort de exacte waarde in te staan, niet de afgeronde.
const euro = (n) =>
new Intl.NumberFormat("nl-NL", { style: "currency", currency: "EUR", minimumFractionDigits: 2 })
.format(Number(n) || 0);
const procent = (n) => `${((Number(n) || 0) * 100).toFixed(2).replace(".", ",")}%`;
const datumNl = (s) => {
if (!s) return "";
const [y, m, d] = String(s).split("-");
return d ? `${d}-${m}-${y}` : String(s);
};
/**
* Tekst veilig in HTML zetten. Een post die "Fonds <A> & co" heet mag de pagina
* niet slopen, en opmerkingen zijn vrije tekst.
*/
const esc = (v) =>
String(v ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
const cel = (v, klasse = "") => `<td${klasse ? ` class="${klasse}"` : ""}>${esc(v)}</td>`;
const kop = (v, klasse = "") => `<th${klasse ? ` class="${klasse}"` : ""}>${esc(v)}</th>`;
/**
* Tabel met kopregel. `num` bevat de kolomindexen met bedragen; die worden
* rechts uitgelijnd. Een rij mag `{ klasse, cellen }` zijn om zichzelf als
* subtotaal of eindtotaal te markeren.
*/
const maakTabel = (kolommen, rijen, num = []) => `
<div class="scroll">
<table>
<thead><tr>${kolommen.map((k, i) => kop(k, num.includes(i) ? "num" : "")).join("")}</tr></thead>
<tbody>${rijen.map((r) => `<tr${r.klasse ? ` class="${r.klasse}"` : ""}>${
(r.cellen || r).map((c, i) => cel(c, num.includes(i) ? "num" : "")).join("")
}</tr>`).join("")}</tbody>
</table>
</div>`;
// ── Opmaak ────────────────────────────────────────────────────────────────────
// Licht en rustig: dit is een document om te lezen en eventueel te printen, niet
// het dashboard. Systeemlettertypen, zodat er niets van buiten geladen hoeft.
const STIJL = `
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 32px 20px 64px;
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #1e2330; background: #f6f7fb;
}
.blad { max-width: 1180px; margin: 0 auto; }
/* De voortgangstabel is Datum + één kolom per categorie + vier vaste kolommen.
Bij zes categorieën zijn dat elf kolommen, en dat past op geen enkel scherm
dat je toevallig bij de hand hebt. Liever horizontaal scrollen dan kolommen
die buiten beeld vallen: zichtbaar is belangrijker dan in één oogopslag. */
.scroll {
overflow-x: auto;
/* Schaduwtruc met vier achtergronden: de twee op 'local' schuiven mee en
dekken de schaduw af zolang je aan die kant al bent, de twee op 'scroll'
blijven staan. Resultaat is een randje dat alleen verschijnt aan de kant
waar nog inhoud zit. Puur CSS — de pagina mag geen scripts bevatten, en
zonder hint ziet niemand dat er rechts nog kolommen staan. */
background:
linear-gradient(to right, #fff 30%, rgba(255,255,255,0)) left center,
linear-gradient(to left, #fff 30%, rgba(255,255,255,0)) right center,
radial-gradient(farthest-side at 0 50%, rgba(30,35,48,0.16), rgba(30,35,48,0)) left center,
radial-gradient(farthest-side at 100% 50%, rgba(30,35,48,0.16), rgba(30,35,48,0)) right center;
background-repeat: no-repeat;
background-size: 40px 100%, 40px 100%, 14px 100%, 14px 100%;
background-attachment: local, local, scroll, scroll;
}
.scroll table { min-width: 100%; width: max-content; }
/* Datum blijft staan tijdens het scrollen; zonder die kolom weet je bij elf
kolommen niet meer naar welk moment je kijkt. */
.scroll th:first-child, .scroll td:first-child { position: sticky; left: 0; background: #fff; }
tr.subtotaal td:first-child { background: #fafbff; }
tr.totaal td:first-child { background: #f4f2ff; }
header { border-bottom: 3px solid #8b5cf6; padding-bottom: 16px; margin-bottom: 28px; }
h1 { margin: 0 0 4px; font-size: 26px; letter-spacing: -0.4px; }
.meta { margin: 0; color: #6b7280; font-size: 13px; }
h2 {
font-size: 15px; text-transform: uppercase; letter-spacing: 0.6px;
color: #6b7280; margin: 34px 0 10px; font-weight: 700;
}
section { background: #fff; border: 1px solid #e3e6ef; border-radius: 10px; padding: 4px 18px 14px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #eceef5; }
th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #6b7280; }
/* Bedragen rechts en met cijfers van gelijke breedte, zodat kolommen uitlijnen */
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
tr.subtotaal td { font-weight: 700; background: #fafbff; }
tr.totaal td { font-weight: 800; border-top: 2px solid #d9dced; background: #f4f2ff; }
.kpi { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; padding: 14px 0; }
.kpi div { border: 1px solid #e3e6ef; border-radius: 8px; padding: 12px 14px; background: #fcfcff; }
.kpi span { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #6b7280; }
.kpi strong { font-size: 19px; font-variant-numeric: tabular-nums; }
.leeg { color: #9aa1b0; font-style: italic; padding: 10px 0; }
footer { margin-top: 34px; color: #6b7280; font-size: 12px; line-height: 1.6; }
footer code { background: #eceef5; padding: 1px 5px; border-radius: 4px; }
@media print {
body { background: #fff; padding: 0; }
section { border: none; border-radius: 0; padding: 0; }
h2 { margin-top: 22px; }
tr { break-inside: avoid; }
/* Papier scrollt niet, dus op print moet alles passen. Liggend formaat: een
tabel van twaalf kolommen krijg je staand met geen enkele lettergrootte
fatsoenlijk op een A4. Verder vervallen de scrollbalk en de schaduwhint,
en gaat de nowrap van de bedragen af — die hield de kolommen op hun volle
breedte en was precies waaraan het krimpen vastliep. */
@page { size: A4 landscape; margin: 12mm; }
.scroll { overflow: visible; background: none; }
.scroll table { width: 100%; min-width: 0; table-layout: fixed; }
.scroll th:first-child, .scroll td:first-child { position: static; }
table { font-size: 9px; }
th, td { padding: 3px 4px; }
.num { white-space: normal; }
.blad { max-width: none; }
}
`;
/**
* Bouwt de complete HTML-pagina.
*
* @returns {string} het volledige bestand, klaar om weg te schrijven
*/
export function bouwHtmlExport({
cats = [],
schuldBlokken = [],
evRows = [],
evVoortgangHistory = [],
schuldHistory = [],
totEV = 0,
totSchuld = 0,
doel = 0,
initieleSchuld = 0,
gebruiker = "",
ruweData = null,
}) {
const nu = new Date();
const exportDatum = `${String(nu.getDate()).padStart(2, "0")}-${String(nu.getMonth() + 1).padStart(2, "0")}-${nu.getFullYear()}`;
// ── Samenvatting ────────────────────────────────────────────
// Zelfde opbouw als de dashboard-KPI's: het oudste voortgangsmoment is de
// startinleg, elke latere rij telt zijn stortingen erbij op.
const startEV = evRows.length > 0 ? (evRows[evRows.length - 1]?.nettoWaarde || 0) : 0;
const extraFiat = evRows.length > 1
? evRows.slice(0, -1).reduce((a, r) => a + cats.reduce((b, c) => b + (r[c.id] || 0), 0), 0)
: 0;
const totaalFiat = startEV + extraFiat;
const winst = totEV - totaalFiat;
const kpis = [
["Totaal eigen vermogen", euro(totEV)],
["Totale schuld", euro(totSchuld)],
["Netto waarde", euro(totEV - totSchuld)],
["Totaal geïnvesteerde fiat", euro(totaalFiat)],
["Winst op investering", euro(winst)],
["ROI", totaalFiat > 0 ? procent(winst / totaalFiat) : "—"],
["Doel eigen vermogen", euro(doel)],
["Voortgang naar doel", doel > 0 ? procent(Math.min(totEV / doel, 1)) : "—"],
];
// ── Eigen vermogen ──────────────────────────────────────────
// Per categorie de posten met een subtotaalregel eronder, en helemaal
// onderaan het eindtotaal. Zo is de pagina te lezen zonder te rekenen.
const evRijen = [];
for (const c of cats) {
const items = c.items || [];
for (const i of items) evRijen.push([c.naam, i.naam, euro(i.waarde)]);
// Eerste kolom leeg: het label zegt al om welke categorie het gaat, en de
// naam er nog eens naast maakt de regel alleen maar drukker.
evRijen.push({
klasse: "subtotaal",
cellen: ["", `Subtotaal ${c.naam}`, euro(items.reduce((a, i) => a + (i.waarde || 0), 0))],
});
}
if (evRijen.length) evRijen.push({ klasse: "totaal", cellen: ["", "Totaal eigen vermogen", euro(totEV)] });
// ── Schulden ────────────────────────────────────────────────
const schuldRijen = [];
for (const b of schuldBlokken) {
const items = b.items || [];
for (const i of items) schuldRijen.push([b.naam, i.naam, euro(i.waarde)]);
schuldRijen.push({
klasse: "subtotaal",
cellen: ["", `Subtotaal ${b.naam}`, euro(items.reduce((a, i) => a + (i.waarde || 0), 0))],
});
}
if (schuldRijen.length) schuldRijen.push({ klasse: "totaal", cellen: ["", "Totale schuld", euro(totSchuld)] });
// De initiële schuld is het beginpunt waartegen het afbetalingsoverzicht in de
// app rekent. Hij staat los van de blokken, dus zonder eigen regel valt hij
// buiten de export — en dan mist de pagina een ingevoerd gegeven.
if (initieleSchuld) {
schuldRijen.push({ klasse: "subtotaal", cellen: ["", "Initiële schuld (startpunt)", euro(initieleSchuld)] });
schuldRijen.push({ klasse: "subtotaal", cellen: ["", "Afgelost sinds start", euro(initieleSchuld - totSchuld)] });
}
// ── Voortgang eigen vermogen ────────────────────────────────
// Oplopend op datum: een archief lees je van oud naar nieuw.
const oplopend = [...evRows].sort((a, b) => String(a.datum).localeCompare(String(b.datum)));
// Verwijder je een categorie, dan blijven de stortingen ervan in de oude
// voortgangsrijen staan — de app toont alleen de kolommen van bestaande
// categorieën, dus die bedragen zie je nergens meer. Voor een export die
// "alles wat is ingevoerd" moet tonen is dat precies het verkeerde gedrag, dus
// die wezen krijgen hier alsnog een kolom, gemerkt met hun ruwe sleutel.
const bekend = new Set(cats.map((c) => c.id));
const vaste = new Set(["datum", "nettoWaarde", "opmerkingen"]);
const wezen = [...new Set(oplopend.flatMap((r) => Object.keys(r)))]
.filter((k) => !bekend.has(k) && !vaste.has(k))
.filter((k) => oplopend.some((r) => typeof r[k] === "number"));
const kolomIds = [...cats.map((c) => c.id), ...wezen];
const voortgangRijen = oplopend.map((r, i) => {
const storting = kolomIds.reduce((a, id) => a + (r[id] || 0), 0);
const verschil = i > 0 ? (r.nettoWaarde || 0) - (oplopend[i - 1].nettoWaarde || 0) : null;
return [
datumNl(r.datum),
...kolomIds.map((id) => euro(r[id])),
euro(storting),
euro(r.nettoWaarde),
verschil === null ? "—" : euro(verschil),
r.opmerkingen || "",
];
});
// ── Historie EV / schuld ────────────────────────────────────
// Twee reeksen die los worden bijgehouden en alleen op datum samenkomen; een
// moment kan er dus maar één van hebben.
const datums = [...new Set([
...evVoortgangHistory.map((r) => r.datum),
...schuldHistory.map((s) => s.datum),
])].sort((a, b) => String(a).localeCompare(String(b)));
const historieRijen = datums.map((d) => {
const e = evVoortgangHistory.find((r) => r.datum === d);
const s = schuldHistory.find((x) => x.datum === d);
const ev = e ? (e.ev ?? null) : null;
const sc = s ? (s.schuld ?? null) : null;
return [
datumNl(d),
ev === null ? "—" : euro(ev),
sc === null ? "—" : euro(sc),
ev !== null && sc !== null ? euro(ev - sc) : "—",
e?.opmerkingen || "",
];
});
const sectie = (titel, inhoud, leegTekst) =>
`<h2>${esc(titel)}</h2><section>${inhoud || `<p class="leeg">${esc(leegTekst)}</p>`}</section>`;
// De ruwe data gaat mee zodat dit bestand ook als backup dient. `<` wordt
// ge-escaped, anders sluit een `</script>` in de gegevens het blok voortijdig
// af en is zowel de pagina als de backup stuk.
const jsonBlok = ruweData
? `\n<script type="application/json" id="vibefinance-backup">${
JSON.stringify(ruweData).replace(/</g, "\\u003c")
}</script>\n`
: "";
return `<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VibeFinance — overzicht ${esc(exportDatum)}</title>
<style>${STIJL}</style>
</head>
<body>
<div class="blad">
<header>
<h1>VibeFinance</h1>
<p class="meta">Volledig overzicht · geëxporteerd op ${esc(exportDatum)}${gebruiker ? ` · ${esc(gebruiker)}` : ""}</p>
</header>
<h2>Samenvatting</h2>
<section>
<div class="kpi">${kpis.map(([l, v]) => `<div><span>${esc(l)}</span><strong>${esc(v)}</strong></div>`).join("")}</div>
</section>
${sectie("Eigen vermogen", evRijen.length ? maakTabel(["Categorie", "Post", "Waarde"], evRijen, [2]) : "", "Geen vermogensposten ingevoerd.")}
${sectie("Schulden", schuldRijen.length ? maakTabel(["Blok", "Onderdeel", "Waarde"], schuldRijen, [2]) : "", "Geen schulden ingevoerd.")}
${sectie("Voortgang eigen vermogen", voortgangRijen.length
? maakTabel(
[
"Datum",
...cats.map((c) => c.naam),
// Een verwijderde categorie heeft geen naam meer; de sleutel is dan
// het enige wat er nog van over is.
...wezen.map((k) => `Verwijderd (${k})`),
"Totaal storting", "Netto waarde", "Verschil", "Opmerkingen",
],
voortgangRijen,
// Alle bedragkolommen: vanaf kolom 1 tot en met "Verschil".
Array.from({ length: kolomIds.length + 3 }, (_, i) => i + 1),
)
: "", "Geen voortgangsmomenten vastgelegd.")}
${sectie("Historie eigen vermogen en schuld", historieRijen.length
? maakTabel(["Datum", "Eigen vermogen", "Schuld", "Netto waarde", "Opmerkingen"], historieRijen, [1, 2, 3])
: "", "Geen historie vastgelegd.")}
<footer>
<p><strong>Dit bestand is ook een backup.</strong> Onderin staat je complete
data in machineleesbare vorm, onzichtbaar op het scherm. Ga in VibeFinance
naar <code>Instellingen → Backup herstellen</code> en kies dit
<code>.html</code>-bestand om alles terug te zetten.</p>
<p>De pagina staat volledig op zichzelf: geen internet, geen lettertypen of
scripts van buitenaf. Bewaar hem naast je backup-ZIP, dan heb je één bestand
dat je zowel kunt lézen als kunt terugzetten.</p>
</footer>
</div>${jsonBlok}</body>
</html>`;
}
/**
* Haalt de ingebakken backup uit een geëxporteerde pagina terug.
*
* Bewust met DOMParser en niet met een reguliere expressie: de gegevens kunnen
* zelf van alles bevatten, en een parser die HTML écht begrijpt trapt daar niet
* in. De pagina wordt niet uitgevoerd — DOMParser draait geen scripts, en het
* blok is `type="application/json"`, dus ook in een browser geen code.
*
* @returns {object|null} de data, of null als het geen export-pagina is
*/
export function leesHtmlBackup(htmlTekst) {
const doc = new DOMParser().parseFromString(htmlTekst, "text/html");
const blok = doc.getElementById("vibefinance-backup");
if (!blok) return null;
return JSON.parse(blok.textContent);
}