Focus on content
This commit is contained in:
Executable
+174
@@ -0,0 +1,174 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user