Files
2026-10-31 15:11:21 -04:00

175 lines
8.1 KiB
JavaScript
Executable File
Raw Permalink 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.
import { createServer } from 'node:http';
import { realpath as portableRealpath } from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
// fs.promises.realpath uses the native API, which fails on some Windows SMB drives.
// The portable implementation still resolves every symlink; never fall back to resolve().
const realpath = promisify(portableRealpath);
const PUBLIC_ROOT = fileURLToPath(new URL('../htdocs/', import.meta.url));
const INCLUDES = new Map([
['<!--#include virtual="/common/header.html" -->', 'common/header.html'],
['<!--#include virtual="/common/topnav.html" -->', 'common/topnav.html'],
['<!--#include virtual="/common/footer.html" -->', 'common/footer.html'],
]);
const MIME = new Map([
['.html', 'text/html; charset=utf-8'], ['.htm', 'text/html; charset=utf-8'],
['.css', 'text/css; charset=utf-8'], ['.js', 'text/javascript; charset=utf-8'],
['.mjs', 'text/javascript; charset=utf-8'], ['.json', 'application/json; charset=utf-8'],
['.txt', 'text/plain; charset=utf-8'], ['.xml', 'application/xml; charset=utf-8'],
['.svg', 'image/svg+xml'], ['.ico', 'image/x-icon'], ['.png', 'image/png'],
['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'],
['.webp', 'image/webp'], ['.avif', 'image/avif'], ['.woff', 'font/woff'],
['.woff2', 'font/woff2'], ['.ttf', 'font/ttf'], ['.otf', 'font/otf'],
['.mp3', 'audio/mpeg'], ['.ogg', 'audio/ogg'], ['.wav', 'audio/wav'],
['.mp4', 'video/mp4'], ['.webm', 'video/webm'], ['.pdf', 'application/pdf'],
['.wasm', 'application/wasm'], ['.pub', 'text/plain; charset=utf-8'],
]);
const PORTAL_NOTICE = `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex"><title>Local preview only · Membership unavailable</title></head>
<body><main><h1>Local preview only</h1><p>The dynamic membership portal is unavailable
in this static preview. Registration, sign-in, profiles, and billing require the
deployed portal. This preview does not emulate authentication or accept payments.</p>
<p><a href="/">Back to the homepage preview</a></p></main></body></html>`;
function httpError(status) {
return Object.assign(new Error('Preview request rejected'), { status });
}
function safeSegments(path) {
// Also reject Windows alternate streams, device names and normalization aliases.
return !/[\\\x00-\x1f\x7f:<>"|?*#%]/u.test(path)
&& path.split('/').every(segment => !segment.startsWith('.')
&& !/[. ]$/u.test(segment)
&& !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(segment));
}
function requestPath(target) {
// Inspect the raw target BEFORE URL parsing can silently normalize ../ or backslashes.
if (typeof target !== 'string' || !target.startsWith('/') || target.startsWith('//')) {
throw httpError(400);
}
const raw = target.split('?')[0];
if (/%(?:2f|5c)/iu.test(raw)) throw httpError(400);
let decoded;
try { decoded = decodeURIComponent(raw); } catch { throw httpError(400); }
if (!safeSegments(decoded)) throw httpError(400);
return decoded;
}
async function containedFile(root, path) {
const candidate = await realpath(resolve(root, path));
const inside = relative(root, candidate);
if (!inside || isAbsolute(inside) || inside === '..' || inside.startsWith(`..${sep}`)
|| !safeSegments(inside.split(sep).join('/'))) throw httpError(403);
if (!(await stat(candidate)).isFile()) throw httpError(404);
return candidate;
}
async function assemble(root) {
let html = await readFile(await containedFile(root, 'index.html'), 'utf8');
// Literal allowlist only: no general SSI parsing, recursive includes, or execution.
for (const [directive, path] of INCLUDES) {
if (!html.includes(directive)) continue;
const snippet = await readFile(await containedFile(root, path), 'utf8');
if (/<!--\s*#/u.test(snippet)) throw httpError(500);
html = html.replaceAll(directive, () => snippet);
}
if (/<!--\s*#/u.test(html)) throw httpError(500);
return Buffer.from(html);
}
function reply(req, res, status, body, type = 'text/plain; charset=utf-8', extra = {}) {
const data = Buffer.isBuffer(body) ? body : Buffer.from(body);
res.writeHead(status, {
'Content-Type': type,
'Content-Length': data.length,
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'no-store',
'Referrer-Policy': 'no-referrer',
'X-Frame-Options': 'DENY',
...extra,
});
res.end(req.method === 'HEAD' ? undefined : data);
}
export function parsePort(value = '8082') {
if (!/^\d+$/u.test(String(value))) throw new Error('Port must be numeric (0–65535).');
const port = Number(value);
if (!Number.isSafeInteger(port) || port < 0 || port > 65535) {
throw new Error('Port must be numeric (0–65535).');
}
return port;
}
// publicRoot is a trusted, programmatic fixture seam, never a request or CLI option.
export async function createPreviewServer({ publicRoot = PUBLIC_ROOT } = {}) {
const root = await realpath(publicRoot);
if (!(await stat(root)).isDirectory()) throw new Error('Public root must be a directory.');
const server = createServer({ requestTimeout: 10_000, headersTimeout: 5_000 }, async (req, res) => {
try {
if (req.method !== 'GET' && req.method !== 'HEAD') {
reply(req, res, 405, 'Only GET and HEAD are supported.\n', undefined, { Allow: 'GET, HEAD' });
return;
}
// Prevent arbitrary Host/DNS-rebinding origins from accessing local files.
if (!/^(?:127\.0\.0\.1|localhost)(?::\d+)?$/iu.test(req.headers.host ?? '')) {
throw httpError(403);
}
const path = requestPath(req.url);
if (path === '/members' || path.startsWith('/members/')) {
reply(req, res, 503, PORTAL_NOTICE, 'text/html; charset=utf-8');
return;
}
const homepage = path === '/' || path === '/index.html';
const file = homepage ? 'index.html' : path.slice(1);
const body = homepage ? await assemble(root) : await readFile(await containedFile(root, file));
reply(req, res, 200, body, MIME.get(extname(file).toLowerCase()) ?? 'application/octet-stream');
} catch (error) {
const status = error.status ?? (['ENOENT', 'ENOTDIR', 'EISDIR'].includes(error.code) ? 404
: ['EACCES', 'EPERM', 'ELOOP'].includes(error.code) ? 403 : 500);
// Never reflect request data, filesystem paths, or exception details.
reply(req, res, status, status === 500 ? 'Preview could not assemble this page.\n'
: status === 404 ? 'Not found in the public preview.\n' : 'Preview request rejected.\n');
}
});
server.keepAliveTimeout = 1_000;
return server;
}
export async function startPreview({ port = 8082, publicRoot = PUBLIC_ROOT } = {}) {
port = parsePort(port);
const server = await createPreviewServer({ publicRoot });
await new Promise((accept, reject) => {
server.once('error', reject);
server.listen(port, '127.0.0.1', () => {
server.removeListener('error', reject);
accept();
});
});
return server;
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
try {
if (process.argv.length > 3) throw new Error('Usage: node scripts/preview.mjs [port]');
const server = await startPreview({ port: parsePort(process.argv[2]) });
console.log(`Static preview only: http://127.0.0.1:${server.address().port}/ (membership unavailable)`);
const stop = () => {
server.close();
server.closeAllConnections();
};
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
} catch (error) {
console.error(error.code === 'EADDRINUSE' ? 'Preview port is already in use.'
: error.message.startsWith('Port') || error.message.startsWith('Usage') ? error.message
: 'Unable to start the local static preview.');
process.exitCode = 1;
}
}