Files
projektuebersicht/server.js
T

161 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
/*
* 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 <Name>.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);
});