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 = { "": ["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; interface NextData { valid: string; upsert: Record; 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 = /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, 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 { 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 { 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 { 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 { 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, }; }