// server.js
const fs = require('fs');
const path = require('path');
const express = require('express');
const chokidar = require('chokidar');
const { XMLParser } = require('fast-xml-parser');
const { PrismaClient } = require('@prisma/client');
const cors = require('cors');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const cookieParser = require('cookie-parser');
const crypto = require('crypto');
const https = require('https');
require('dotenv').config();
const WATCH_PATH = process.argv[2] || process.env.WATCH_PATH || './data';
const FRONTEND_ORIGIN = process.env.FRONTEND_ORIGIN || 'https://sekt.local';
const API_BIND = process.env.API_BIND || '0.0.0.0'; // nicht FRONTEND-Host
const API_PORT = process.env.API_PORT || 3001;
const originUrl = new URL(FRONTEND_ORIGIN);
const ALLOWED_FEATURES = ['DOWNLOADS'];
const prisma = new PrismaClient();
const app = express();
const nodemailer = require('nodemailer');
const puppeteer = require('puppeteer');
const { Buffer } = require('buffer');
const { Agent, fetch } = require('undici');
const { PDFDocument } = require('pdf-lib');
const mailer = nodemailer.createTransport({
host : process.env.SMTP_HOST,
port : parseInt(process.env.SMTP_PORT || '587', 10),
secure: false,
auth : {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
const allowedHosts = [originUrl.hostname, 'localhost', '10.0.1.25', '10.0.3.6', 'kennzeichen.tegdssd.de', 'sekt.tegdssd.de', 'kennzeichen.local', 'sekt.local'];
app.use(
cors({
origin: (origin, cb) => {
if (!origin) return cb(null, true);
const { hostname } = new URL(origin);
if (allowedHosts.includes(hostname)) return cb(null, true);
cb(new Error('Nicht erlaubter Origin'));
},
credentials: true,
})
);
app.use(cookieParser());
app.use(express.json());
app.set('trust proxy', 1);
// === SSE-Clientverwaltung ===
const sseClients = new Map();
let lastResetTimestamp = new Date();
// === Funktionen ===
function pushLogout(userId, reason = 'expired') {
const set = sseClients.get(userId);
if (!set) return;
const payload = `event: logout\ndata: ${JSON.stringify({ reason })}\n\n`;
for (const res of set) res.write(payload);
}
async function lookupImage(filename) {
if (!filename) return null;
const full = await findImageFile(filename);
return full && fs.existsSync(full) ? full : null;
}
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}
function localDateKey(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`; // YYYY-MM-DD in lokaler Zeit
}
function createUnsubscribeToken(ruleId, email) {
const secret = process.env.UNSUBSCRIBE_SECRET || 'fallback_secret';
const base = ruleId != null ? `${ruleId}:${email}` : `all:${email}`;
return crypto.createHmac('sha256', secret).update(base).digest('hex');
}
async function sendNotificationMail(rule, rec) {
const toList = rule.recipients.map(r => r.email.trim()).filter(Boolean);
if (!toList.length) {
console.warn(`🔔 Regel ${rule.id}: keine Empfänger – Mail übersprungen.`);
return { sent: false, reason: 'noRecipients' };
}
const snapPath = await lookupImage(rec.imageFile);
const platePath = await lookupImage(rec.plateFile);
const attachments = [];
if (snapPath) attachments.push({ filename: path.basename(snapPath), path: snapPath });
if (platePath) attachments.push({ filename: path.basename(platePath), path: platePath });
let successes = [], failures = [];
for (const email of toList) {
// Regel-Token: löschen + neu anlegen
await prisma.unsubscribeToken.deleteMany({
where: { ruleId: rule.id, email }
});
const tokenRecord = await prisma.unsubscribeToken.create({
data: {
ruleId: rule.id,
email,
token: '', // wird gleich gesetzt
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
}
});
const unsubscribeSig = createUnsubscribeToken(tokenRecord.ruleId, tokenRecord.email);
await prisma.unsubscribeToken.update({
where: { id: tokenRecord.id },
data: { token: unsubscribeSig },
});
// Globaler Token: löschen + neu anlegen
await prisma.unsubscribeToken.deleteMany({
where: { ruleId: null, email }
});
const globalTokenRecord = await prisma.unsubscribeToken.create({
data: {
ruleId: null,
email,
token: '', // wird gleich gesetzt
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
}
});
const globalSig = createUnsubscribeToken(null, globalTokenRecord.email);
await prisma.unsubscribeToken.update({
where: { id: globalTokenRecord.id },
data: { token: globalSig },
});
const unsubscribeLink = new URL(`/api/unsubscribe?id=${tokenRecord.id}&sig=${unsubscribeSig}`, FRONTEND_ORIGIN).toString();
const globalUnsubscribeLink = new URL(`/api/unsubscribe?id=${globalTokenRecord.id}&sig=${globalSig}`, FRONTEND_ORIGIN).toString();
const html = `
Hallo ${rule.user.username},
es gibt einen neuen Treffer fĂĽr deine Regel #${rule.id}:
- Kennzeichen: ${rec.licenseFormatted ?? rec.license}
${rec.brand ? `- Marke: ${rec.brand}
` : ''}
${rec.model ? `- Modell: ${rec.model}
` : ''}
- Kamera: ${rec.cameraName || '–'}
- Zeit: ${new Date(rec.timestampLocal).toLocaleString('de-DE')}
Diese Mail wurde automatisch generiert. Bitte nicht antworten.
Du möchtest keine E-Mails mehr zu dieser Regel?
Hier austragen
Du möchtest dich von allen Benachrichtigungen abmelden?
Alle Regeln abbestellen
Die Links sind 30 Tage gĂĽltig.
`;
try {
const info = await mailer.sendMail({
from: process.env.MAIL_FROM,
to: email,
subject: `Kennzeichentreffer: ${rec.licenseFormatted ?? rec.license}`,
html,
attachments
});
if (info.rejected.length === 0) {
successes.push(email);
console.log(`âś… Mail an ${email} versendet`);
} else {
failures.push(email);
console.warn(`⚠️ Mail an ${email} abgelehnt: ${info.response}`);
}
} catch (err) {
console.error(`❌ Fehler beim Mailversand an ${email}:`, err);
failures.push(email);
}
}
return { sent: failures.length === 0, accepted: successes, rejected: failures };
}
function timeStringToMinutes(time) {
if (!time) return null;
const [h, m] = time.split(':').map(Number);
if (isNaN(h) || isNaN(m)) return null;
return h * 60 + m;
}
function matchesRule(rule, rec) {
if (!rule.enabled) return false;
if (rule.plates) {
const re = new RegExp(rule.plates, 'i');
if (!re.test(rec.license)) return false;
}
if (rule.brand && !(rec.brand || '').toLowerCase().includes(rule.brand.toLowerCase())) return false;
if (rule.model && !(rec.model || '').toLowerCase().includes(rule.model.toLowerCase())) return false;
if (rule.camera && (rec.cameraName !== rule.camera)) return false;
if (rule.timeFrom && rule.timeTo) {
const nowMinutes = new Date(rec.timestampLocal).getHours() * 60 + new Date(rec.timestampLocal).getMinutes();
const from = timeStringToMinutes(rule.timeFrom);
const to = timeStringToMinutes(rule.timeTo);
if (from != null && to != null) {
if (from <= to) {
if (nowMinutes < from || nowMinutes > to) return false; // z. B. 08:00–17:00
} else {
if (nowMinutes > to && nowMinutes < from) return false; // über Mitternacht, z. B. 22:00–06:00
}
}
}
return true;
}
async function verifyToken(req, res, next) {
let token;
if (req.cookies?.token) token = req.cookies.token;
if (!token && req.headers.authorization?.startsWith('Bearer ')) {
token = req.headers.authorization.substring(7);
}
if (!token && req.headers.cookie) {
const cookies = req.headers.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'token') { token = value; break; }
}
}
if (!token) return res.status(401).json({ error: 'Kein Token' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await prisma.user.findUnique({
where: { id: decoded.id },
include:{ cameraAccess: true, features: { select: { feature: true } } },
});
if (!user) return res.status(401).json({ error: 'Benutzer nicht gefunden' });
// Non-Admins: Ablauf prĂĽfen
if (!user.isAdmin && user.expiresAt && new Date(user.expiresAt) < new Date()) {
pushLogout(user.id, 'expired');
return res
.clearCookie('token', { httpOnly: true, secure: true, sameSite: 'none' })
.status(403).json({ error: 'Der Zugang ist abgelaufen.', logout: true });
}
const canDownload = user.isAdmin || user.features.some(f => f.feature === 'DOWNLOADS');
req.user = {
id: user.id,
username: user.username,
isAdmin: user.isAdmin,
cameraAccess: user.cameraAccess ?? [],
canDownload,
};
// Leises Refresh, wenn < 2 Min Restlaufzeit
const expMs = typeof decoded.exp === 'number' ? decoded.exp * 1000 : 0;
if (expMs - Date.now() < 2 * 60 * 1000) {
const maxAgeMs = 60 * 60 * 1000;
const newToken = jwt.sign(
{ id: user.id, username: user.username, isAdmin: user.isAdmin, canDownload },
process.env.JWT_SECRET,
{ expiresIn: maxAgeMs / 1000 }
);
res.cookie('token', newToken, {
httpOnly: true,
secure: true,
sameSite: 'none',
maxAge: maxAgeMs,
});
}
next();
} catch (err) {
console.error('❌ Token-Fehler:', err);
return res.status(401).json({ error: 'UngĂĽltiger Token' });
}
}
function buildCameraAccessFilter(cameraAccess, isAdmin = false) {
if (isAdmin) return {}; // 🔓 Admins dürfen alles sehen
if (!cameraAccess || cameraAccess.length === 0) {
// Prisma WHERE-Filter, der garantiert keine Ergebnisse liefert
return { cameraName: '__NO_ACCESS__' };
}
return {
OR: cameraAccess.map(access => {
const cond = { cameraName: access.camera };
if (access.from || access.to) {
cond.timestampLocal = {};
if (access.from) cond.timestampLocal.gte = new Date(access.from);
if (access.to) cond.timestampLocal.lte = new Date(access.to);
}
return cond;
}),
};
}
async function findAllXmlFiles(dir) {
console.log('đź“„ Starte XML-Import...');
function walk(d) {
try {
const entries = fs.readdirSync(d, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(d, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.isFile() && entry.name.endsWith('_Info.xml')) {
enqueueFile(fullPath);
}
}
} catch (err) {
console.error(`❌ Fehler beim Lesen von ${d}:`, err.message);
}
}
walk(dir);
console.log('âś… XML-Import gestartet.');
}
function enqueueFile(filePath) {
fileQueue.push(filePath);
if (!processing) processQueue();
}
async function processQueue() {
processing = true;
while (fileQueue.length > 0) {
await processFile(fileQueue.shift());
}
processing = false;
}
async function findImageFile(filename) {
const jpgFilename = filename.replace(/\.webp$/, '.jpg');
function searchInDir(dir) {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const found = searchInDir(fullPath);
if (found) return found;
} else if (entry.isFile() && entry.name === jpgFilename) {
return fullPath;
}
}
} catch (err) {
console.error('❌ Fehler beim Lesen des Verzeichnisses:', err);
}
return null;
}
return searchInDir(WATCH_PATH);
}
async function processFile(filePath) {
try {
const xml = fs.readFileSync(filePath, 'utf-8');
const json = parser.parse(xml);
const data = json?.ReturnRecognitionData?.StandardMessage?.RecognitionData;
if (!data?.License) return;
const baseName = path.basename(filePath).replace('_Info.xml', '');
const dir = path.dirname(filePath);
const jpgPath = path.join(dir, `${baseName}.jpg`);
const platePath = path.join(dir, `${baseName}_plate.jpg`);
const timestampUTC = new Date(
data.DateUTC.Year, data.DateUTC.Month - 1, data.DateUTC.Day,
data.TimeUTC.Hour, data.TimeUTC.Min, data.TimeUTC.Sec, data.TimeUTC.Msec
);
const timestampLocal = new Date(
data.DateLocal.Year, data.DateLocal.Month - 1, data.DateLocal.Day,
data.TimeLocal.Hour, data.TimeLocal.Min, data.TimeLocal.Sec, data.TimeLocal.Msec
);
const exists = await prisma.recognition.findFirst({
where: { license: String(data.License), timestampUTC, timestampLocal }
});
if (exists) return;
const saved = await prisma.recognition.create({
data: {
license: String(data.License) || null,
licenseFormatted: String(data.LicenseFormatted) || null,
country: data.Country || null,
confidence: parseInt(data.Confidence || '0', 10),
timestampUTC,
timestampLocal,
cameraName: String(data.CameraName) || null,
classification: String(data.MMR?.Classification) || null,
direction: String(data.Direction) || null,
directionDegrees: parseInt(data.DirectionDegrees || '0', 10),
imageFile: fs.existsSync(jpgPath) ? `${baseName}.jpg` : null,
plateFile: fs.existsSync(platePath) ? `${baseName}_plate.jpg` : null,
brand: data.MMR?.Make ? String(data.MMR.Make) : null || null,
model: data.MMR?.Model ? String(data.MMR.Model) : null || null,
brandmodelconfidence: parseInt(data.MMR?.MakeModelConfidence || '0', 10),
},
});
const rules = await prisma.notificationRule.findMany({
where : { enabled: true },
include: {
recipients: true,
user: { select: { username: true } }
},
});
for (const rule of rules) {
if (matchesRule(rule, saved)) {
const url = saved.imageFile
? new URL(`/images/${saved.imageFile}`, FRONTEND_ORIGIN).toString()
: null;
sendNotificationMail(rule, saved, url).catch(console.error);
}
}
broadcastSSE(saved);
console.log(`âś… Erfasst: ${saved.license}`);
} catch (err) {
console.error(`❌ Fehler bei ${filePath}:`, err.message);
}
}
function generateSecurePassword(length = 12) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%()*+,-./:;=?@[]_{|}~';
const bytes = crypto.randomBytes(length);
const password = Array.from(bytes, (byte) => chars[byte % chars.length]).join('');
return password;
}
// Token-Cleanup (z. B. beim Start oder via Intervall)
async function cleanUpExpiredTokens() {
const result = await prisma.unsubscribeToken.deleteMany({
where: {
expiresAt: { lt: new Date() }
}
});
console.log(`đź§ą ${result.count} abgelaufene Tokens entfernt`);
}
function isoDateLocal(date) {
// Offset in Millisekunden (negativ in CEST)
const offsetMs = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 10);
}
function broadcastSSE(data) {
const payload = `event: new-recognition\ndata: ${JSON.stringify(data)}\n\n`;
sseClients.forEach((resSet, userId) => {
prisma.user.findUnique({
where: { id: userId },
include: { cameraAccess: true },
}).then(user => {
if (!user) return;
const isAllowed = user.cameraAccess.some(access => {
if (access.camera !== data.cameraName) return false;
const ts = new Date(data.timestampLocal).getTime();
const from = access.from ? new Date(access.from).getTime() : -Infinity;
const to = access.to ? new Date(access.to).getTime() : Infinity;
return ts >= from && ts <= to;
});
if (user.isAdmin || isAllowed) {
for (const res of resSet) res.write(payload);
}
}).catch(err => {
console.error(`❌ Fehler beim Laden von User ${userId}:`, err);
});
});
}
// === REST-API-Endpunkte ===
// === POST ===
// âś… /api/login
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
const user = await prisma.user.findUnique({ where: { username } });
if (!user) return res.status(401).json({ error: 'UngĂĽltiger Benutzername oder Passwort' });
const isValidPw = await bcrypt.compare(password, user.passwordHash);
if (!isValidPw) return res.status(401).json({ error: 'UngĂĽltiger Benutzername oder Passwort' });
if (!user.isAdmin && user.expiresAt && new Date(user.expiresAt) <= new Date()) {
return res.status(403).json({ error: 'Der Zugang ist abgelaufen.' });
}
// lastLogin best-effort
const now = new Date();
try { await prisma.user.update({ where: { id: user.id }, data: { lastLogin: now } }); } catch {}
// Features laden
const feats = await prisma.userFeature.findMany({ where: { userId: user.id }, select: { feature: true } });
const features = feats.map(f => f.feature);
// Admins: DOWNLOADS garantieren (DB + Antwort)
if (user.isAdmin && !features.includes('DOWNLOADS')) {
await prisma.userFeature.upsert({
where: { userId_feature_unique: { userId: user.id, feature: 'DOWNLOADS' } },
update: {},
create: { userId: user.id, feature: 'DOWNLOADS' },
});
features.push('DOWNLOADS');
}
const canDownload = user.isAdmin || features.includes('DOWNLOADS');
const maxAgeMs = 60 * 60 * 1000; // 1h
const token = jwt.sign(
{ id: user.id, username: user.username, isAdmin: user.isAdmin, canDownload },
process.env.JWT_SECRET,
{ expiresIn: maxAgeMs / 1000 }
);
res.cookie('token', token, {
httpOnly: true,
secure: true,
sameSite: 'none',
maxAge: maxAgeMs,
});
res.json({
success: true,
token,
user: {
id: user.id,
username: user.username,
isAdmin: user.isAdmin,
tokenExpiresAt: Date.now() + maxAgeMs,
expiresAt: user.expiresAt ? (user.expiresAt.toISOString?.() ?? user.expiresAt) : null,
lastLogin: now.toISOString(),
features,
},
});
});
// âś… /api/refresh-token
app.post('/api/refresh-token', verifyToken, async (req, res) => {
const { id, username, isAdmin } = req.user;
// canDownload frisch gegen DB
let canDownload = !!isAdmin;
if (!canDownload) {
canDownload = await prisma.userFeature.count({
where: { userId: id, feature: 'DOWNLOADS' }
}) > 0;
}
const maxAgeMs = 60 * 60 * 1000;
const newToken = jwt.sign({ id, username, isAdmin, canDownload }, process.env.JWT_SECRET, {
expiresIn: maxAgeMs / 1000,
});
res.cookie('token', newToken, {
httpOnly: true,
secure: true,
sameSite: 'none',
maxAge: maxAgeMs,
});
res.json({ success: true, tokenExpiresAt: Date.now() + maxAgeMs });
});
app.post('/api/logout', (req, res) => {
res.clearCookie('token', {
httpOnly: true,
secure: true,
sameSite: 'none',
});
res.json({ success: true });
});
app.post('/api/notifications', verifyToken, async (req, res) => {
const {
plates, brand, model, camera,
timeFrom, timeTo,
emails // Array
} = req.body;
if (!Array.isArray(emails) || emails.length === 0) {
return res.status(400).json({ error: 'Mindestens eine Empfänger-E-Mail angeben.' });
}
try {
const raw = await prisma.notificationRule.create({
data: {
userId : req.user.id,
plates,
brand,
model,
camera,
timeFrom,
timeTo,
recipients: { create: emails.map(e => ({ email: e.trim() })) }
},
include: { recipients: { select: { email: true } } }
});
const rule = {
id : raw.id,
licensePattern : raw.plates,
brand : raw.brand,
model : raw.model,
camera : raw.camera,
timeFrom : raw.timeFrom,
timeTo : raw.timeTo,
recipients : raw.recipients.map(r => r.email)
};
res.status(201).json(rule);
} catch (err) {
console.error('❌ Fehler beim Anlegen der NotificationRule:', err);
res.status(500).json({ error: 'Konnte Regel nicht speichern.' });
}
});
// /api/admin/create-user
app.post('/api/admin/create-user', verifyToken, async (req, res) => {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Nicht autorisiert' });
}
const { username, expiresAt, cameraAccess, features } = req.body;
const newPassword = generateSecurePassword(12);
const passwordHash = await bcrypt.hash(newPassword, 10);
// nur erlaubte Features ĂĽbernehmen
const validFeatures = Array.isArray(features)
? features.filter(f => ALLOWED_FEATURES.includes(f))
: [];
try {
const user = await prisma.user.create({
data: {
username,
passwordHash,
expiresAt: expiresAt ? new Date(expiresAt) : null,
// Kamerazugriffe
cameraAccess: {
create: (cameraAccess?.map(a => ({
camera: a.camera,
from : a.from ? new Date(a.from) : null,
to : a.to ? new Date(a.to) : null,
})) ?? []),
},
// 🔑 HIER: Features gleich mit anlegen
...(validFeatures.length
? {
features: {
create: validFeatures.map(f => ({ feature: f })),
},
}
: {}),
},
include: {
cameraAccess: true,
features: { select: { feature: true } },
},
});
// Antwort vereinheitlichen (Features als String-Array)
res.json({
success: true,
user: {
...user,
features: user.features.map(f => f.feature),
},
newPassword,
});
} catch (err) {
console.error('Fehler beim Erstellen des Benutzers:', err);
res.status(500).json({ error: 'Fehler beim Erstellen des Benutzers' });
}
});
// Zuweisen
app.post('/api/admin/features/grant', verifyToken, async (req, res) => {
if (!req.user?.isAdmin) return res.status(403).json({ error: 'Nicht autorisiert' });
const { userId, feature } = req.body;
if (!userId || !ALLOWED_FEATURES.includes(feature)) {
return res.status(400).json({ error: 'UngĂĽltige Parameter' });
}
await prisma.userFeature.upsert({
where: { userId_feature_unique: { userId, feature } },
update: {},
create: { userId, feature },
});
res.json({ success: true });
});
// âś… /api/admin/features/revoke
app.post('/api/admin/features/revoke', verifyToken, async (req, res) => {
if (!req.user?.isAdmin) return res.status(403).json({ error: 'Nicht autorisiert' });
const { userId, feature } = req.body;
if (!userId || !ALLOWED_FEATURES.includes(feature)) {
return res.status(400).json({ error: 'UngĂĽltige Parameter' });
}
const target = await prisma.user.findUnique({ where: { id: userId }, select: { isAdmin: true } });
if (target?.isAdmin && feature === 'DOWNLOADS') {
return res.status(400).json({ error: 'Admins mĂĽssen das Feature "DOWNLOADS" behalten.' });
}
await prisma.userFeature.deleteMany({ where: { userId, feature } });
// âť— Sofortige Wirkung erzwingen
pushLogout(userId, 'features-changed');
res.json({ success: true });
});
app.post('/api/admin/reset-password/:id', verifyToken, async (req, res) => {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Nicht autorisiert' });
}
const userId = req.params.id;
const newPassword = generateSecurePassword(12);
const passwordHash = await bcrypt.hash(newPassword, 10);
try {
await prisma.user.update({
where: { id: userId },
data: { passwordHash },
});
res.json({ success: true, newPassword });
} catch (err) {
console.error('Fehler beim ZurĂĽcksetzen des Passworts:', err);
res.status(500).json({ error: 'Passwort konnte nicht zurĂĽckgesetzt werden' });
}
});
app.post('/api/recognitions/reset-count', (req, res) => {
lastResetTimestamp = new Date();
res.status(200).json({ success: true });
});
app.post('/api/admin/block-user/:id', verifyToken, async (req, res) => {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Nicht autorisiert' });
}
const userId = req.params.id;
if (userId === String(req.user.id)) {
return res.status(400).json({ error: 'Du kannst dich nicht selbst sperren.' });
}
try {
await prisma.user.update({
where: { id: userId },
data: { expiresAt: new Date() },
});
pushLogout(userId, 'blocked');
res.json({ success: true });
} catch (err) {
console.error('❌ Fehler beim Sperren des Benutzers:', err);
res.status(500).json({ error: 'Zugang konnte nicht gesperrt werden' });
}
});
app.post('/api/recognitions/export', verifyToken, async (req, res) => {
try {
const { format = 'csv', filters = {}, selection, fields } = req.body;
const { search = '', direction = '', timestampFrom, timestampTo, camera = '' } = filters;
// ---- Felder-Whitelist und Defaults -----------------------------------
const ALLOWED = [
'id','license','licenseFormatted','country','brand','model',
'confidence','timestampLocal','cameraName','direction','directionDegrees'
];
const DEFAULT_FIELDS = [
'licenseFormatted','country','brand','model',
'confidence','timestampLocal','cameraName','direction','directionDegrees'
];
// In der Route, nach dem Whitelisten:
const fieldsFiltered = Array.isArray(fields) && fields.length
? fields.filter((f) => ALLOWED.includes(f))
: DEFAULT_FIELDS;
// NEU: direction erzwingt directionDegrees
const fieldsToUse = pairDirectionFields(fieldsFiltered);
// ---- Hilfen -----------------------------------------------------------
const normalize = (s) => (s || '').toString().trim().toLowerCase().replace(/[-\s]+/g, '');
const searchNorm = normalize(search);
const timeFilter = {};
if (timestampFrom) timeFilter.gte = new Date(timestampFrom);
if (timestampTo) { const t = new Date(timestampTo); t.setHours(23,59,59,999); timeFilter.lte = t; }
const user = req.user;
const accessFilters = user.cameraAccess.map(access => {
const tf = {};
if (access.from) tf.gte = access.from;
if (access.to) tf.lte = access.to;
return { cameraName: access.camera, ...(Object.keys(tf).length ? { timestampLocal: tf } : {}) };
});
const searchFilters = [];
if (searchNorm) {
searchFilters.push(
{ license: { contains: searchNorm } },
{ brand : { contains: searchNorm } },
{ model : { contains: searchNorm } },
);
}
const baseWhere = {
AND: [
...(!user.isAdmin ? [{ OR: accessFilters }] : []),
...(searchFilters.length ? [{ OR: searchFilters }] : []),
...(Object.keys(timeFilter).length ? [{ timestampLocal: timeFilter }] : []),
...(direction === 'towards' || direction === 'away' ? [{ direction: { equals: capitalize(direction) } }] : []),
...(camera ? [{ cameraName: camera }] : []),
],
};
let where = baseWhere;
if (selection?.mode === 'selected') {
where = { AND: [baseWhere, { id: { in: selection.ids || [] } }] };
} else if (selection?.mode === 'selected-all-except') {
where = { AND: [baseWhere, { id: { notIn: selection.exceptIds || [] } }] };
}
// ---- Daten holen (Superset) ------------------------------------------
const rows = await prisma.recognition.findMany({
where,
orderBy: { timestampLocal: 'desc' },
select: {
id: true, license: true, licenseFormatted: true, country: true,
brand: true, model: true, confidence: true, timestampLocal: true,
cameraName: true, direction: true, directionDegrees: true,
imageFile: true, plateFile: true,
},
take: 2000,
});
const clientJobId = req.body?.clientJobId || null;
// ---- Dispatch je Format ----------------------------------------------
if (format === 'pdf') {
return await exportAsPdf(rows, fieldsToUse, req, res, { clientJobId });
}
if (format === 'json') {
return exportAsJson(rows, fieldsToUse, res);
}
// default CSV
return exportAsCsv(rows, fieldsToUse, res);
} catch (err) {
console.error('❌ Export error:', err);
res.status(500).json({ error: 'Export fehlgeschlagen' });
}
});
/* ============================== HELPERS ============================== */
// Einheitlicher Export-Dateiname: export_YYYY-MM-DD_HH-mm.
function buildExportFilename(ext = 'csv', d = new Date()) {
const pad = (n) => String(n).padStart(2, '0');
const yyyy = d.getFullYear();
const mm = pad(d.getMonth() + 1);
const dd = pad(d.getDate());
const hh = pad(d.getHours());
const mi = pad(d.getMinutes());
return `export_${yyyy}-${mm}-${dd}_${hh}-${mi}.${ext}`;
}
// ---- Helper: Datei -> Data-URL ----
async function fileToDataUrl(absPath, mime = 'image/jpeg') {
const buf = await fs.promises.readFile(absPath);
return `data:${mime};base64,${buf.toString('base64')}`;
}
// ---- Helper: HTTP -> Data-URL mit eigener CA (fĂĽr Logo-Fallback) ----
async function fetchAsDataUrl(url, cookie = '', bearer = '') {
let dispatcher = undefined;
try {
const ca = fs.readFileSync(path.resolve(__dirname, 'certs', 'myRoot.crt'));
dispatcher = new Agent({ connect: { ca } });
} catch { /* wenn CA fehlt, versucht er es ohne */ }
const headers = {};
if (cookie) headers['Cookie'] = cookie;
if (bearer) headers['Authorization'] = `Bearer ${bearer}`;
const resp = await fetch(url, { dispatcher, headers });
if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${url}`);
const buf = Buffer.from(await resp.arrayBuffer());
const mime = resp.headers.get('content-type') || 'image/png';
return `data:${mime};base64,${buf.toString('base64')}`;
}
// ---- Helper: Bild der Erkennung -> Data-URL (bevorzugt Filesystem) ----
async function fullPhotoDataUrlForRow(r) {
if (r.imageFile) {
try {
const abs = await findImageFile(r.imageFile); // deine vorhandene Funktion
if (abs && fs.existsSync(abs)) return await fileToDataUrl(abs, 'image/jpeg');
} catch {}
}
// Fallback: per HTTP holen (inkl. Token/Cookies) und inline konvertieren
try {
const tokenFromCookie = req.cookies?.token || null;
const tokenFromAuth = req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice(7)
: null;
const token = tokenFromCookie || tokenFromAuth || '';
const url = r.imageFile
? `${BASE}/images/${r.imageFile}`
: `${BASE}/assets/img/placeholder.jpg`;
return await fetchAsDataUrl(url, req.headers.cookie || '', token);
} catch {
// letzter Fallback: „leeres“ 1x1 PNG
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAoMBgVOWk5EAAAAASUVORK5CYII=';
}
}
async function platePhotoDataUrlForRow(r) {
if (r.plateFile) {
try {
const abs = await findImageFile(r.plateFile); // deine vorhandene Funktion
if (abs && fs.existsSync(abs)) return await fileToDataUrl(abs, 'image/jpeg');
} catch {}
}
// Fallback: per HTTP holen (inkl. Token/Cookies) und inline konvertieren
try {
const tokenFromCookie = req.cookies?.token || null;
const tokenFromAuth = req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice(7)
: null;
const token = tokenFromCookie || tokenFromAuth || '';
const url = r.plateFile
? `${BASE}/images/${r.plateFile}`
: `${BASE}/assets/img/placeholder.jpg`;
return await fetchAsDataUrl(url, req.headers.cookie || '', token);
} catch {
// letzter Fallback: „leeres“ 1x1 PNG
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAoMBgVOWk5EAAAAASUVORK5CYII=';
}
}
function emitToUser(userId, event, payload) {
const set = sseClients.get(String(userId));
if (!set) return;
const msg = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const res of set) res.write(msg);
}
function pairDirectionFields(fields) {
if (!Array.isArray(fields)) return fields;
const hasDir = fields.includes('direction');
const hasDeg = fields.includes('directionDegrees');
if (hasDir && !hasDeg) {
const out = [];
for (const f of fields) {
out.push(f);
if (f === 'direction') out.push('directionDegrees'); // direkt danach einsortieren
}
return out;
}
return fields;
}
// CSV -----------------------------------------------------------------
function exportAsCsv(rows, fieldsToUse, res) {
const header = fieldsToUse;
const csv = [
header.join(';'),
...rows.map(r =>
header.map(key => {
const v = r[key];
const s = v instanceof Date ? v.toISOString() : (v ?? '');
return `"${String(s).replace(/"/g, '""')}"`;
}).join(';')
),
].join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
const filename = buildExportFilename('csv');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`);
return res.send(csv);
}
// JSON ----------------------------------------------------------------
function exportAsJson(rows, fieldsToUse, res) {
const slim = rows.map(r => {
const obj = {};
for (const k of fieldsToUse) obj[k] = r[k];
return obj;
});
res.setHeader('Content-Type', 'application/json; charset=utf-8');
const filename = buildExportFilename('json');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`);
return res.send(JSON.stringify(slim));
}
// PDF -----------------------------------------------------------------
// Erwartet emitToUser(userId, event, payload) als globale Helper-Funktion
// sowie findImageFile, fileToDataUrl, fetchAsDataUrl und puppeteer.
async function exportAsPdf(rows, fieldsToUse, req, res, { clientJobId } = {}) {
const userId = String(req.user?.id || '');
const total = rows.length;
const CHUNK_SIZE = 40;
const now = new Date();
// ===== Progress-Helfer ==================================================
let done = 0; // bereits "verarbeitete" Datensätze (für Progress)
const safeEmit = (payload) => {
try {
if (typeof emitToUser === 'function') {
emitToUser(userId, 'export-progress', payload);
}
} catch {}
};
const ping = (stage, overrideProgress) => {
if (!clientJobId) return;
const computed = total > 0 ? Math.round((done / total) * 98) : 1;
const progress = Math.max(1, Math.min(99, overrideProgress ?? computed));
safeEmit({ jobId: clientJobId, stage, done, total, progress });
};
// ===== Utilities ========================================================
const escapeHtml = (s) =>
String(s ?? '')
.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"')
.replace(/'/g, ''');
const fmtDate = (d) => {
try {
return new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(d));
} catch { return new Date(d).toISOString(); }
};
const BASE = process.env.FRONTEND_ORIGIN || 'http://localhost:3000';
async function fileToDataUrl(absPath, mime = 'image/jpeg') {
const buf = await fs.promises.readFile(absPath);
return `data:${mime};base64,${buf.toString('base64')}`;
}
async function fetchAsDataUrl(url, cookie = '', bearer = '') {
let dispatcher;
try {
const ca = fs.readFileSync(path.resolve(__dirname, 'certs', 'myRoot.crt'));
dispatcher = new Agent({ connect: { ca } });
} catch {}
const headers = {};
if (cookie) headers['Cookie'] = cookie;
if (bearer) headers['Authorization'] = `Bearer ${bearer}`;
const resp = await fetch(url, { dispatcher, headers });
if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${url}`);
const buf = Buffer.from(await resp.arrayBuffer());
const mime = resp.headers.get('content-type') || 'image/png';
return `data:${mime};base64,${buf.toString('base64')}`;
}
async function findImageFile(filename) {
if (!filename) return null;
const jpgFilename = filename.replace(/\.webp$/, '.jpg');
function searchInDir(dir) {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) { const f = searchInDir(full); if (f) return f; }
else if (e.isFile() && e.name === jpgFilename) return full;
}
} catch {}
return null;
}
return searchInDir(WATCH_PATH);
}
async function fullPhotoDataUrlForRow(r) {
if (r.imageFile) {
const abs = await findImageFile(r.imageFile);
if (abs && fs.existsSync(abs)) return await fileToDataUrl(abs, 'image/jpeg');
}
try {
const tokenFromCookie = req.cookies?.token || null;
const tokenFromAuth = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7) : null;
const token = tokenFromCookie || tokenFromAuth || '';
const url = r.imageFile ? `${BASE}/images/${r.imageFile}` : `${BASE}/assets/img/placeholder.jpg`;
return await fetchAsDataUrl(url, req.headers.cookie || '', token);
} catch {
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAoMBgVOWk5EAAAAASUVORK5CYII=';
}
}
async function platePhotoDataUrlForRow(r) {
if (r.plateFile) {
const abs = await findImageFile(r.plateFile);
if (abs && fs.existsSync(abs)) return await fileToDataUrl(abs, 'image/jpeg');
}
try {
const tokenFromCookie = req.cookies?.token || null;
const tokenFromAuth = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7) : null;
const token = tokenFromCookie || tokenFromAuth || '';
const url = r.plateFile ? `${BASE}/images/${r.plateFile}` : `${BASE}/assets/img/placeholder.jpg`;
return await fetchAsDataUrl(url, req.headers.cookie || '', token);
} catch {
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAoMBgVOWk5EAAAAASUVORK5CYII=';
}
}
const labelOf = {
id:'ID', license:'Kennzeichen (roh)', licenseFormatted:'Kennzeichen',
country:'Land', brand:'Marke', model:'Modell', confidence:'Treffsicherheit',
timestampLocal:'Zeit', cameraName:'Kamera', direction:'Richtung', directionDegrees:'Richtung (°)',
};
const fieldsForPdf = fieldsToUse.filter(f => f !== 'directionDegrees');
const valueFor = (r, k) => {
if (k === 'timestampLocal') return fmtDate(r.timestampLocal);
if (k === 'confidence' && r.confidence != null) return `${r.confidence}%`;
if (k === 'licenseFormatted') return r.licenseFormatted || r.license || '-';
if (k === 'direction') return r.directionDegrees != null ? `${r.direction ?? '-'} (${r.directionDegrees}°)` : (r.direction ?? '-');
return r[k] ?? '-';
};
// ---- Logo --------------------------------------------------------------
let logoSrc;
try {
const svg = path.resolve(__dirname, 'assets', 'img', 'logo.svg');
const png = path.resolve(__dirname, 'assets', 'img', 'logo.png');
if (fs.existsSync(svg)) {
logoSrc = await fileToDataUrl(svg, 'image/svg+xml');
} else if (fs.existsSync(png)) {
logoSrc = await fileToDataUrl(png, 'image/png');
} else {
const cookie = req.headers.cookie || '';
const bearer = req.cookies?.token || '';
try {
logoSrc = await fetchAsDataUrl(`${BASE}/assets/img/logo.svg`, cookie, bearer);
} catch {
logoSrc = await fetchAsDataUrl(`${BASE}/assets/img/logo.png`, cookie, bearer);
}
}
} catch {
logoSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAACENnwnAAAAIUlEQVR42mP8z8Dwn4GBgYHhP2NgYGBg+P///w8GAAxwBvUQ8ZqjAAAAAElFTkSuQmCC';
}
const headerHtml = `
`;
const minTs = rows.length ? new Date(Math.min(...rows.map(r => new Date(r.timestampLocal).getTime()))) : null;
const maxTs = rows.length ? new Date(Math.max(...rows.map(r => new Date(r.timestampLocal).getTime()))) : null;
const rangeHtml = (minTs && maxTs)
? `Von: ${escapeHtml(fmtDate(minTs))}
Bis: ${escapeHtml(fmtDate(maxTs))}
` : '–';
const coverHtml = `
${headerHtml}
Kennzeichenerfassung – Export
- Erstellt von
- ${escapeHtml(req.user?.username || 'Unbekannt')}
- Erstellt am
- ${escapeHtml(fmtDate(now))}
- Anzahl Einträge
- ${rows.length.toLocaleString('de-DE')}
- Zeitraum
- ${rangeHtml}
`;
const style = `
@page { size: A4; margin: 14mm 12mm 16mm; }
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial, sans-serif; color:#111; }
.head{ display:flex; justify-content:space-between; align-items:center; gap:12mm; width:100%; max-width:160mm; margin:0 auto 12mm; }
.head-left p{ margin:0; }
.head-logo img{ height:14mm; object-fit:contain; display:block; border:0; }
.cover .head{ max-width:150mm; }
.page{ page-break-after: always; display:flex; flex-direction:column; align-items:center; }
.cover{ justify-content:center; }
.cover-inner{ max-width:160mm; text-align:center; }
.cover h1{ font-size:22pt; margin:6mm 0 10mm; }
.center{ width:100%; display:flex; justify-content:center; }
.meta{ display:grid; grid-template-columns:45mm 1fr; gap:6px 12px; font-size:12pt; margin:0 auto; }
.meta dt{ color:#555; font-weight:600; text-align:left; } .meta dd{ margin:0; text-align:left; }
.meta.meta--stack{ display:grid; grid-template-columns:1fr; gap:10px; justify-content:center; }
.meta.meta--stack .row{ display:grid; grid-template-columns:45mm 1fr; column-gap:14px; align-items:start; }
.meta .muted{ color:#666; }
.photo-wrap{ width:100%; display:flex; flex-direction:column; align-items:center; gap:4mm; }
.photo{ max-width:160mm; max-height:100mm; object-fit:contain; border-radius:6px; border:1px solid #ddd; }
.details{ width:100%; margin-top:10mm; }
h2{ margin:0 0 6mm; font-size:16pt; }
table{ width:100%; border-collapse:collapse; font-size:11pt; }
th,td{ padding:6px 0; vertical-align:top; text-align:left; }
th{ width:42mm; color:#444; font-weight:600; padding-right:8mm; }
tr+tr td, tr+tr th{ border-top:1px solid #eee; }
`;
// 🔹 pro Zeile pingen + kurz yielden
async function buildPagesHtml(subRows) {
const chunks = [];
for (const r of subRows) {
const [fullPhotoSrc, platePhotoSrc] = await Promise.all([
fullPhotoDataUrlForRow(r),
platePhotoDataUrlForRow(r),
]);
const detailRows = fieldsForPdf.map(k => `
| ${escapeHtml(labelOf[k])} | ${escapeHtml(String(valueFor(r,k)))} |
`).join('');
chunks.push(`
${headerHtml}
`);
// 🔹 hier: done+1 und sofort Progress senden
done += 1;
ping(`Seite ${done}/${total}`);
// 🔹 Event-Loop freigeben, damit SSE rausfliegt
await new Promise(resolve => setImmediate(resolve));
}
return chunks.join('\n');
}
function wrapHtml(body) {
return `
Kennzeichenerfassung Export
${body}`;
}
// ===== Progress-Start ===================================================
ping('starte…', 1);
ping('PDF wird aufgebaut…');
// ---- CHUNKED RENDERING -------------------------------------------------
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage','--no-zygote','--no-first-run','--no-default-browser-check'],
});
const page = await browser.newPage();
page.setDefaultTimeout(60_000);
page.setDefaultNavigationTimeout(60_000);
const partBuffers = [];
for (let i = 0; i < rows.length; i += CHUNK_SIZE) {
const sub = rows.slice(i, i + CHUNK_SIZE);
const pagesHtml = await buildPagesHtml(sub); // 🔹 pings passieren hier pro Zeile
const withCover = i === 0 ? coverHtml : '';
const html = wrapHtml(`${withCover}${pagesHtml}`);
await page.setContent(html, { waitUntil: 'domcontentloaded' });
await page.emulateMediaType('screen');
const buf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '14mm', right: '12mm', bottom: '16mm', left: '12mm' },
});
partBuffers.push(buf);
// 🔹 zusätzlicher Stage-Hinweis (ohne Progress-Override)
ping(`Teil ${Math.floor(i / CHUNK_SIZE) + 1} gerendert`);
await new Promise(resolve => setImmediate(resolve));
}
await page.close();
await browser.close();
// ---- PDFs zusammenfĂĽhren ----------------------------------------------
ping('PDF wird zusammengeführt…', Math.min(99, (total > 0 ? Math.round((done / total) * 98) + 1 : 98)));
const merged = await PDFDocument.create();
for (const b of partBuffers) {
const src = await PDFDocument.load(b);
const pages = await merged.copyPages(src, src.getPageIndices());
pages.forEach(p => merged.addPage(p));
}
const mergedBytes = await merged.save();
// Final
ping('bereit zum Download', 99);
const filename = buildExportFilename('pdf', now);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`);
return res.send(Buffer.from(mergedBytes));
}
// === PUT ===
app.put('/api/notifications/:id', verifyToken, async (req, res) => {
const id = parseInt(req.params.id, 10);
/* ── gehört diese Regel dem anfragenden User? ───────────────────── */
const exists = await prisma.notificationRule.findUnique({
where : { id },
select: { userId: true },
});
if (!exists || exists.userId !== req.user.id) {
return res.status(404).json({ error: 'Not found' });
}
/* ── Body zerlegen: „emails“ darf nicht direkt ins Update ───────── */
const {
emails, // Array oder undefined
plates,
brand,
model,
camera,
timeFrom,
timeTo,
} = req.body;
try {
/* 1. Hauptfelder updaten ---------------------------------------- */
await prisma.notificationRule.update({
where: { id },
data : {
plates,
brand,
model,
camera,
timeFrom: timeFrom,
timeTo: timeTo,
},
});
/* 2. Recipients komplett ersetzen ------------------------------- */
if (Array.isArray(emails)) {
// (a) alte Empfänger entfernen
await prisma.notificationRecipient.deleteMany({ where: { ruleId: id } });
// (b) neue Empfänger anlegen
if (emails.length) {
await prisma.notificationRecipient.createMany({
data: emails.map((e) => ({ ruleId: id, email: e.trim() })),
});
}
} else {
// kein E-Mail-Array => aktuelle Regel direkt zurĂĽckgeben
const rule = await prisma.notificationRule.findUnique({
where : { id },
include: { recipients: { select: { email: true } } },
});
return res.json({
id : rule.id,
licensePattern : rule.plates,
brand : rule.brand,
model : rule.model,
camera : rule.camera,
timeFrom : rule.timeFrom,
timeTo : rule.timeTo,
recipients : rule.recipients.map((r) => r.email),
});
}
/* 3. Aktuelle Regel zurĂĽckgeben --------------------------------- */
const rule = await prisma.notificationRule.findUnique({
where : { id },
include: { recipients: { select: { email: true } } },
});
res.json({
id : rule.id,
licensePattern : rule.plates,
brand : rule.brand,
model : rule.model,
camera : rule.camera,
timeFrom : rule.timeFrom,
timeTo : rule.timeTo,
recipients : rule.recipients.map((r) => r.email),
});
} catch (err) {
console.error('❌ Fehler beim Aktualisieren der NotificationRule:', err);
res.status(500).json({ error: 'Update fehlgeschlagen' });
}
});
// âś… /api/admin/update-user/:id
app.put('/api/admin/update-user/:id', verifyToken, async (req, res) => {
if (!req.user?.isAdmin) return res.status(403).json({ error: 'Nicht autorisiert' });
const userId = req.params.id;
const { username, expiresAt, cameraAccess, features } = req.body;
try {
// Update Basisdaten
await prisma.user.update({
where: { id: userId },
data : { username, expiresAt: expiresAt ? new Date(expiresAt) : null },
});
// Kamera-Zugriffe ersetzen
await prisma.cameraAccess.deleteMany({ where: { userId } });
if (Array.isArray(cameraAccess) && cameraAccess.length) {
await prisma.cameraAccess.createMany({
data: cameraAccess.map(a => ({
camera: a.camera,
from : a.from ? new Date(a.from) : null,
to : a.to ? new Date(a.to) : null,
userId,
})),
});
}
// Features neu setzen + Entzug erkennen
if (Array.isArray(features)) {
const before = await prisma.userFeature.findMany({
where: { userId },
select: { feature: true },
});
const hadDownloads = before.some(f => f.feature === 'DOWNLOADS');
await prisma.userFeature.deleteMany({ where: { userId } });
const toCreate = features
.filter(f => ALLOWED_FEATURES.includes(f))
.map(f => ({ userId, feature: f }));
if (toCreate.length) await prisma.userFeature.createMany({ data: toCreate });
// Admins mĂĽssen DOWNLOADS behalten
const isTargetAdmin = await prisma.user.findUnique({ where: { id: userId }, select: { isAdmin: true } });
if (isTargetAdmin?.isAdmin) {
await prisma.userFeature.upsert({
where: { userId_feature_unique: { userId, feature: 'DOWNLOADS' } },
update: {},
create: { userId, feature: 'DOWNLOADS' },
});
}
// Nachher prĂĽfen
const after = await prisma.userFeature.findMany({
where: { userId },
select: { feature: true },
});
const hasDownloadsNow = after.some(f => f.feature === 'DOWNLOADS');
// âť— Bei Entzug sofort ausloggen (JWT wird ungĂĽltig gemacht, weil Client /api/logout aufruft)
if (hadDownloads && !hasDownloadsNow) {
pushLogout(userId, 'features-changed');
}
}
// Sofortige Abmeldung, falls Zugang jetzt abgelaufen
if (expiresAt && new Date(expiresAt) <= new Date()) {
pushLogout(userId, 'expired');
}
res.json({ success: true });
} catch (err) {
console.error('❌ Fehler beim Aktualisieren des Benutzers:', err);
res.status(500).json({ error: 'Aktualisierung fehlgeschlagen' });
}
});
// === DELETE ===
app.delete('/api/notifications/:id', verifyToken, async (req, res) => {
await prisma.notificationRule.delete({
where: { id: parseInt(req.params.id, 10), userId: req.user.id }
});
res.status(204).end();
});
app.delete('/api/admin/delete-user/:id', verifyToken, async (req, res) => {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Nicht autorisiert' });
}
const userId = req.params.id
try {
// Zuerst Kamera-Zugriffe löschen (wegen Foreign Key Constraints)
await prisma.cameraAccess.deleteMany({
where: { userId },
});
// Danach Benutzer löschen
await prisma.user.delete({
where: { id: userId },
});
res.json({ success: true });
} catch (err) {
console.error('❌ Fehler beim Löschen des Benutzers:', err);
res.status(500).json({ error: 'Benutzer konnte nicht gelöscht werden' });
}
});
// === GET ===
app.get('/api/protected', verifyToken, (req, res) => {
if (!req.user.isAdmin) return res.status(403).json({ error: 'Kein Zugriff' });
res.json({ data: 'Admin-Daten' });
});
app.get('/api/notifications', verifyToken, async (req, res) => {
const rows = await prisma.notificationRule.findMany({
where : { userId: req.user.id },
orderBy: { createdAt: 'desc' },
include: { recipients: { select: { email: true } } }
});
const data = rows.map(r => ({
id : r.id,
licensePattern : r.plates,
brand : r.brand,
model : r.model,
camera : r.camera,
timeFrom : r.timeFrom,
timeTo : r.timeTo,
recipients : r.recipients.map(x => x.email)
}));
res.json(data);
});
app.get('/api/unsubscribe', async (req, res) => {
const id = req.query.id?.toString(); // UUID (Token-ID)
const sig = req.query.sig?.toString(); // HMAC-Signatur
const redirectTo = new URL('/unsubscribe', FRONTEND_ORIGIN).toString();
const setStatus = (status, ruleId = '') => {
res.cookie('unsubscribeStatus', status, {
maxAge: 5 * 1000,
path: '/',
secure: true,
sameSite: 'none',
});
res.cookie('unsubscribeRule', ruleId.toString(), {
maxAge: 5 * 1000,
path: '/',
secure: true,
sameSite: 'none',
});
};
if (!id || !sig) {
setStatus('error');
return res.redirect(302, redirectTo);
}
try {
const record = await prisma.unsubscribeToken.findUnique({ where: { id } });
if (!record) {
setStatus('invalid');
return res.redirect(302, redirectTo);
}
const expectedSig = createUnsubscribeToken(record.ruleId, record.email);
const isValid = sig === expectedSig;
if (!isValid) {
setStatus('invalid');
return res.redirect(302, redirectTo);
}
const ruleId = record.ruleId;
/* ───────────────────────────── Rule-spezifische Austragung ───────────────────────────── */
if (ruleId !== null) {
const result = await prisma.$transaction(async (tx) => {
// Empfänger (nur diese E-Mail) bei dieser Regel entfernen
const del = await tx.notificationRecipient.deleteMany({
where: { ruleId, email: record.email },
});
// Prüfen, ob die Regel danach noch Empfänger hat
const remaining = await tx.notificationRecipient.count({ where: { ruleId } });
if (remaining === 0) {
// Aufräumen: erst Tokens für diese Regel löschen, dann die Regel selbst
await tx.unsubscribeToken.deleteMany({ where: { ruleId } });
await tx.notificationRule.delete({ where: { id: ruleId } });
// Falls der aktuelle Token noch existiert (kann in deleteMany enthalten sein), ignorieren wir das bewusst
return { removed: del.count, prunedRule: true };
} else {
// Nur den benutzten Token löschen
await tx.unsubscribeToken.delete({ where: { id: record.id } });
return { removed: del.count, prunedRule: false };
}
});
setStatus(result.removed > 0 ? 'success' : 'notfound', ruleId);
return res.redirect(302, redirectTo);
}
/* ───────────────────────────── Globale Austragung ─────────────────────────────
Entfernt diese E-Mail aus allen Regeln und löscht alle Regeln, die danach
keine Empfänger mehr haben. Räumt anschließend alle Tokens dieser E-Mail auf.
─────────────────────────────────────────────────────────────────────────────── */
const outcome = await prisma.$transaction(async (tx) => {
// 1) Alle Empfänger-Einträge dieser E-Mail entfernen
const del = await tx.notificationRecipient.deleteMany({ where: { email: record.email } });
// 2) Alle Regeln ohne Empfänger finden …
const emptyRules = await tx.notificationRule.findMany({
where: { recipients: { none: {} } },
select: { id: true },
});
const emptyIds = emptyRules.map(r => r.id);
// … und löschen (vorher zugehörige Tokens weg)
if (emptyIds.length) {
await tx.unsubscribeToken.deleteMany({ where: { ruleId: { in: emptyIds } } });
await tx.notificationRule.deleteMany({ where: { id: { in: emptyIds } } });
}
// 3) Alle Tokens dieser E-Mail entfernen (inkl. des aktuell genutzten)
await tx.unsubscribeToken.deleteMany({ where: { email: record.email } });
return { removed: del.count, prunedCount: emptyIds.length };
});
setStatus(outcome.removed > 0 ? 'success' : 'notfound', 'all');
return res.redirect(302, redirectTo);
} catch (err) {
console.error('❌ Fehler bei /api/unsubscribe:', err);
setStatus('error');
return res.redirect(302, redirectTo);
}
});
app.get('/api/cameras', verifyToken, async (req, res) => {
try {
let cameraNames = [];
if (req.user.isAdmin) {
const cameras = await prisma.recognition.findMany({
where: { cameraName: { not: null } },
distinct: ['cameraName'],
select: { cameraName: true },
});
cameraNames = cameras
.map((entry) => entry.cameraName)
.filter(Boolean);
} else {
cameraNames = req.user.cameraAccess
.map((access) => access.camera)
.filter(Boolean);
}
const uniqueSorted = [...new Set(cameraNames)].sort((a, b) => a.localeCompare(b));
res.json({ cameras: uniqueSorted });
} catch (err) {
console.error('❌ Fehler bei /api/cameras:', err);
res.status(500).json({ error: 'Kameras konnten nicht geladen werden' });
}
});
app.get('/api/me', async (req, res) => {
const token = req.cookies.token;
if (!token) return res.status(401).json({ error: 'Nicht eingeloggt' });
try {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('JWT_SECRET fehlt');
const raw = jwt.verify(token, secret); // kann string oder object sein
if (typeof raw !== 'object' || raw === null) {
throw new Error('UngĂĽltiger Token-Payload');
}
// raw ist nun ein Objekt – Felder defensiv auslesen
const id = String(raw.id);
const username = String(raw.username);
const isAdmin = Boolean(raw.isAdmin);
const exp = typeof raw.exp === 'number' ? raw.exp : undefined;
const dbUser = await prisma.user.findUnique({
where: { id }, // User.id ist String (cuid)
select: {
lastLogin: true,
expiresAt: true,
features: { select: { feature: true } }, // Enum-Werte holen
},
});
res.json({
id,
username,
isAdmin,
tokenExpiresAt: exp ? exp * 1000 : undefined,
lastLogin: dbUser?.lastLogin ? dbUser.lastLogin.toISOString() : null,
expiresAt: dbUser?.expiresAt ? dbUser.expiresAt.toISOString() : null,
features: dbUser?.features?.map(f => f.feature) ?? [], // -> ['DOWNLOADS']
});
} catch (err) {
console.error('GET /api/me error:', err);
res.status(401).json({ error: 'UngĂĽltiger Token' });
}
});
app.get('/api/recognitions', verifyToken, async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
//const search = req.query.search?.toString().trim().toLowerCase().replace(/\s+/g, '');
const searchRaw = req.query.search?.toString() ?? '';
const searchRawLower = searchRaw.trim().toLowerCase();
// Entfernt Bindestriche UND Whitespaces (robuster):
const searchNoSep = searchRawLower.replace(/[^a-z0-9]+/g, '');
const direction = req.query.direction?.toString();
const camera = req.query.camera?.toString()?.trim();
let timestampFrom = req.query.timestampFrom ? new Date(req.query.timestampFrom) : null;
let timestampTo = req.query.timestampTo ? new Date(req.query.timestampTo) : null;
if (timestampTo) {
timestampTo.setHours(23, 59, 59, 999);
}
// Nutzer & Kamera-Zugriffsrechte laden
const user = req.user;
if (!user || (!user.isAdmin && !user.cameraAccess.length)) {
return res.json({ data: [], page: 1, totalPages: 1, totalCount: 0 });
}
// Filter nach erlaubten Kameras + Zeiträumen
const accessFilters = user.cameraAccess.map(access => {
const timeFilter = {};
if (access.from) timeFilter.gte = access.from;
if (access.to) timeFilter.lte = access.to;
return {
cameraName: access.camera,
...(Object.keys(timeFilter).length ? { timestampLocal: timeFilter } : {}),
};
});
// Suchfilter
const searchFilters = [];
if (searchNoSep) {
// 1) Auf "license" ohne Trennzeichen suchen (DB-Feld ohne "-")
searchFilters.push({ license: { contains: searchNoSep } });
// 2) Optional zusätzlich auf licenseFormatted MIT Trennzeichen suchen
// (falls der Nutzer "D-AB" tippt und dein Feld so gespeichert ist)
searchFilters.push({ licenseFormatted: { contains: searchRawLower } });
// 3) Brand/Model normal (GroĂź-/Kleinschreibung ignorieren)
searchFilters.push(
{ brand: { contains: searchRawLower } },
{ model: { contains: searchRawLower } },
);
}
// Globaler Zeitfilter (aus Filterleiste)
const globalTimeFilter = {};
if (timestampFrom) globalTimeFilter.gte = timestampFrom;
if (timestampTo) globalTimeFilter.lte = timestampTo;
// Kombinierter Prisma-Filter
const where = {
AND: [
...(!user.isAdmin ? [{ OR: accessFilters }] : []),
...(searchFilters.length ? [{ OR: searchFilters }] : []),
...(Object.keys(globalTimeFilter).length ? [{ timestampLocal: globalTimeFilter }] : []),
...(direction === 'towards' || direction === 'away'
? [{ direction: { equals: capitalize(direction) } }]
: []),
...(camera ? [{ cameraName: camera }] : []),
],
};
const [entries, totalCount] = await Promise.all([
prisma.recognition.findMany({
where,
orderBy: { timestampLocal: 'desc' },
skip,
take: limit,
}),
prisma.recognition.count({ where }),
]);
res.json({
data: entries,
page,
totalPages: Math.ceil(totalCount / limit),
totalCount,
});
} catch (err) {
console.error('❌ Fehler bei /api/recognitions:', err);
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
app.get('/api/recognitions/stream', verifyToken, (req, res) => {
// 1) Verifizierter User steckt jetzt in req.user
const uid = String(req.user.id);
const username = String(req.user.username);
res.status(200);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('Access-Control-Allow-Origin', FRONTEND_ORIGIN);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.flushHeaders();
/* --- Map pflegen ------------------------------------------------------ */
if (!sseClients.has(uid)) sseClients.set(uid, new Set());
sseClients.get(uid).add(res);
console.log(`🟢 SSE-Client (${username}) verbunden`);
res.write(': connected\n\n');
/* --- Ping — egal ob du ihn brauchst oder nicht ------------------------ */
const ping = setInterval(() => {
res.write(`event: ping\ndata: ${Date.now()}\n\n`);
}, 30_000);
/* --- Aufräumen -------------------------------------------------------- */
req.on('close', () => {
clearInterval(ping);
sseClients.get(uid).delete(res);
if (sseClients.get(uid).size === 0) sseClients.delete(uid);
console.log(`đź”´ SSE-Client (${username}) getrennt`);
});
});
app.get('/api/recognitions/count', verifyToken, async (req, res) => {
const where = buildCameraAccessFilter(req.user.cameraAccess, req.user.isAdmin);
const count = await prisma.recognition.count({ where });
res.json({ count });
});
app.get('/api/recognitions/new-count', verifyToken, async (req, res) => {
const count = await prisma.recognition.count({
where: {
createdAt: { gt: lastResetTimestamp },
...buildCameraAccessFilter(req.user.cameraAccess, req.user.isAdmin),
},
});
res.json({ count });
});
app.get('/api/recognitions/by-camera', verifyToken, async (req, res) => {
try {
const isAdmin = req.user?.isAdmin;
const filter = isAdmin
? {}
: buildCameraAccessFilter(req.user.cameraAccess, req.user.isAdmin);
const result = await prisma.recognition.findMany({ where: filter, select: { cameraName: true } });
const counts = result.reduce((acc, { cameraName }) => {
if (!cameraName) return acc;
acc[cameraName] = (acc[cameraName] || 0) + 1;
return acc;
}, {});
const labels = Object.keys(counts);
const series = Object.values(counts);
res.json({ labels, series });
} catch (err) {
console.error('❌ Fehler bei /api/recognitions/by-camera:', err);
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
app.get('/api/recognitions/dates', verifyToken, async (req, res) => {
try {
const cameraFilter = req.query.camera?.toString();
// Admins: kein Filter notwendig
const isAdmin = req.user?.isAdmin;
const filter = isAdmin
? {}
: buildCameraAccessFilter(req.user.cameraAccess, req.user.isAdmin);
const recognitions = await prisma.recognition.findMany({
where: cameraFilter
? { cameraName: cameraFilter, ...filter }
: filter,
select: {
cameraName: true,
timestampLocal: true,
},
});
if (cameraFilter) {
const timestamps = recognitions.map(r => r.timestampLocal);
if (timestamps.length === 0) {
return res.json([]);
}
const startDate = isoDateLocal(new Date(Math.min(...timestamps.map(t => t.getTime()))));
const endDate = isoDateLocal(new Date(Math.max(...timestamps.map(t => t.getTime()))));
return res.json([
{
camera: cameraFilter,
startDate,
endDate,
}
]);
} else {
const grouped = new Map();
for (const entry of recognitions) {
const camera = entry.cameraName || 'Unbekannt';
if (!grouped.has(camera)) grouped.set(camera, []);
grouped.get(camera).push(entry.timestampLocal);
}
const result = Array.from(grouped.entries()).map(([camera, timestamps]) => {
const startDate = isoDateLocal(new Date(Math.min(...timestamps.map(t => t.getTime()))));
const endDate = isoDateLocal(new Date(Math.max(...timestamps.map(t => t.getTime()))));
return { camera, startDate, endDate };
});
res.json(result);
}
} catch (err) {
console.error('❌ Fehler bei /api/recognitions/dates:', err);
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
app.get('/api/recognitions/counts', verifyToken, async (req, res) => {
try {
const days = parseInt(req.query.days) || 7;
const today = new Date();
today.setHours(0, 0, 0, 0); // lokales 00:00
const fromDate = new Date(today);
fromDate.setDate(fromDate.getDate() - (days - 1));
const entries = await prisma.recognition.findMany({
where: {
timestampLocal: {
gte: fromDate,
lt: new Date(today.getTime() + 24 * 60 * 60 * 1000), // bis Ende heute
},
...buildCameraAccessFilter(req.user.cameraAccess, req.user.isAdmin),
},
select: { timestampLocal: true },
});
const countsMap = new Map();
for (const { timestampLocal } of entries) {
const key = localDateKey(timestampLocal); // âť— lokal statt ISO/UTC
countsMap.set(key, (countsMap.get(key) || 0) + 1);
}
const counts = [];
for (let i = 0; i < days; i++) {
const d = new Date(fromDate);
d.setDate(fromDate.getDate() + i);
const key = localDateKey(d); // âť— gleiche Logik
counts.push({ date: key, count: countsMap.get(key) || 0 });
}
res.json(counts);
} catch (err) {
console.error('❌ Fehler bei /api/recognitions/counts:', err);
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
// /api/recognitions/top10plates
app.get('/api/recognitions/top10plates', verifyToken, async (req, res) => {
try {
// Admins sehen alles; sonst Kamera-/Zeitfenster einschränken
const accessFilter = buildCameraAccessFilter(req.user.cameraAccess, req.user.isAdmin);
// 1) In der DB zählen (Top 10)
const grouped = await prisma.recognition.groupBy({
by: ['license'],
where: {
license: { not: '' },
// optional: wenn NULL ausgeschlossen werden soll
// license: { notIn: ['', null] as any },
...(req.user.isAdmin ? {} : accessFilter),
},
_count: { license: true },
orderBy: { _count: { license: 'desc' } },
take: 10,
});
// Falls keine Daten, direkt leere Antwort
if (!grouped.length) {
return res.json({ data: [] });
}
// 2) FĂĽr diese Top-Kennzeichen die *neueste* Marke/Modell holen
const topPlates = grouped.map(g => g.license);
const latestMeta = await prisma.recognition.findMany({
where: {
license: { in: topPlates },
...(req.user.isAdmin ? {} : accessFilter),
},
select: { license: true, brand: true, model: true, timestampLocal: true },
orderBy: { timestampLocal: 'desc' }, // neueste zuerst
});
// 3) Pro Kennzeichen die erste (neueste) Meta-Zeile nehmen
const metaByPlate = new Map();
for (const r of latestMeta) {
if (!metaByPlate.has(r.license)) {
metaByPlate.set(r.license, {
brand: r.brand ?? null,
model: r.model ?? null,
});
}
// danach ignorieren wir ältere Einträge für dasselbe Kennzeichen
}
// 4) Antwort zusammenbauen (Reihenfolge bereits nach Count desc)
const data = grouped.map(g => ({
plate: g.license,
count: g._count.license,
brand: metaByPlate.get(g.license)?.brand ?? null,
model: metaByPlate.get(g.license)?.model ?? null,
}));
res.json({ data });
} catch (error) {
console.error('❌ Fehler bei /api/recognitions/top10plates:', error);
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
app.get('/api/recognitions/top10brands', verifyToken, async (req, res) => {
try {
const result = await prisma.recognition.findMany({
where: {
brand: { not: null },
...buildCameraAccessFilter(req.user.cameraAccess, req.user.isAdmin)
},
select: { brand: true },
});
const brandCounts = {};
for (const { brand } of result) {
if (!brand) continue;
brandCounts[brand] = (brandCounts[brand] || 0) + 1;
}
// Sortieren nach Häufigkeit (absteigend)
const sorted = Object.entries(brandCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10); // Nur Top 10
const labels = sorted.map(([brand]) => brand);
const series = sorted.map(([_, count]) => count);
res.json({ labels, series });
} catch (err) {
console.error('❌ Fehler bei /api/recognitions/top10brands:', err);
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
app.get('/api/recognitions/countries', verifyToken, async (req, res) => {
try {
const result = await prisma.recognition.findMany({
where: {
country: { not: null },
...buildCameraAccessFilter(req.user.cameraAccess, req.user.isAdmin)
},
select: { country: true }
});
const counts = result.reduce((acc, { country }) => {
if (!country) return acc;
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {});
const labels = Object.keys(counts);
const series = Object.values(counts);
res.json({ labels, series });
} catch (err) {
console.error('❌ Fehler bei /api/recognitions/countries:', err);
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
app.get('/api/recognitions/by-hour', verifyToken, async (req, res) => {
try {
const result = await prisma.recognition.findMany({
where: buildCameraAccessFilter(req.user.cameraAccess, req.user.isAdmin),
select: {
timestampLocal: true,
},
});
const hourCounts = Array(24).fill(0);
for (const entry of result) {
const hour = entry.timestampLocal.getHours();
hourCounts[hour]++;
}
const labels = Array.from({ length: 24 }, (_, i) => {
const pad = (n) => n.toString().padStart(2, '0');
return `${pad(i)}:00–${pad((i + 1) % 24)}:00`;
});
res.json({ labels, series: hourCounts });
} catch (err) {
console.error('❌ Fehler bei /api/recognitions/by-hour:', err);
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
app.get('/images/:filename', verifyToken, async (req, res) => {
const filename = req.params.filename;
const imagePath = await findImageFile(filename);
if (!imagePath || !fs.existsSync(imagePath)) {
return res.status(404).send('Bild nicht gefunden.');
}
try {
const data = await fs.promises.readFile(imagePath);
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Cache-Control', 'public, max-age=31536000');
res.send(data);
} catch (err) {
console.error('❌ Fehler beim Lesen der Datei:', err);
res.status(500).send('Fehler beim Laden des Bildes.');
}
});
app.get('/api/admin/users', verifyToken, async (req, res) => {
if (!req.user?.isAdmin) {
return res.status(403).json({ error: 'Nicht autorisiert' });
}
try {
const rows = await prisma.user.findMany({
orderBy: { username: 'asc' },
select: {
id: true,
username: true,
isAdmin: true,
expiresAt: true,
lastLogin: true,
cameraAccess: {
select: {
id: true,
camera: true,
from: true,
to: true,
},
},
features: { select: { feature: true } },
},
});
// Normalisieren (ISO-Strings oder null) – passend zu deinem Frontend-Typ UserWithAccess
const users = rows.map((u) => ({
id: u.id,
username: u.username,
isAdmin: u.isAdmin,
expiresAt: u.expiresAt ? u.expiresAt.toISOString() : null,
lastLogin: u.lastLogin ? u.lastLogin.toISOString() : null,
cameraAccess: u.cameraAccess.map((c) => ({
id: c.id,
camera: c.camera,
from: c.from ? c.from.toISOString() : null,
to: c.to ? c.to.toISOString() : null,
})),
features: u.features.map(f => f.feature),
}));
res.json({ users });
} catch (err) {
console.error('Fehler beim Laden der Benutzer:', err);
res.status(500).json({ error: 'Fehler beim Laden der Benutzer' });
}
});
// === HTTPS-Server starten ===
const keyPath = path.resolve(__dirname, 'certs', 'myRoot.key');
const certPath = path.resolve(__dirname, 'certs', 'myRoot.crt');
const sslOptions = {
key: fs.readFileSync(keyPath),
cert: fs.readFileSync(certPath),
};
https.createServer(sslOptions, app).listen(API_PORT, API_BIND, () => {
console.log(`✅ HTTPS-Server läuft auf https://${API_BIND}:${API_PORT}`);
});
// === XML-Parser und DateiĂĽberwachung ===
const parser = new XMLParser({ ignoreAttributes: false });
const fileQueue = [];
let processing = false;
// === Watcher starten ===
chokidar.watch(WATCH_PATH, {
usePolling: true,
interval: 1000,
binaryInterval: 3000,
alwaysStat: true,
ignored: /^\./,
persistent: true,
ignoreInitial: true,
depth: 99,
}).on('add', (filePath) => {
if (filePath.endsWith('_Info.xml')) enqueueFile(filePath);
});
// === Initial-Import ===
cleanUpExpiredTokens();
setInterval(cleanUpExpiredTokens, 24 * 60 * 60 * 1000); // alle 24 Stunden
findAllXmlFiles(WATCH_PATH);