kennzeichen/backend/server.js
2025-11-10 07:12:06 +01:00

2322 lines
77 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = `
<p>Hallo ${rule.user.username},</p>
<p>es gibt einen neuen Treffer für deine Regel #${rule.id}:</p>
<ul>
<li>Kennzeichen: <b>${rec.licenseFormatted ?? rec.license}</b></li>
${rec.brand ? `<li>Marke: ${rec.brand}</li>` : ''}
${rec.model ? `<li>Modell: ${rec.model}</li>` : ''}
<li>Kamera: ${rec.cameraName || ''}</li>
<li>Zeit: ${new Date(rec.timestampLocal).toLocaleString('de-DE')}</li>
</ul>
<p style="color:#888; font-size: 10px">
Diese Mail wurde automatisch generiert. Bitte nicht antworten.<br>
Du möchtest keine E-Mails mehr zu <strong>dieser Regel</strong>?<br>
<a href="${unsubscribeLink}">Hier austragen</a><br><br>
Du möchtest dich <strong>von allen Benachrichtigungen</strong> abmelden?<br>
<a href="${globalUnsubscribeLink}">Alle Regeln abbestellen</a><br><br>
Die Links sind 30 Tage gültig.
</p>
`;
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:0017:00
} else {
if (nowMinutes > to && nowMinutes < from) return false; // über Mitternacht, z.B. 22:0006: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<string>
} = 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.<ext>
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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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 = `
<header class="head">
<div class="head-left">
<p><b><span>PP Düsseldorf</span></b></p>
<p><b><span>Dir. GE / Spezialeinheiten</span></b></p>
<p><b><span>Technische Einsatzgruppe</span></b></p>
</div>
<div class="head-logo"><img src="${escapeHtml(logoSrc)}" alt="Logo" /></div>
</header>
`;
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)
? `<div><span class="muted">Von:</span> ${escapeHtml(fmtDate(minTs))}</div>
<div><span class="muted">Bis:</span> ${escapeHtml(fmtDate(maxTs))}</div>` : '';
const coverHtml = `
<section class="page cover">
${headerHtml}
<div class="cover-inner">
<h1>Kennzeichenerfassung Export</h1>
<dl class="meta meta--stack">
<div class="row"><dt>Erstellt von</dt><dd>${escapeHtml(req.user?.username || 'Unbekannt')}</dd></div>
<div class="row"><dt>Erstellt am</dt><dd>${escapeHtml(fmtDate(now))}</dd></div>
<div class="row"><dt>Anzahl Einträge</dt><dd>${rows.length.toLocaleString('de-DE')}</dd></div>
<div class="row"><dt>Zeitraum</dt><dd class="dd--stack">${rangeHtml}</dd></div>
</dl>
</div>
</section>
`;
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 => `
<tr><th>${escapeHtml(labelOf[k])}</th><td>${escapeHtml(String(valueFor(r,k)))}</td></tr>
`).join('');
chunks.push(`
${headerHtml}
<section class="page">
<h2 class="center">Erkennung #${r.id}</h2>
<div class="photo-wrap">
<img class="photo" src="${escapeHtml(platePhotoSrc)}" alt="Plate ${escapeHtml(r.licenseFormatted || r.license || String(r.id))}" />
<img class="photo" src="${escapeHtml(fullPhotoSrc)}" alt="Full ${escapeHtml(r.licenseFormatted || r.license || String(r.id))}" />
</div>
<div class="details"><table><tbody>${detailRows}</tbody></table></div>
</section>
`);
// 🔹 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 `<!doctype html><html lang="de"><head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kennzeichenerfassung Export</title><style>${style}</style>
</head><body>${body}</body></html>`;
}
// ===== 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<string> 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);