diff --git a/README.md b/README.md index f41edb3..7cfcfeb 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,22 @@ can use the permanent channel and playlist links. Requests use 10-second timeout and a 2 MiB response limit. Titles are rendered as text, and watch/thumbnail URLs are restricted to the PeerTube origin. No descriptions, HTML, or players are embedded. +### Theme music + +The homepage plays the eom.dev theme (composed in Strudel) as a seamless 16-bar +loop: `htdocs/static/theme-player.mjs` with `htdocs/static/audio/eom-theme-loop.ogg` +(Opus) and an AAC `.m4a` fallback. Web Audio loops it without a gap. Browsers +block sound until the first click or key press, so it starts then unless the +visitor switched it off with the "Theme" button (remembered in localStorage). + +While it plays, links between the homepage and `/members/` pages are fetched +and swapped into the same document, so the music does not restart on the +membership page. Forms, sign-in, checkout, billing and other sites stay normal +navigations (`HARD_NAV` in the module), and with the music off every link is a +normal navigation. The membership templates do not load the player; a visitor +who opens `/members/` directly gets no music. Sources and longer versions are +kept outside this repository (`eom-theme.strudel.js`). + ### Local preview and content checks With Node.js 24 or later, run `node scripts/preview.mjs` from this repository diff --git a/htdocs/index.html b/htdocs/index.html index 858a0d8..a00ad36 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -323,6 +323,7 @@ + diff --git a/htdocs/static/audio/eom-theme-loop.m4a b/htdocs/static/audio/eom-theme-loop.m4a new file mode 100644 index 0000000..aa5f7ab Binary files /dev/null and b/htdocs/static/audio/eom-theme-loop.m4a differ diff --git a/htdocs/static/audio/eom-theme-loop.ogg b/htdocs/static/audio/eom-theme-loop.ogg new file mode 100644 index 0000000..b5468e1 Binary files /dev/null and b/htdocs/static/audio/eom-theme-loop.ogg differ diff --git a/htdocs/static/audio/eom-theme.mp3 b/htdocs/static/audio/eom-theme.mp3 new file mode 100644 index 0000000..3042af6 Binary files /dev/null and b/htdocs/static/audio/eom-theme.mp3 differ diff --git a/htdocs/static/theme-player.mjs b/htdocs/static/theme-player.mjs new file mode 100644 index 0000000..7a0e68d --- /dev/null +++ b/htdocs/static/theme-player.mjs @@ -0,0 +1,302 @@ +/** + * eom.dev theme music for the homepage. + * + * Plays a seamless 16-bar loop of the eom.dev theme (Web Audio, so the loop has no + * gap). While it plays, links between the homepage and the membership pages are + * loaded in place (fetch + swap) instead of reloading the document, so the music + * keeps going on /members/. Anything else (forms, sign-in, checkout, other sites) + * is a normal navigation. + * + * Browsers block sound until the visitor interacts with the page, so playback + * starts on the first click or key press unless the visitor has turned it off. + * The choice is kept in localStorage ("eom-theme-music": "on" | "off"). + */ + +const SOURCES = [ + ['/static/audio/eom-theme-loop.ogg', 'audio/ogg; codecs=opus'], + ['/static/audio/eom-theme-loop.m4a', 'audio/mp4; codecs=mp4a.40.2'], +]; +const VOLUME = 0.3; +const FADE_S = 1.5; +const PREF_KEY = 'eom-theme-music'; +const RESUME_KEY = 'eom-theme-music-position'; +const PLAYER_ID = 'eom-theme-player'; +// Membership routes that must be real navigations (redirects off-site, POST-only, webhooks). +const HARD_NAV = /^\/members\/(login|logout|oidc|checkout|billing|stripe|shkeeper|crypto\/checkout)\b/; + +const storage = { + get(key, area = localStorage) { try { return area.getItem(key); } catch { return null; } }, + set(key, value, area = localStorage) { try { area.setItem(key, value); } catch { /* private mode */ } }, +}; + +class ThemeMusic { + constructor() { + this.ctx = null; + this.buffer = null; + this.source = null; + this.gain = null; + this.startedAt = 0; // ctx time when the current source started + this.offset = 0; // loop position (s) at startedAt + this.playing = false; + this.loading = null; + } + + position() { + if (!this.buffer) return 0; + const t = this.playing ? this.offset + (this.ctx.currentTime - this.startedAt) : this.offset; + return t % this.buffer.duration; + } + + async load() { + if (this.buffer) return this.buffer; + this.loading ??= (async () => { + this.ctx = new (window.AudioContext || window.webkitAudioContext)(); + this.gain = this.ctx.createGain(); + this.gain.gain.value = 0; + this.gain.connect(this.ctx.destination); + const probe = document.createElement('audio'); + const candidates = SOURCES.filter(([, type]) => probe.canPlayType(type)); + for (const [url] of candidates.length ? candidates : SOURCES) { + try { + const res = await fetch(url); + if (!res.ok) continue; + this.buffer = await this.ctx.decodeAudioData(await res.arrayBuffer()); + return this.buffer; + } catch { /* try the next format */ } + } + throw new Error('theme music could not be decoded'); + })(); + return this.loading; + } + + async play(at = this.offset) { + await this.load(); + if (this.ctx.state === 'suspended') await this.ctx.resume(); + if (this.ctx.state !== 'running') throw new Error('audio blocked until user interaction'); + if (this.playing) return; + const src = this.ctx.createBufferSource(); + src.buffer = this.buffer; + src.loop = true; + src.connect(this.gain); + this.offset = at % this.buffer.duration; + this.startedAt = this.ctx.currentTime; + src.start(0, this.offset); + this.source = src; + this.playing = true; + const g = this.gain.gain; + g.cancelScheduledValues(this.ctx.currentTime); + g.setValueAtTime(g.value, this.ctx.currentTime); + g.linearRampToValueAtTime(VOLUME, this.ctx.currentTime + FADE_S); + } + + pause() { + if (!this.playing) return; + this.offset = this.position(); + this.playing = false; + const now = this.ctx.currentTime; + const g = this.gain.gain; + g.cancelScheduledValues(now); + g.setValueAtTime(g.value, now); + g.linearRampToValueAtTime(0, now + 0.3); + this.source.stop(now + 0.32); + this.source = null; + } +} + +const music = new ThemeMusic(); +let button; +let starting = false; // a start is in flight (audio context resuming) +let renderedPath = location.pathname + location.search; + +function render() { + if (!button) return; + button.setAttribute('aria-pressed', String(music.playing)); + button.title = music.playing ? 'Pause the eom.dev theme' : 'Play the eom.dev theme'; + button.querySelector('.label').textContent = music.playing ? 'Theme on' : 'Theme off'; +} + +async function start() { + starting = true; + try { await music.play(); } catch { /* still blocked; wait for a gesture */ } + starting = false; + render(); +} + +function toggle() { + if (music.playing) { + music.pause(); + storage.set(PREF_KEY, 'off'); + render(); + } else { + storage.set(PREF_KEY, 'on'); + start(); + } +} + +function mountButton() { + if (document.getElementById(PLAYER_ID)) return; + const style = document.createElement('style'); + style.id = `${PLAYER_ID}-style`; + style.textContent = ` +#${PLAYER_ID}{position:fixed;right:1rem;bottom:1rem;z-index:1000;display:inline-flex;align-items:center;gap:.45rem; + padding:.5rem .8rem;border-radius:999px;border:1px solid var(--border,#3c3d56);background:var(--surface-raised,#292940); + color:var(--text,#cdd6f4);font:600 .85rem/1 system-ui,sans-serif;cursor:pointer;box-shadow:0 4px 18px rgb(0 0 0 / .25)} +#${PLAYER_ID}:hover{border-color:var(--accent,#cba6f7)} +#${PLAYER_ID}:focus-visible{outline:2px solid var(--accent,#cba6f7);outline-offset:2px} +#${PLAYER_ID} .bars{display:inline-flex;gap:2px;align-items:flex-end;height:.9rem} +#${PLAYER_ID} .bars i{width:3px;height:30%;background:var(--accent,#cba6f7);border-radius:1px} +#${PLAYER_ID}[aria-pressed="true"] .bars i{animation:eomBars 1.07s ease-in-out infinite} +#${PLAYER_ID}[aria-pressed="true"] .bars i:nth-child(2){animation-delay:-.27s} +#${PLAYER_ID}[aria-pressed="true"] .bars i:nth-child(3){animation-delay:-.54s} +@keyframes eomBars{0%,100%{height:30%}50%{height:100%}} +@media (prefers-reduced-motion:reduce){#${PLAYER_ID} .bars i{animation:none!important;height:60%!important}} +@media print{#${PLAYER_ID}{display:none}}`; + document.head.append(style); + button = document.createElement('button'); + button.id = PLAYER_ID; + button.type = 'button'; + button.innerHTML = ''; + button.addEventListener('click', toggle); + document.body.append(button); + render(); +} + +/* ---------- in-place navigation while the music plays ---------- */ + +let navToken = 0; +let moduleGeneration = 0; + +function softNavigable(url) { + if (url.origin !== location.origin) return false; + if (url.pathname === location.pathname && url.search === location.search && url.hash) return false; // same-page anchor + if (HARD_NAV.test(url.pathname)) return false; + return url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/members/'); +} + +function onClick(event) { + if (!(music.playing || starting) || event.defaultPrevented || event.button !== 0) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + const link = event.target.closest?.('a[href]'); + if (!link || (link.target && link.target !== '_self') || link.hasAttribute('download')) return; + const url = new URL(link.href, location.href); + if (!softNavigable(url)) return; + event.preventDefault(); + navigate(url, { push: true }); +} + +async function adoptStylesheets(doc) { + const wanted = [...doc.querySelectorAll('link[rel="stylesheet"]')].map(l => new URL(l.getAttribute('href'), location.href).href); + const current = [...document.querySelectorAll('link[rel="stylesheet"]')]; + // Load the new sheets first (no unstyled flash), then put every sheet in the new + // page's order; cascade order matters (pure.css must come before home.css). + const fresh = new Map(); + for (const href of wanted) { + if (current.some(l => l.href === href)) continue; + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + fresh.set(href, new Promise(resolve => { link.onload = link.onerror = () => resolve(link); })); + document.head.append(link); + } + const added = new Map(await Promise.all([...fresh].map(async ([href, p]) => [href, await p]))); + for (const link of current) if (!wanted.includes(link.href)) link.remove(); + for (const href of wanted) document.head.append(added.get(href) ?? current.find(l => l.href === href)); +} + +function runScripts(container) { + for (const old of container.querySelectorAll('script')) { + const type = old.getAttribute('type') || ''; + if (type && type !== 'module' && type !== 'text/javascript') continue; // e.g. JSON-LD + if (old.src && new URL(old.src, location.href).pathname === new URL(import.meta.url).pathname) { old.remove(); continue; } + const fresh = document.createElement('script'); + for (const { name, value } of old.attributes) fresh.setAttribute(name, value); + if (old.src && type === 'module') { + // A module URL only runs once per document; a new query makes it run again. + const src = new URL(old.src, location.href); + src.searchParams.set('nav', String(++moduleGeneration)); + fresh.src = src.href; + } + fresh.textContent = old.textContent; + old.replaceWith(fresh); + } +} + +async function navigate(url, { push }) { + const token = ++navToken; + let res; + try { + res = await fetch(url.href, { credentials: 'same-origin', headers: { Accept: 'text/html' } }); + } catch { location.assign(url.href); return; } // e.g. redirected to another origin + if (token !== navToken) return; + const finalURL = new URL(res.url || url.href); + if (!res.ok || !(res.headers.get('content-type') || '').includes('text/html') || finalURL.origin !== location.origin) { + location.assign(url.href); + return; + } + const doc = new DOMParser().parseFromString(await res.text(), 'text/html'); + if (token !== navToken) return; + finalURL.hash = url.hash; + if (push) history.pushState({ eomTheme: true }, '', finalURL.href); + renderedPath = finalURL.pathname + finalURL.search; + + await adoptStylesheets(doc); + document.title = doc.title; + for (const name of ['description', 'robots']) { + const next = doc.querySelector(`meta[name="${name}"]`); + const cur = document.querySelector(`meta[name="${name}"]`); + if (next && cur) cur.content = next.content; + else if (next) document.head.append(next.cloneNode()); + else cur?.remove(); + } + document.documentElement.lang = doc.documentElement.lang || document.documentElement.lang; + const keep = document.getElementById(PLAYER_ID); + document.body.replaceChildren(...doc.body.childNodes); + for (const { name, value } of doc.body.attributes) document.body.setAttribute(name, value); + if (keep) document.body.append(keep); + runScripts(document.body); + + const target = url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1))); + if (target) target.scrollIntoView(); + else window.scrollTo(0, 0); + (document.querySelector('main') || document.body).focus?.({ preventScroll: true }); +} + +function onPopState() { + if (location.pathname + location.search === renderedPath) return; // only the #hash changed + if (!music.playing) { location.reload(); return; } + navigate(new URL(location.href), { push: false }); +} + +/* ---------- boot ---------- */ + +function boot() { + if (window.__eomThemeMusic) return; // already running in this document + window.__eomThemeMusic = music; + mountButton(); + document.addEventListener('click', onClick); + window.addEventListener('popstate', onPopState); + if (!history.state) history.replaceState({ eomTheme: true }, ''); + + // Keep the loop position across a real reload of the homepage. + window.addEventListener('pagehide', () => { + if (music.playing) storage.set(RESUME_KEY, JSON.stringify({ at: music.position(), t: Date.now() }), sessionStorage); + else try { sessionStorage.removeItem(RESUME_KEY); } catch { /* private mode */ } + }); + try { + const saved = JSON.parse(storage.get(RESUME_KEY, sessionStorage) || 'null'); + if (saved && Date.now() - saved.t < 30_000) music.offset = saved.at + (Date.now() - saved.t) / 1000; + } catch { /* ignore */ } + + if (storage.get(PREF_KEY) === 'off') return; + music.load().catch(() => {}); // fetch/decode early so the first click starts instantly + start(); // succeeds only where autoplay is allowed + const onGesture = event => { + if (event.target.closest?.(`#${PLAYER_ID}`)) return; // the toggle handles its own click + for (const type of ['pointerdown', 'keydown']) removeEventListener(type, onGesture, true); + if (!music.playing && storage.get(PREF_KEY) !== 'off') start(); + }; + for (const type of ['pointerdown', 'keydown']) addEventListener(type, onGesture, true); +} + +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true }); +else boot(); diff --git a/scripts/preview.mjs b/scripts/preview.mjs index 7a6ddb5..d72762c 100755 --- a/scripts/preview.mjs +++ b/scripts/preview.mjs @@ -24,7 +24,7 @@ const MIME = new Map([ ['.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'], + ['.mp3', 'audio/mpeg'], ['.ogg', 'audio/ogg'], ['.m4a', 'audio/mp4'], ['.wav', 'audio/wav'], ['.mp4', 'video/mp4'], ['.webm', 'video/webm'], ['.pdf', 'application/pdf'], ['.wasm', 'application/wasm'], ['.pub', 'text/plain; charset=utf-8'], ]); diff --git a/tests/site.test.mjs b/tests/site.test.mjs index 4de8eae..00d23d0 100755 --- a/tests/site.test.mjs +++ b/tests/site.test.mjs @@ -1,431 +1,451 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { request } from 'node:http'; -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { startPreview, parsePort } from '../scripts/preview.mjs'; - -const repository = new URL('../', import.meta.url); - -// Small scanner for this site's static markup, not a general-purpose HTML validator. -function attributes(tag) { - return new Map([...tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu)] - .map(([, name, double, single]) => [name.toLowerCase(), double ?? single])); -} - -function text(html) { - return html.replace(/<[^>]*>/gu, ' ').replace(/&/gu, '&') - .replace(/&(?:nbsp|#160);/gu, ' ').replace(/\s+/gu, ' ').trim(); -} - -function links(html) { - return [...html.matchAll(/]*)>([\s\S]*?)<\/a>/giu)] - .map(([, tag, inner]) => ({ attrs: attributes(tag), inner })); -} - -function get(server, path = '/', method = 'GET', headers = {}) { - // Raw request paths preserve traversal probes that fetch/URL would normalize away. - return new Promise((accept, reject) => { - const req = request({ host: '127.0.0.1', port: server.address().port, path, - method, headers, agent: false }, res => { - const chunks = []; - res.on('data', chunk => chunks.push(chunk)); - res.on('error', reject); - res.on('end', () => accept({ status: res.statusCode, headers: res.headers, - body: Buffer.concat(chunks) })); - }); - req.on('error', reject); - req.setTimeout(3_000, () => req.destroy(new Error('Preview request timed out'))); - req.end(); - }); -} - -async function preview(t, options) { - const server = await startPreview({ ...options, port: 0 }); - t.after(() => new Promise((accept, reject) => { - server.close(error => error ? reject(error) : accept()); - server.closeAllConnections(); - })); - assert.equal(server.address().address, '127.0.0.1'); - return server; -} - -async function homepage(t) { - const response = await get(await preview(t)); - assert.equal(response.status, 200); - return response.body.toString('utf8'); -} - -test('homepage assembles only the three common includes, with complete metadata and JSON-LD', async t => { - const html = await homepage(t); - assert.doesNotMatch(html, /`).join('\n')); - const response = await get(server); - assert.equal(response.status, 200); - assert.doesNotMatch(response.body.toString(), /', '', - '', '', - ]) { - await writeFile(join(root, 'index.html'), directive); - const rejected = await get(server); - assert.equal(rejected.status, 500); - assert.doesNotMatch(rejected.body.toString(), /outside-public-root|should-never-execute/u); - } - await writeFile(join(root, 'index.html'), ''); - await writeFile(join(root, 'common', 'header.html'), ''); - assert.equal((await get(server)).status, 500); -}); - -test('realpath containment blocks directory symlinks/junctions, including common SSI directories', async t => { - const { directory, root } = await fixture(t); - const outside = join(directory, 'outside'); - await mkdir(outside); - await writeFile(join(outside, 'header.html'), 'outside-public-root'); - try { - await symlink(outside, join(root, 'escape-dir'), process.platform === 'win32' ? 'junction' : 'dir'); - } catch (error) { - if (['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) { - t.skip(`Directory symlinks unavailable on this host (${error.code}).`); - return; - } - throw error; - } - const server = await preview(t, { publicRoot: root }); - assert.equal((await get(server, '/escape-dir/header.html')).status, 403); - await rm(join(root, 'common'), { recursive: true }); - await symlink(outside, join(root, 'common'), process.platform === 'win32' ? 'junction' : 'dir'); - await writeFile(join(root, 'index.html'), ''); - const response = await get(server); - assert.equal(response.status, 403); - assert.doesNotMatch(response.body.toString(), /outside-public-root/u); -}); - -test('realpath containment blocks file symlinks and hidden targets, including SSI', async t => { - const { directory, root } = await fixture(t); - await writeFile(join(root, '.hidden.txt'), 'hidden-target'); - try { - await symlink(join(directory, 'private.txt'), join(root, 'escape.txt'), 'file'); - await symlink(join(root, '.hidden.txt'), join(root, 'alias.txt'), 'file'); - await symlink(join(directory, 'private.txt'), join(root, 'common', 'header.html'), 'file'); - } catch (error) { - if (['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) { - t.skip(`File symlinks unavailable on this host (${error.code}); run on a symlink-capable host too.`); - return; - } - throw error; - } - await writeFile(join(root, 'index.html'), ''); - const server = await preview(t, { publicRoot: root }); - for (const path of ['/escape.txt', '/alias.txt', '/']) { - const response = await get(server, path); - assert.equal(response.status, 403, path); - assert.doesNotMatch(response.body.toString(), /outside-public-root|hidden-target/u); - } -}); - -test('port parsing defaults to 8082 and accepts only numeric ports in range', () => { - assert.equal(parsePort(), 8082); - assert.equal(parsePort('0'), 0); - assert.equal(parsePort('65535'), 65535); - for (const value of ['', '-1', '65536', '1.5', '8082x', ' 8082', 'Infinity', '127.0.0.1:8082']) { - assert.throws(() => parsePort(value), /Port must be numeric/u); - } -}); +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { request } from 'node:http'; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { startPreview, parsePort } from '../scripts/preview.mjs'; + +const repository = new URL('../', import.meta.url); + +// Small scanner for this site's static markup, not a general-purpose HTML validator. +function attributes(tag) { + return new Map([...tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu)] + .map(([, name, double, single]) => [name.toLowerCase(), double ?? single])); +} + +function text(html) { + return html.replace(/<[^>]*>/gu, ' ').replace(/&/gu, '&') + .replace(/&(?:nbsp|#160);/gu, ' ').replace(/\s+/gu, ' ').trim(); +} + +function links(html) { + return [...html.matchAll(/]*)>([\s\S]*?)<\/a>/giu)] + .map(([, tag, inner]) => ({ attrs: attributes(tag), inner })); +} + +function get(server, path = '/', method = 'GET', headers = {}) { + // Raw request paths preserve traversal probes that fetch/URL would normalize away. + return new Promise((accept, reject) => { + const req = request({ host: '127.0.0.1', port: server.address().port, path, + method, headers, agent: false }, res => { + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('error', reject); + res.on('end', () => accept({ status: res.statusCode, headers: res.headers, + body: Buffer.concat(chunks) })); + }); + req.on('error', reject); + req.setTimeout(3_000, () => req.destroy(new Error('Preview request timed out'))); + req.end(); + }); +} + +async function preview(t, options) { + const server = await startPreview({ ...options, port: 0 }); + t.after(() => new Promise((accept, reject) => { + server.close(error => error ? reject(error) : accept()); + server.closeAllConnections(); + })); + assert.equal(server.address().address, '127.0.0.1'); + return server; +} + +async function homepage(t) { + const response = await get(await preview(t)); + assert.equal(response.status, 200); + return response.body.toString('utf8'); +} + +test('homepage assembles only the three common includes, with complete metadata and JSON-LD', async t => { + const html = await homepage(t); + assert.doesNotMatch(html, /`).join('\n')); + const response = await get(server); + assert.equal(response.status, 200); + assert.doesNotMatch(response.body.toString(), /', '', + '', '', + ]) { + await writeFile(join(root, 'index.html'), directive); + const rejected = await get(server); + assert.equal(rejected.status, 500); + assert.doesNotMatch(rejected.body.toString(), /outside-public-root|should-never-execute/u); + } + await writeFile(join(root, 'index.html'), ''); + await writeFile(join(root, 'common', 'header.html'), ''); + assert.equal((await get(server)).status, 500); +}); + +test('realpath containment blocks directory symlinks/junctions, including common SSI directories', async t => { + const { directory, root } = await fixture(t); + const outside = join(directory, 'outside'); + await mkdir(outside); + await writeFile(join(outside, 'header.html'), 'outside-public-root'); + try { + await symlink(outside, join(root, 'escape-dir'), process.platform === 'win32' ? 'junction' : 'dir'); + } catch (error) { + if (['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) { + t.skip(`Directory symlinks unavailable on this host (${error.code}).`); + return; + } + throw error; + } + const server = await preview(t, { publicRoot: root }); + assert.equal((await get(server, '/escape-dir/header.html')).status, 403); + await rm(join(root, 'common'), { recursive: true }); + await symlink(outside, join(root, 'common'), process.platform === 'win32' ? 'junction' : 'dir'); + await writeFile(join(root, 'index.html'), ''); + const response = await get(server); + assert.equal(response.status, 403); + assert.doesNotMatch(response.body.toString(), /outside-public-root/u); +}); + +test('realpath containment blocks file symlinks and hidden targets, including SSI', async t => { + const { directory, root } = await fixture(t); + await writeFile(join(root, '.hidden.txt'), 'hidden-target'); + try { + await symlink(join(directory, 'private.txt'), join(root, 'escape.txt'), 'file'); + await symlink(join(root, '.hidden.txt'), join(root, 'alias.txt'), 'file'); + await symlink(join(directory, 'private.txt'), join(root, 'common', 'header.html'), 'file'); + } catch (error) { + if (['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) { + t.skip(`File symlinks unavailable on this host (${error.code}); run on a symlink-capable host too.`); + return; + } + throw error; + } + await writeFile(join(root, 'index.html'), ''); + const server = await preview(t, { publicRoot: root }); + for (const path of ['/escape.txt', '/alias.txt', '/']) { + const response = await get(server, path); + assert.equal(response.status, 403, path); + assert.doesNotMatch(response.body.toString(), /outside-public-root|hidden-target/u); + } +}); + +test('port parsing defaults to 8082 and accepts only numeric ports in range', () => { + assert.equal(parsePort(), 8082); + assert.equal(parsePort('0'), 0); + assert.equal(parsePort('65535'), 65535); + for (const value of ['', '-1', '65536', '1.5', '8082x', ' 8082', 'Infinity', '127.0.0.1:8082']) { + assert.throws(() => parsePort(value), /Port must be numeric/u); + } +});