BLZ-Bankdaten: Laufzeit-Auto-Update ins Volume + Einstellungen-Seite

Statt npm-Rebuild-Wartung aktualisiert sich der Bankleitzahlen-Datensatz
jetzt zur Laufzeit. Ein Scheduler (taeglich 03:30 + Catch-up 90s) prueft
gemaess konfigurierbarem Intervall und laedt current.json/next.json von
npm/jsDelivr (Paket bankdata-germany) in das neue Bind-Mount-Volume
BANKDATA_DIR (./data/bankdata -> /app/bankdata).

Lookup bevorzugt den Volume-Datensatz vor den ins Image gebackenen Daten
(Fallback). Es wird kein Fremdcode ausgefuehrt - nur JSON gelesen und die
current+next-Delta-Logik nachgebaut. Validierung (>=1000 Eintraege, Format)
+ atomarer Write (tmp+rename) schuetzen den guten Stand vor Muell.

Datenschutz: Der Updater sendet keine Kundendaten, laedt nur eine
oeffentliche Datendatei; abschaltbar; bei Fehler/ohne Egress greift Builtin.

Neue Einstellungen-Seite /settings/bank-data zeigt Datenstand, Update-
Verfuegbarkeit und bietet "Jetzt aktualisieren" + Auto-Update-Schalter +
Intervall. Endpoints GET /api/settings/blz, POST /api/settings/blz/update-now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 13:15:07 +02:00
co-authored by Claude Opus 4.8
parent 73bd72bd9f
commit 91fbe12299
16 changed files with 788 additions and 7 deletions
+1
View File
@@ -36,3 +36,4 @@ npm-debug.log*
# OS
.DS_Store
Thumbs.db
bankdata/
+1 -1
View File
@@ -67,7 +67,7 @@ COPY backend/factory-defaults /app/factory-defaults-builtin
COPY backend/scripts /app/scripts
# Daten-Verzeichnisse (werden via Bind-Mount überlagert; hier nur als Fallback)
RUN mkdir -p uploads factory-defaults prisma/backups
RUN mkdir -p uploads factory-defaults prisma/backups bankdata
# Healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
@@ -1,13 +1,14 @@
import { Response } from 'express';
import { isValidIBAN } from 'ibantools';
import { bankDataByIBAN } from 'bankdata-germany';
import * as blzData from '../services/blzData.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
/**
* IBAN-Lookup: prüft die Prüfziffer (mod-97, offline über ibantools) und liefert
* für deutsche IBANs BIC und Banknamen aus der Bundesbank-Bankleitzahlen-
* datei (offline über bankdata-germany). Die IBAN verlässt NICHT den Server; es
* findet KEIN externer Request statt. Zurückgegeben werden nur öffentliche
* datei (offline; siehe blzData.service Volume-Datensatz bevorzugt, sonst die
* ins Image gebackenen Daten). Die IBAN verlässt NICHT den Server; es findet
* KEIN externer Request statt. Zurückgegeben werden nur öffentliche
* Bankverzeichnis-Daten (BIC/Name), keine kundenbezogenen Informationen.
*
* Antwortformen (immer HTTP 200, sofern eine IBAN übergeben wurde):
@@ -32,7 +33,7 @@ export async function lookupIban(req: AuthRequest, res: Response): Promise<void>
}
const country = iban.slice(0, 2);
const bank = country === 'DE' ? bankDataByIBAN(iban) : null;
const bank = country === 'DE' ? blzData.lookupByIban(iban) : null;
res.json({
success: true,
@@ -0,0 +1,40 @@
import { Response } from 'express';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { logChange } from '../services/audit.service.js';
import * as blzData from '../services/blzData.service.js';
// Status der BLZ-/Bankdaten (installierte Version, Quelle, Auto-Update-Stand).
export async function getStatus(req: AuthRequest, res: Response): Promise<void> {
try {
const status = await blzData.getStatus();
res.json({ success: true, data: status } as ApiResponse);
} catch (error) {
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Laden des BLZ-Status',
} as ApiResponse);
}
}
// Jetzt prüfen/aktualisieren (manueller Trigger). ?force=1 lädt auch bei
// gleicher Version neu.
export async function updateNow(req: AuthRequest, res: Response): Promise<void> {
try {
const force = req.query.force === '1' || req.query.force === 'true';
const result = await blzData.runUpdate(force);
await logChange({
req,
action: 'UPDATE',
resourceType: 'AppSetting',
resourceId: 'blz-data',
label: `BLZ-Bankdaten: ${result.message}`,
});
const status = await blzData.getStatus();
res.json({ success: true, data: { result, status } } as ApiResponse);
} catch (error) {
res.status(502).json({
success: false,
error: error instanceof Error ? error.message : 'BLZ-Aktualisierung fehlgeschlagen',
} as ApiResponse);
}
}
+2
View File
@@ -65,6 +65,7 @@ import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
import { downloadFile } from './controllers/fileDownload.controller.js';
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
import { startBlzUpdateScheduler } from './services/blzUpdateScheduler.service.js';
import { startSecurityMonitorScheduler } from './services/securityAlert.service.js';
import monitoringRoutes from './routes/monitoring.routes.js';
import { auditContextMiddleware } from './middleware/auditContext.js';
@@ -486,4 +487,5 @@ app.listen(PORT as number, LISTEN_ADDR, () => {
startBirthdayScheduler();
startContractStatusScheduler();
startSecurityMonitorScheduler();
startBlzUpdateScheduler();
});
+19
View File
@@ -3,6 +3,7 @@ import multer from 'multer';
import * as appSettingController from '../controllers/appSetting.controller.js';
import * as backupController from '../controllers/backup.controller.js';
import * as rateLimitAdminController from '../controllers/rateLimitAdmin.controller.js';
import * as blzDataController from '../controllers/blzData.controller.js';
import { authenticate, requirePermission } from '../middleware/auth.js';
// Multer für Backup-Upload (in Memory speichern)
@@ -115,6 +116,24 @@ router.get(
backupController.getBackupLogDetail
);
// ==================== BLZ-/BANKDATEN ====================
// Status (installierte Version, Quelle, Auto-Update-Stand)
router.get(
'/blz',
authenticate,
requirePermission('settings:read'),
blzDataController.getStatus,
);
// Jetzt prüfen/aktualisieren (?force=1 lädt auch bei gleicher Version)
router.post(
'/blz/update-now',
authenticate,
requirePermission('settings:update'),
blzDataController.updateNow,
);
// Rate-Limit-Verwaltung (Admin)
router.get(
'/rate-limits/active',
+19 -1
View File
@@ -12,6 +12,10 @@ const DEFAULT_SETTINGS: Record<string, string> = {
// Ausweis-Ablauf: Fristenschwellen (in Tagen)
documentExpiryCriticalDays: '30', // Rot: Kritisch (Standard 30 Tage)
documentExpiryWarningDays: '90', // Gelb: Warnung (Standard 90 Tage)
// BLZ-/Bankdaten-Auto-Update (Bundesbank-Bankleitzahlen via bankdata-germany).
// Der Updater lädt nur eine öffentliche Datendatei keine Kundendaten.
blzAutoUpdateEnabled: 'true', // Auto-Update an/aus
blzUpdateIntervalDays: '30', // Prüf-/Update-Intervall in Tagen
};
// Whitelist erlaubter Setting-Keys. PUT /api/settings nimmt KEINE
@@ -90,8 +94,22 @@ export function validateSettingValue(key: string, rawValue: string): { ok: true;
return { ok: true, value: trimmed };
}
// BLZ-Update-Intervall: mind. 1 Tag, höchstens 365 (verhindert Dauer-Polling
// bzw. faktisch nie).
if (key === 'blzUpdateIntervalDays') {
const trimmed = rawValue.trim();
if (!/^\d+$/.test(trimmed)) {
return { ok: false, error: 'Das Intervall muss eine positive ganze Zahl (Tage) sein.' };
}
const n = parseInt(trimmed, 10);
if (n < 1 || n > 365) {
return { ok: false, error: 'Das Intervall muss zwischen 1 und 365 Tagen liegen.' };
}
return { ok: true, value: String(n) };
}
// Bool-Settings
if (key === 'customerSupportTicketsEnabled' || key === 'monitoringDigestEnabled') {
if (key === 'customerSupportTicketsEnabled' || key === 'monitoringDigestEnabled' || key === 'blzAutoUpdateEnabled') {
const trimmed = rawValue.trim().toLowerCase();
if (trimmed !== 'true' && trimmed !== 'false') {
return { ok: false, error: `${key} muss 'true' oder 'false' sein.` };
+348
View File
@@ -0,0 +1,348 @@
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]
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) ----
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)`);
const sample = cur[keys[0]];
if (!Array.isArray(sample) || sample.length !== 2 || typeof sample[0] !== 'string' || typeof sample[1] !== 'string') {
throw new Error('current-Eintrag hat unerwartetes Format');
}
const next = raw.next;
if (!next || typeof next !== 'object' || typeof next.valid !== 'string' || typeof next.upsert !== 'object' || !Array.isArray(next.remove)) {
throw new Error('next fehlt/ungültig');
}
}
// ---- 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);
}
}
/** 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');
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,
};
}
@@ -0,0 +1,51 @@
/**
* Scheduler für das automatische BLZ-/Bankdaten-Update.
*
* Prüft täglich (03:30) sowie 90s nach Start, ob ein Update fällig ist:
* Auto-Update aktiv UND (noch nie aktualisiert ODER letzte Aktualisierung
* länger her als das eingestellte Intervall in Tagen). Trifft das zu, wird
* der neueste Datensatz geladen (siehe blzData.service).
*
* Das eigentliche Intervall ist in den Einstellungen konfigurierbar
* (`blzUpdateIntervalDays`); der Cron-Tick prüft nur die Fälligkeit.
*/
import cron from 'node-cron';
import { getSetting, getSettingBool } from './appSetting.service.js';
import { runUpdate } from './blzData.service.js';
async function maybeRun(): Promise<void> {
const enabled = await getSettingBool('blzAutoUpdateEnabled');
if (!enabled) {
return;
}
const intervalDays = parseInt((await getSetting('blzUpdateIntervalDays')) || '30', 10) || 30;
const lastUpdatedAt = await getSetting('blzLastUpdatedAt');
if (lastUpdatedAt) {
const last = new Date(lastUpdatedAt).getTime();
if (!Number.isNaN(last)) {
const ageDays = (Date.now() - last) / 86_400_000;
if (ageDays < intervalDays) {
return; // noch nicht fällig
}
}
}
console.log('[BLZ-Scheduler] Update fällig prüfe auf neueren Datensatz…');
await runUpdate(false);
}
export function startBlzUpdateScheduler(): void {
// Täglich um 03:30 (Server-Zeit) Fälligkeit prüfen.
cron.schedule('30 3 * * *', () => {
maybeRun().catch((err) => console.error('[BLZ-Scheduler] Lauf fehlgeschlagen:', err));
});
// Catch-up 90s nach Start.
setTimeout(() => {
maybeRun().catch((err) => console.error('[BLZ-Scheduler] Catch-up fehlgeschlagen:', err));
}, 90_000);
console.log('[BLZ-Scheduler] Gestartet tägliche Fälligkeitsprüfung + Catch-up nach 90s');
}