/* * Express-Server der Projektübersicht: liefert die statischen Frontend-Dateien aus * public/ (Test.html unter "/") aus und stellt den Frontend-Modulen unter public/js/ * eine CSV-API zum Laden und Speichern der Sitzungsdaten in sessions/ bereit. * Standardport ist 3003, überschreibbar über die Umgebungsvariable PORT. * Endpunkte: GET /, GET /health, GET /load-all, POST /save-csv, POST /delete-csv. * Die Daten werden als UTF-8-CSV mit BOM geschrieben; ungültige Abteilungen fallen * auf ELT zurück, Dateinamen werden über safeName() streng validiert. */ const express = require('express'); const fs = require('fs'); const path = require('path'); const app = express(); // Wichtige Konstanten: UTF-8-BOM für CSV-Dateien, Serverport (PORT-Env) und die gültigen Abteilungsordner const BOM = Buffer.from([0xEF, 0xBB, 0xBF]); const PORT = process.env.PORT || 3003; const VALID_DEPARTMENTS = new Set(['ELT','HLS','ARCH']); // Globale Middleware: CORS, JSON-Body-Limit (2 MB) und statisches Serving von public/ ohne Cache für Skripte/Styles app.use(require('cors')()); app.use(express.json({ limit: '2mb' })); app.use(express.static(path.join(__dirname, 'public'), { setHeaders: (res, path) => { if(path.endsWith('.js') || path.endsWith('.css') || path.endsWith('.html')){ res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); res.set('Pragma', 'no-cache'); res.set('Expires', '0'); } } })); // Datenverzeichnis sessions/ bei Bedarf anlegen const SESSIONS_DIR = path.join(__dirname, 'sessions'); if(!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true }); // Erlaubt ausschliesslich sichere CSV-Dateinamen im Format .csv function safeName(name){ const raw = String(name || ''); // Pfad-Trennzeichen und Verzeichnisnavigation ablehnen if(/[\\/]|\.\./.test(raw)) return null; const base = path.basename(raw); if(!base || base.toLowerCase() === '.csv') return null; const safe = base.replace(/[^a-zA-Z0-9_\-\.]/g,'_'); if(!/^([a-zA-Z0-9_\-]+)\.csv$/i.test(safe)) return null; return safe; } // Liefert den Zielordner für eine Abteilung (nur ELT|HLS|ARCH, sonst ELT) und legt ihn bei Bedarf an function departmentDir(department){ const dept = (typeof department === 'string' && VALID_DEPARTMENTS.has(department)) ? department : 'ELT'; const dir = path.join(SESSIONS_DIR, dept); if(!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); return dir; } // Route: Speichert eine CSV-Datei (mit UTF-8-BOM) für eine Abteilung im sessions/-Ordner app.post('/save-csv', (req, res) => { const { filename, csv, department } = req.body || {}; if(!filename || typeof csv !== 'string') return res.status(400).json({ ok: false, error: 'Missing filename or csv' }); const safe = safeName(filename); if(!safe) return res.status(400).json({ ok: false, error: 'Invalid filename' }); const dir = departmentDir(department); const out = path.join(dir, safe); const data = Buffer.concat([BOM, Buffer.from(csv, 'utf8')]); fs.writeFile(out, data, (err) => { if(err){ console.error('Write error', err); return res.status(500).json({ ok:false, error: String(err) }); } console.log('Saved CSV:', out); return res.json({ ok: true, path: out }); }); }); // Route: Löscht eine CSV-Datei einer Abteilung aus dem sessions/-Ordner (fehlende Datei wird toleriert) app.post('/delete-csv', (req, res) => { const { filename, department } = req.body || {}; if(!filename) return res.status(400).json({ ok: false, error: 'Missing filename' }); const safe = safeName(filename); if(!safe) return res.status(400).json({ ok: false, error: 'Invalid filename' }); const dir = departmentDir(department); const out = path.join(dir, safe); fs.unlink(out, (err) => { if(err){ if(err.code === 'ENOENT') { console.log('CSV-Datei nicht gefunden:', out); return res.json({ ok: true, missing: true }); } console.error('Delete error', err); return res.status(500).json({ ok:false, error: String(err) }); } console.log('Deleted CSV:', out); return res.json({ ok: true }); }); }); // Route: Lädt alle Sitzungs-CSV-Dateien einer Abteilung plus _people.csv (nach Dateiname sortiert) app.get('/load-all', (req, res) => { const dir = departmentDir(req.query.department); fs.readdir(dir, (err, files) => { if(err){ console.error('Read error', err); return res.status(500).json({ ok:false, error: String(err) }); } const csvFiles = files.filter(f => f.toLowerCase().endsWith('.csv')); const result = { ok: true, sessions: [], people: null }; csvFiles.forEach(filename => { if(filename.toLowerCase() === '_people.csv'){ result.people = fs.readFileSync(path.join(dir, filename), 'utf8'); return; } const content = fs.readFileSync(path.join(dir, filename), 'utf8'); result.sessions.push({ filename, content }); }); // Sortiere Sitzungen nach Dateiname, damit die Reihenfolge stabil ist result.sessions.sort((a,b) => a.filename.localeCompare(b.filename)); res.json(result); }); }); // Route: Healthcheck-Endpunkt für Docker/Überwachung app.get('/health', (req,res)=> res.json({ok:true})); // Route: Liefert die Hauptseite Test.html app.get('/', (req,res)=>{ res.sendFile(path.join(__dirname, 'public', 'Test.html')); }); // Gültige Tab-Slugs für Deep-Link-Routen (muss zu TAB_SLUGS in js/globals.js passen) const VALID_TAB_SLUGS = new Set(['Projektübersicht','Auslastung','Projekte','Projektbeteiligte']); // Slug der Projektplan-Seite (muss zu PLAN_SLUG in js/globals.js passen) const PLAN_SLUG = 'Projektplan'; // Route: Deep-Links je Abteilung + Tab, z. B. "/HLS/Auslastung" oder // "/ELT/Projektübersicht". Liefert wie "/" die Hauptseite; unbekannte // Abteilungen/Tabs werden abgelehnt (404), damit keine Test.html auf beliebigen // Pfaden ausgeliefert wird. app.get('/:department/:tab', (req, res) => { const { department, tab } = req.params; if(!VALID_DEPARTMENTS.has(department)) return res.status(404).send('Nicht gefunden'); if(tab === PLAN_SLUG){ // Projektplan-Seite (GANTT), z. B. "/HLS/Projektplan?weekLabel=..." return res.sendFile(path.join(__dirname, 'public', 'project-plan.html')); } if(!VALID_TAB_SLUGS.has(tab)) return res.status(404).send('Nicht gefunden'); res.sendFile(path.join(__dirname, 'public', 'Test.html')); }); // Route: Projektplan-Seite mit Projektname im Pfad, z. B. // "/HLS/Projektplan/Gymnasium%20Hemer?weekLabel=..." – das Pfad-Segment dient // nur der lesbaren Adresse, die Identifikation erfolgt über weekLabel/projNr. app.get('/:department/Projektplan/:projName', (req, res) => { if(!VALID_DEPARTMENTS.has(req.params.department)) return res.status(404).send('Nicht gefunden'); res.sendFile(path.join(__dirname, 'public', 'project-plan.html')); }); // Serverstart mit Fehlerbehandlung (z.B. Port bereits belegt) const server = app.listen(PORT, ()=> console.log(`CSV-Session server running on http://localhost:${PORT}`)); server.on('error', (err) => { console.error('Server konnte nicht starten:', err.message); if(err.code === 'EADDRINUSE'){ console.error(`Port ${PORT} ist bereits belegt. Bitte einen anderen PORT verwenden, z.B. PORT=3004 npm start`); } process.exit(1); });