const http = require('http'); const fs = require('fs'); const path = require('path'); const url = require('url'); const zlib = require('zlib'); const PORT = 80; const HOST = '0.0.0.0'; // Wichtig: IPv4 binden const PUBLIC_DIR = path.join(__dirname, 'public'); // ./app/public const STATUS_FILE = path.join(PUBLIC_DIR, 'data', 'status.json'); const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.mjs': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8', '.map': 'application/json; charset=utf-8' }; const SECURITY_HEADERS = { 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'SAMEORIGIN', 'Referrer-Policy': 'strict-origin-when-cross-origin', // Allow same-origin scripts and styles; adjust if you load external assets 'Content-Security-Policy': "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self';" }; // Helper: safe join under PUBLIC_DIR (avoid path traversal) function resolvePublicPath(requestPath) { const clean = path.normalize(requestPath).replace(/^(\.\.[/\\])+/, ''); const absolutePath = path.join(PUBLIC_DIR, clean); if (!absolutePath.startsWith(PUBLIC_DIR)) return null; return absolutePath; } // Helper: simple logger function log(req, res, extra = '') { const ts = new Date().toISOString(); console.log(`[${ts}] ${req.method} ${req.url} -> ${res.statusCode} ${extra}`); } // Helper: send JSON function sendJSON(res, statusCode, obj) { const buf = Buffer.from(JSON.stringify(obj, null, 2), 'utf-8'); res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...SECURITY_HEADERS }); res.end(buf); } // Helper: gzip if accepted function maybeCompress(req, res, contentType) { const accept = req.headers['accept-encoding'] || ''; const supportsGzip = /\bgzip\b/.test(accept); if (!supportsGzip) return null; res.setHeader('Content-Encoding', 'gzip'); res.setHeader('Vary', 'Accept-Encoding'); return zlib.createGzip(); } // Serve static files from /public function serveStatic(req, res, pathname) { // Default to index.html for "/" and for directory paths let targetPath = pathname === '/' ? '/index.html' : pathname; // Prevent directory traversal and map to filesystem const abs = resolvePublicPath(targetPath); if (!abs) { res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8', ...SECURITY_HEADERS }); res.end('Bad request'); return; } // If the path maps to a directory, try index.html inside it let stat; try { stat = fs.statSync(abs); if (stat.isDirectory()) { targetPath = path.join(targetPath, 'index.html'); const absIndex = resolvePublicPath(targetPath); if (!absIndex || !fs.existsSync(absIndex)) { res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8', ...SECURITY_HEADERS }); res.end('Forbidden'); return; } return streamFile(req, res, absIndex); } } catch { // Fallback: try adding .html if no extension and file missing const ext = path.extname(abs); if (!ext) { const htmlCandidate = `${abs}.html`; if (fs.existsSync(htmlCandidate)) { return streamFile(req, res, htmlCandidate); } } // 404 if not found res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', ...SECURITY_HEADERS }); res.end('Not found'); return; } // File exists—stream it return streamFile(req, res, abs); } // Stream file with basic headers and optional gzip function streamFile(req, res, absFile) { const ext = path.extname(absFile).toLowerCase(); const contentType = MIME[ext] || 'application/octet-stream'; const stat = fs.statSync(absFile); // Cache policy: static assets get short cache; HTML no-cache to help dev const isHTML = contentType.startsWith('text/html'); const cacheControl = isHTML ? 'no-cache' : 'public, max-age=60'; // ETag (weak): size + mtime const etag = `W/"${stat.size}-${stat.mtimeMs.toFixed(0)}"`; if (req.headers['if-none-match'] === etag) { res.writeHead(304, { 'ETag': etag, 'Cache-Control': cacheControl, ...SECURITY_HEADERS }); res.end(); return; } res.setHeader('Content-Type', contentType); res.setHeader('Content-Length', stat.size); res.setHeader('ETag', etag); res.setHeader('Cache-Control', cacheControl); Object.entries(SECURITY_HEADERS).forEach(([k, v]) => res.setHeader(k, v)); // Gzip if accepted (skip for already compressed types like images) const maybeGzip = (/\btext\/|application\/(javascript|json|xml)/.test(contentType)) ? maybeCompress(req, res, contentType) : null; const stream = fs.createReadStream(absFile); stream.on('error', (err) => { console.error('[static] stream error:', err); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8', ...SECURITY_HEADERS }); } res.end('Internal server error'); }); if (maybeGzip) { stream.pipe(maybeGzip).pipe(res); } else { stream.pipe(res); } } const server = http.createServer((req, res) => { const parsed = url.parse(req.url, true); const pathname = parsed.pathname || '/'; // Minimal access log (only path, no query) res.on('finish', () => log(req, res)); // CORS for API (read-only) if (pathname.startsWith('/api/')) { // Allow GET from any origin (adjust if needed) res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (pathname === '/api/status') { fs.readFile(STATUS_FILE, 'utf-8', (err, json) => { if (err) { console.error('[api] status read error:', err); sendJSON(res, 500, { error: 'status_unavailable' }); return; } // Validate JSON and return try { const data = JSON.parse(json); sendJSON(res, 200, data); } catch (e) { console.error('[api] status parse error:', e); sendJSON(res, 500, { error: 'status_invalid_json' }); } }); return; } sendJSON(res, 404, { error: 'not_found' }); return; } // Serve static from /public serveStatic(req, res, pathname); }); server .listen(PORT, HOST, () => { console.log(`Node.js server listening on http://${HOST}:${PORT}`); console.log(`Serving static files from: ${PUBLIC_DIR}`); console.log(`Status file expected at: ${STATUS_FILE}`); }) .on('error', (err) => { console.error('[server] listen error:', err); });