Der Poisoning-Guard pruefte bisher nur die erste current-Zeile - ein Set mit 1 echten + 999 Fake-Eintraegen kaeme durch. Jetzt wird JEDER Eintrag in current + next.upsert geprueft (8-stellige BLZ, Wert [Name] oder [Name,BIC]), next.remove auf 8-stellige BLZ, next.valid auf ein gueltiges Datum. Dabei korrekt beruecksichtigt: Banken ohne BIC haben nur [Name] (Laenge 1) - lookupBlz liefert dann bic:''. Real gegen den echten Datensatz verifiziert (3506 Eintraege, inkl. BIC-lose wie BLZ 60050009). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
391 lines
14 KiB
TypeScript
391 lines
14 KiB
TypeScript
import fs from 'fs';
|
||
import path from 'path';
|
||
import { getSetting, getSettingBool, setSetting } from './appSetting.service.js';
|
||
|
||
/**
|
||
* BLZ-/Bankdaten-Service.
|
||
*
|
||
* Liefert BIC + Banknamen zu einer deutschen BLZ/IBAN. Zwei Datenquellen,
|
||
* in dieser Reihenfolge:
|
||
* 1. VOLUME – ein zur Laufzeit aktualisierter Datensatz unter BANKDATA_DIR
|
||
* (Bind-Mount, siehe docker-compose). Wird vom Auto-Updater
|
||
* befüllt.
|
||
* 2. BUILTIN – die ins Image gebackenen JSON-Daten des npm-Pakets
|
||
* `bankdata-germany` (Fallback, immer vorhanden).
|
||
*
|
||
* Es wird KEIN Paketcode ausgeführt – nur die reinen JSON-Datendateien
|
||
* (current.json = { "<BLZ>": ["Name","BIC"] }, next.json = Delta der nächsten
|
||
* Periode) werden gelesen und mit eigener Logik indiziert.
|
||
*
|
||
* Datenschutz: Beim Lookup verlässt KEINE IBAN den Server. Nur der
|
||
* Auto-Updater macht ausgehende Requests – und lädt dabei lediglich eine
|
||
* öffentliche Datendatei (keine Kundendaten).
|
||
*/
|
||
|
||
// Kompiliert nach CommonJS – das native `require` ist zur Laufzeit verfügbar
|
||
// und wird nur genutzt, um den Pfad der gebackenen Paketdaten aufzulösen.
|
||
declare const require: NodeRequire;
|
||
|
||
// ---- Pfade / Quellen (per Env überschreibbar) ----
|
||
const BANKDATA_DIR = process.env.BANKDATA_DIR || path.join(process.cwd(), 'bankdata');
|
||
const DATASET_FILE = path.join(BANKDATA_DIR, 'blz-dataset.json');
|
||
const PACKAGE = 'bankdata-germany';
|
||
const CDN_BASE = process.env.BLZ_CDN_BASE || 'https://cdn.jsdelivr.net/npm';
|
||
const REGISTRY_BASE = process.env.BLZ_REGISTRY_BASE || 'https://registry.npmjs.org';
|
||
const FETCH_TIMEOUT_MS = 20_000;
|
||
|
||
// ---- Typen ----
|
||
type BankTuple = [string, string?]; // [bankName, bic?] – BIC fehlt bei manchen Banken
|
||
type CurrentData = Record<string, BankTuple>;
|
||
interface NextData {
|
||
valid: string;
|
||
upsert: Record<string, BankTuple>;
|
||
remove: string[];
|
||
}
|
||
interface RawDataset {
|
||
current: CurrentData;
|
||
next: NextData;
|
||
}
|
||
interface LoadedDataset extends RawDataset {
|
||
source: 'volume' | 'builtin';
|
||
version: string;
|
||
fetchedAt: string | null;
|
||
}
|
||
export interface BankInfo {
|
||
bankName: string;
|
||
bic: string;
|
||
blz: string;
|
||
}
|
||
|
||
// ---- In-Memory-Cache (per mtime invalidiert) ----
|
||
let cache: { dataset: LoadedDataset; combined: CurrentData; key: string } | null = null;
|
||
|
||
function builtinDataDir(): string {
|
||
// require.resolve liefert .../dist/cjs/main.js → data/ liegt daneben.
|
||
const main = require.resolve(PACKAGE);
|
||
return path.join(path.dirname(main), 'data');
|
||
}
|
||
|
||
function builtinVersion(): string {
|
||
try {
|
||
const main = require.resolve(PACKAGE);
|
||
// main = <root>/dist/cjs/main.js → package.json zwei Ebenen höher.
|
||
const pkgPath = path.join(path.dirname(main), '..', '..', 'package.json');
|
||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||
return typeof pkg.version === 'string' ? pkg.version : 'unbekannt';
|
||
} catch {
|
||
return 'unbekannt';
|
||
}
|
||
}
|
||
|
||
function readJson(file: string): any {
|
||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||
}
|
||
|
||
/**
|
||
* Lädt den aktiven Datensatz (Volume bevorzugt, sonst Builtin). Cached anhand
|
||
* der mtime der Volume-Datei bzw. eines statischen Keys für Builtin.
|
||
*/
|
||
function loadDataset(): LoadedDataset {
|
||
// Volume vorhanden?
|
||
let volumeMtime: number | null = null;
|
||
try {
|
||
volumeMtime = fs.statSync(DATASET_FILE).mtimeMs;
|
||
} catch {
|
||
volumeMtime = null;
|
||
}
|
||
|
||
const key = volumeMtime !== null ? `volume:${volumeMtime}` : 'builtin';
|
||
if (cache && cache.key === key) return cache.dataset;
|
||
|
||
let dataset: LoadedDataset;
|
||
if (volumeMtime !== null) {
|
||
try {
|
||
const raw: any = readJson(DATASET_FILE);
|
||
// Metadaten VOR der Assertion lesen (die narrowt raw auf RawDataset).
|
||
const version = typeof raw.version === 'string' ? raw.version : 'unbekannt';
|
||
const fetchedAt = typeof raw.fetchedAt === 'string' ? raw.fetchedAt : null;
|
||
assertValidRawDataset(raw);
|
||
dataset = {
|
||
source: 'volume',
|
||
version,
|
||
fetchedAt,
|
||
current: raw.current,
|
||
next: raw.next,
|
||
};
|
||
} catch (err) {
|
||
console.error('[BLZ] Volume-Datensatz unlesbar, nutze Builtin:', err);
|
||
dataset = loadBuiltin();
|
||
}
|
||
} else {
|
||
dataset = loadBuiltin();
|
||
}
|
||
|
||
const combined = combine(dataset);
|
||
cache = { dataset, combined, key };
|
||
return dataset;
|
||
}
|
||
|
||
function loadBuiltin(): LoadedDataset {
|
||
const dir = builtinDataDir();
|
||
const current = readJson(path.join(dir, 'current.json')) as CurrentData;
|
||
const next = readJson(path.join(dir, 'next.json')) as NextData;
|
||
return { source: 'builtin', version: builtinVersion(), fetchedAt: null, current, next };
|
||
}
|
||
|
||
/**
|
||
* Kombiniert current + next-Delta, wenn das aktuelle Datum den Gültig-ab-
|
||
* Zeitpunkt der nächsten Periode erreicht hat (identische Logik wie das
|
||
* npm-Paket).
|
||
*/
|
||
function combine(ds: RawDataset): CurrentData {
|
||
const validFrom = new Date(ds.next?.valid);
|
||
if (ds.next && !Number.isNaN(validFrom.getTime()) && new Date() >= validFrom) {
|
||
const merged: CurrentData = { ...ds.current, ...ds.next.upsert };
|
||
for (const blz of ds.next.remove || []) delete merged[blz];
|
||
return merged;
|
||
}
|
||
return ds.current;
|
||
}
|
||
|
||
function combinedData(): CurrentData {
|
||
loadDataset();
|
||
return cache!.combined;
|
||
}
|
||
|
||
// ---- Lookup ----
|
||
|
||
export function lookupBlz(blz: string): BankInfo | null {
|
||
if (!/^[1-9]\d{7}$/.test(blz)) return null;
|
||
const entry = combinedData()[blz];
|
||
if (!entry) return null;
|
||
return { bankName: entry[0], bic: entry[1] ?? '', blz };
|
||
}
|
||
|
||
/** Extrahiert die BLZ aus einer deutschen IBAN und schlägt sie nach. */
|
||
export function lookupByIban(iban: string): BankInfo | null {
|
||
if (!/^DE\d{20}$/i.test(iban)) return null;
|
||
// IBAN: DE + 2 Prüfziffern + 8 BLZ + 10 Kontonummer
|
||
return lookupBlz(iban.slice(4, 12));
|
||
}
|
||
|
||
// ---- Validierung des Roh-Datensatzes (gegen Müll/HTML-Antworten) ----
|
||
|
||
// Prüft eine BLZ→[Name,BIC]-Map vollständig: JEDER Schlüssel eine 8-stellige
|
||
// BLZ, JEDER Wert genau [string, string] (BIC darf leer sein). Nicht nur die
|
||
// erste Zeile (Pentest R148, Code-Note) – sonst käme ein Set mit 1 echten +
|
||
// 999 Fake-Einträgen durch.
|
||
function assertValidBankMap(map: Record<string, unknown>, label: string): void {
|
||
for (const [blz, entry] of Object.entries(map)) {
|
||
if (!/^\d{8}$/.test(blz)) {
|
||
throw new Error(`${label}: ungültige BLZ "${blz}"`);
|
||
}
|
||
// Wert ist [Name] (Bank ohne BIC) ODER [Name, BIC]. Beides ist in der
|
||
// echten Bundesbank-Datei vorhanden (z.B. reine Zahlungsverkehr-BLZ).
|
||
if (
|
||
!Array.isArray(entry) ||
|
||
entry.length < 1 ||
|
||
entry.length > 2 ||
|
||
typeof entry[0] !== 'string' ||
|
||
(entry.length === 2 && typeof entry[1] !== 'string')
|
||
) {
|
||
throw new Error(`${label}: Eintrag ${blz} hat unerwartetes Format`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertValidRawDataset(raw: any): asserts raw is RawDataset {
|
||
if (!raw || typeof raw !== 'object') throw new Error('Datensatz ist kein Objekt');
|
||
const cur = raw.current;
|
||
if (!cur || typeof cur !== 'object' || Array.isArray(cur)) throw new Error('current fehlt/ungültig');
|
||
const keys = Object.keys(cur);
|
||
if (keys.length < 1000) throw new Error(`current zu klein (${keys.length} Einträge)`);
|
||
// VOLLSTÄNDIGE Formatprüfung aller Einträge, nicht nur des ersten.
|
||
assertValidBankMap(cur, 'current');
|
||
|
||
const next = raw.next;
|
||
if (!next || typeof next !== 'object' || typeof next.valid !== 'string' || typeof next.upsert !== 'object' || Array.isArray(next.upsert) || !Array.isArray(next.remove)) {
|
||
throw new Error('next fehlt/ungültig');
|
||
}
|
||
// Datum plausibel?
|
||
if (Number.isNaN(new Date(next.valid).getTime())) {
|
||
throw new Error('next.valid ist kein gültiges Datum');
|
||
}
|
||
// upsert-Einträge ebenso streng; remove muss aus 8-stelligen BLZ bestehen.
|
||
assertValidBankMap(next.upsert, 'next.upsert');
|
||
for (const blz of next.remove) {
|
||
if (typeof blz !== 'string' || !/^\d{8}$/.test(blz)) {
|
||
throw new Error(`next.remove enthält ungültige BLZ "${blz}"`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- Ausgehende Requests (nur Updater) ----
|
||
|
||
async function fetchJson(url: string): Promise<any> {
|
||
const ctrl = new AbortController();
|
||
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
||
try {
|
||
const res = await fetch(url, {
|
||
signal: ctrl.signal,
|
||
headers: { Accept: 'application/json', 'User-Agent': 'OpenCRM-BLZ-Updater' },
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status} bei ${url}`);
|
||
return await res.json();
|
||
} finally {
|
||
clearTimeout(t);
|
||
}
|
||
}
|
||
|
||
// Erlaubtes Versionsformat (semver-Kern, rein numerisch). Die Version wird in
|
||
// die CDN-URL interpoliert – ohne strikte Prüfung könnte eine manipulierte
|
||
// Registry-Antwort (`../`, Slashes, Query-Zeichen) den Pfad verbiegen. Der Host
|
||
// bleibt zwar fix (CDN_BASE), aber wir lassen nur `1.2.3` zu.
|
||
const VERSION_RE = /^\d{1,6}\.\d{1,6}\.\d{1,6}$/;
|
||
|
||
/** Ermittelt die neueste verfügbare Paket-Version (npm dist-tag latest). */
|
||
export async function fetchLatestVersion(): Promise<string> {
|
||
const manifest = await fetchJson(`${REGISTRY_BASE}/${PACKAGE}/latest`);
|
||
if (!manifest || typeof manifest.version !== 'string') throw new Error('Registry lieferte keine Version');
|
||
if (!VERSION_RE.test(manifest.version)) {
|
||
throw new Error(`Unerwartetes Versionsformat: ${manifest.version}`);
|
||
}
|
||
return manifest.version;
|
||
}
|
||
|
||
// ---- Versionsvergleich (numerische Segmente, z.B. 2.2603.0) ----
|
||
|
||
export function compareVersions(a: string, b: string): number {
|
||
const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
|
||
const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
|
||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||
const d = (pa[i] || 0) - (pb[i] || 0);
|
||
if (d !== 0) return d > 0 ? 1 : -1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function activeVersion(): string {
|
||
return loadDataset().version;
|
||
}
|
||
|
||
// ---- Update-Durchführung ----
|
||
|
||
let updating = false;
|
||
|
||
export interface UpdateResult {
|
||
changed: boolean;
|
||
version: string;
|
||
message: string;
|
||
}
|
||
|
||
/**
|
||
* Lädt (falls neuer) den aktuellen Datensatz von der CDN ins Volume.
|
||
* `force` lädt auch bei gleicher Version neu.
|
||
*/
|
||
export async function runUpdate(force = false): Promise<UpdateResult> {
|
||
if (updating) {
|
||
return { changed: false, version: activeVersion(), message: 'Update läuft bereits.' };
|
||
}
|
||
updating = true;
|
||
const nowIso = new Date().toISOString();
|
||
try {
|
||
const latest = await fetchLatestVersion();
|
||
await setSetting('blzLatestVersion', latest);
|
||
await setSetting('blzLastCheckedAt', nowIso);
|
||
|
||
const current = activeVersion();
|
||
if (!force && current !== 'unbekannt' && compareVersions(latest, current) <= 0) {
|
||
await setSetting('blzLastError', '');
|
||
return { changed: false, version: current, message: `Bereits aktuell (${current}).` };
|
||
}
|
||
|
||
// Datendateien der Zielversion laden.
|
||
const base = `${CDN_BASE}/${PACKAGE}@${latest}/dist/cjs/data`;
|
||
const [currentData, nextData] = await Promise.all([
|
||
fetchJson(`${base}/current.json`),
|
||
fetchJson(`${base}/next.json`),
|
||
]);
|
||
|
||
const raw = { current: currentData, next: nextData };
|
||
assertValidRawDataset(raw); // wirft bei Müll → kein Überschreiben
|
||
|
||
const payload = JSON.stringify({
|
||
version: latest,
|
||
fetchedAt: nowIso,
|
||
source: 'cdn',
|
||
current: currentData,
|
||
next: nextData,
|
||
});
|
||
|
||
fs.mkdirSync(BANKDATA_DIR, { recursive: true });
|
||
const tmp = `${DATASET_FILE}.tmp`;
|
||
fs.writeFileSync(tmp, payload, 'utf8');
|
||
fs.renameSync(tmp, DATASET_FILE); // atomar
|
||
cache = null; // Cache invalidieren → nächster Lookup lädt neu
|
||
|
||
await setSetting('blzLastUpdatedAt', nowIso);
|
||
await setSetting('blzLastError', '');
|
||
|
||
console.log(`[BLZ] Datensatz aktualisiert auf ${latest} (${Object.keys(currentData).length} Einträge).`);
|
||
return { changed: true, version: latest, message: `Aktualisiert auf ${latest}.` };
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
await setSetting('blzLastError', msg).catch(() => {});
|
||
await setSetting('blzLastCheckedAt', nowIso).catch(() => {});
|
||
console.error('[BLZ] Update fehlgeschlagen:', msg);
|
||
throw err instanceof Error ? err : new Error(msg);
|
||
} finally {
|
||
updating = false;
|
||
}
|
||
}
|
||
|
||
// ---- Status für die Einstellungen-Seite ----
|
||
|
||
export interface BlzStatus {
|
||
activeSource: 'volume' | 'builtin';
|
||
activeVersion: string;
|
||
builtinVersion: string;
|
||
entryCount: number;
|
||
nextValidFrom: string | null;
|
||
fetchedAt: string | null;
|
||
autoUpdateEnabled: boolean;
|
||
intervalDays: number;
|
||
lastCheckedAt: string | null;
|
||
lastUpdatedAt: string | null;
|
||
latestVersion: string | null;
|
||
updateAvailable: boolean;
|
||
lastError: string | null;
|
||
}
|
||
|
||
export async function getStatus(): Promise<BlzStatus> {
|
||
const ds = loadDataset();
|
||
const combined = combinedData();
|
||
|
||
const autoUpdateEnabled = await getSettingBool('blzAutoUpdateEnabled');
|
||
const intervalDays = parseInt((await getSetting('blzUpdateIntervalDays')) || '30', 10) || 30;
|
||
const lastCheckedAt = (await getSetting('blzLastCheckedAt')) || null;
|
||
const lastUpdatedAt = (await getSetting('blzLastUpdatedAt')) || null;
|
||
const latestVersion = (await getSetting('blzLatestVersion')) || null;
|
||
const lastErrorRaw = (await getSetting('blzLastError')) || '';
|
||
|
||
const updateAvailable =
|
||
!!latestVersion && ds.version !== 'unbekannt' && compareVersions(latestVersion, ds.version) > 0;
|
||
|
||
return {
|
||
activeSource: ds.source,
|
||
activeVersion: ds.version,
|
||
builtinVersion: builtinVersion(),
|
||
entryCount: Object.keys(combined).length,
|
||
nextValidFrom: ds.next?.valid || null,
|
||
fetchedAt: ds.fetchedAt,
|
||
autoUpdateEnabled,
|
||
intervalDays,
|
||
lastCheckedAt,
|
||
lastUpdatedAt,
|
||
latestVersion,
|
||
updateAvailable,
|
||
lastError: lastErrorRaw || null,
|
||
};
|
||
}
|