217 lines
7.0 KiB
JavaScript
Executable File
217 lines
7.0 KiB
JavaScript
Executable File
|
|
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);
|
|
});
|