diff --git a/backend/server.js b/backend/server.js index 378c603..10729e6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,4 +1,4 @@ -// server.js +// backend\server.js const fs = require('fs'); const path = require('path'); const express = require('express'); @@ -72,6 +72,12 @@ let lastResetTimestamp = new Date(); // === Funktionen === +function endOfDay(d) { + const x = new Date(d); + x.setHours(23, 59, 59, 999); + return x; +} + function pushLogout(userId, reason = 'expired') { const set = sseClients.get(userId); if (!set) return; @@ -393,6 +399,36 @@ async function findImageFile(filename) { return searchInDir(WATCH_PATH); } +async function findSidecarXmlFromMediaFilename(filename) { + if (!filename) return null; + + // Basisname aus Bild oder Plate ableiten: + // - "abc123.jpg" -> "abc123_Info.xml" + // - "abc123_plate.jpg" -> "abc123_Info.xml" + const parsed = path.parse(filename); + const base = parsed.name.replace(/_plate$/i, ''); // _plate abschneiden + const xmlFilename = `${base}_Info.xml`; + + function searchInDir(dir) { + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + const found = searchInDir(full); + if (found) return found; + } else if (entry.isFile() && entry.name === xmlFilename) { + return full; + } + } + } catch { /* still */ } + return null; + } + + return searchInDir(WATCH_PATH); +} + + async function processFile(filePath) { try { const xml = fs.readFileSync(filePath, 'utf-8'); @@ -1545,6 +1581,140 @@ app.put('/api/admin/update-user/:id', verifyToken, async (req, res) => { } }); +// ✅ Admin: Erkennungen löschen (mit Vorschau / Dry-Run + Progress über SSE) +// Body: { camera?: string, from?: string, to?: string, dryRun?: boolean, alsoFiles?: boolean, clientJobId?: string } +app.post('/api/admin/recognitions/purge', verifyToken, async (req, res) => { + if (!req.user?.isAdmin) return res.status(403).json({ error: 'Nicht autorisiert' }); + + try { + let { camera, from, to, dryRun = true, alsoFiles = false, clientJobId } = req.body || {}; + + const where = {}; + if (camera && typeof camera === 'string' && camera.trim()) { + where.cameraName = camera.trim(); + } + if (from || to) { + where.timestampLocal = {}; + if (from) where.timestampLocal.gte = new Date(from); + if (to) where.timestampLocal.lte = endOfDay(to); + } + + if (!where.cameraName && !where.timestampLocal) { + return res.status(400).json({ error: 'Bitte mindestens Kamera oder Zeitraum angeben.' }); + } + + // Vorab: Kandidaten einsammeln (IDs & Dateien), damit wir Batches + Files managen können + const rows = await prisma.recognition.findMany({ + where, + select: { id: true, imageFile: true, plateFile: true } + }); + + if (dryRun) { + console.log(`🧪 [purge:preview] user=${req.user.username} camera=${camera ?? 'ALL'} from=${from ?? '-'} to=${to ?? '-'} matches=${rows.length}`); + return res.json({ + dryRun: true, + matchCount: rows.length, + sampleIds: rows.slice(0, 10).map(r => r.id), + camera: camera ?? null, + from: from ?? null, + to: to ?? null + }); + } + + const userId = String(req.user.id); + const jobId = clientJobId || crypto.randomUUID(); + const total = rows.length; + + console.log(`🧨 [purge:start] job=${jobId} user=${req.user.username} camera=${camera ?? 'ALL'} from=${from ?? '-'} to=${to ?? '-'} alsoFiles=${!!alsoFiles} total=${total}`); + + // Schnell-Fall: nichts zu löschen + if (total === 0) { + emitToUser(userId, 'purge-progress', { jobId, stage: 'nichts zu löschen', done: 0, total: 0, progress: 100, filesDeleted: 0 }); + console.log(`✅ [purge:done] job=${jobId} deleted=0 files=0`); + return res.json({ dryRun: false, deletedCount: 0, filesDeleted: 0 }); + } + + let filesDeleted = 0; + let done = 0; + const BATCH = 500; + const t0 = Date.now(); + + const ping = (stage, overrideProgress) => { + const computed = total > 0 ? Math.round((done / total) * 98) : 1; // bis 98% + const progress = Math.max(1, Math.min(99, overrideProgress ?? computed)); + emitToUser(userId, 'purge-progress', { jobId, stage, done, total, progress, filesDeleted }); + }; + + ping('starte…', 1); + + for (let i = 0; i < rows.length; i += BATCH) { + const slice = rows.slice(i, i + BATCH); + const ids = slice.map(r => r.id); + + // DB: diesen Batch löschen + const delRes = await prisma.recognition.deleteMany({ where: { id: { in: ids } } }); + done += delRes.count; + + // Dateien: best-effort (außerhalb der DB-Transaktion) + if (alsoFiles) { + for (const r of slice) { + const deletedPaths = new Set(); + + // Bilder (Full + Plate) + for (const fname of [r.imageFile, r.plateFile]) { + if (!fname) continue; + try { + const abs = await findImageFile(fname); + if (abs && fs.existsSync(abs) && !deletedPaths.has(abs)) { + await fs.promises.unlink(abs); + filesDeleted += 1; + deletedPaths.add(abs); + } + } catch { /* ignore */ } + } + + // XML-Sidecar (abgeleitet vom Bild-/Plate-Namen) + for (const candidate of [r.imageFile, r.plateFile]) { + if (!candidate) continue; + try { + const xmlAbs = await findSidecarXmlFromMediaFilename(candidate); + if (xmlAbs && fs.existsSync(xmlAbs) && !deletedPaths.has(xmlAbs)) { + await fs.promises.unlink(xmlAbs); + filesDeleted += 1; + deletedPaths.add(xmlAbs); + } + } catch { /* ignore */ } + } + } + } + + // Fortschritt + Konsole + ping('lösche…'); + if (i === 0 || i + BATCH >= rows.length || (i / BATCH) % 2 === 0) { + const pct = Math.round((done / total) * 100); + console.log(`… [purge:progress] job=${jobId} ${done}/${total} (${pct}%) files=${filesDeleted}`); + } + + // Event-Loop freigeben, damit SSE sofort rausgeht + await new Promise(r => setImmediate(r)); + } + + ping('fertig', 99); + const ms = Date.now() - t0; + console.log(`✅ [purge:done] job=${jobId} deleted=${done} files=${filesDeleted} in ${ms}ms`); + + return res.json({ + dryRun: false, + deletedCount: done, + filesDeleted + }); + } catch (err) { + console.error('❌ purge error:', err); + return res.status(500).json({ error: 'Löschen fehlgeschlagen' }); + } +}); + + // === DELETE === app.delete('/api/notifications/:id', verifyToken, async (req, res) => { diff --git a/frontend/src/app/(protected)/admin/page.tsx b/frontend/src/app/(protected)/admin/page.tsx index c47a66d..75e92cd 100644 --- a/frontend/src/app/(protected)/admin/page.tsx +++ b/frontend/src/app/(protected)/admin/page.tsx @@ -1,11 +1,15 @@ +// frontend\src\app\(protected)\admin\page.tsx + 'use client'; import UserForm from '../../components/UserForm'; import UserTable from '../../components/UserTable'; +import AdminPurgePanel from '../../components/AdminPurgePanel'; export default function Administration() { return (
+ {}} />
diff --git a/frontend/src/app/(protected)/downloads/page.tsx b/frontend/src/app/(protected)/downloads/page.tsx index 5a6a3d5..8e003d1 100644 --- a/frontend/src/app/(protected)/downloads/page.tsx +++ b/frontend/src/app/(protected)/downloads/page.tsx @@ -5,6 +5,7 @@ import path from "path"; import ChecksumCopy from "@/app/components/ChecksumCopy"; import crypto from "crypto"; import { createReadStream } from "fs"; +import Alert from "@/app/components/Alert"; export const runtime = "nodejs"; // optional: Seiten-Caching (Inhalte werden eh per mtime-Cache beschleunigt) @@ -83,14 +84,24 @@ async function withAutoChecksums(fw: Firmware): Promise { /* ---------- Typen ---------- */ +type AlertColor = 'dark' | 'secondary' | 'info' | 'success' | 'danger' | 'warning' | 'light'; + +type Notice = { + title?: string; + message: string; + type?: 'solid' | 'soft'; + color?: AlertColor; +}; + type Firmware = { version: string; date: string; // ISO-Datum url: string; - releaseNotesUrl?: string; // <-- NEU: Link zur PDF + releaseNotesUrl?: string; notes?: string; // (legacy, falls alte JSONs noch Text haben) checksumSha256?: string; checksumMD5?: string; + notice?: Notice; }; type Doc = { @@ -98,11 +109,13 @@ type Doc = { url: string; lang?: string; kind?: string; + notice?: Notice; }; type DownloadsFile = { firmwares: Firmware[]; documents: Doc[]; + globalNotice?: Notice; }; /* ---------- Utils ---------- */ @@ -163,6 +176,15 @@ export default async function DownloadPage() {

+ {data.globalNotice && ( + + )} + {/* 2-Spalten-Layout */}
{/* Firmware (links) */} @@ -233,10 +255,18 @@ export default async function DownloadPage() { {/* ⬇️ SHA/MD5: klickbar & kopierbar */} {(latest.checksumSha256 || latest.checksumMD5) && ( -
- - -
+
+ + +
+ )} + {latest.notice && ( + )}
) : ( @@ -249,53 +279,80 @@ export default async function DownloadPage() { Ältere Versionen anzeigen -