Homepage: seamless theme music that keeps playing on /members/
Adds the eom.dev theme (Strudel) as a 16-bar Web Audio loop (Opus, AAC fallback) with a Theme on/off button. Playback starts on the first interaction unless the visitor turned it off. While it plays, links between the homepage and membership pages are fetched and swapped in place, so the music doesn't restart; sign-in, checkout, billing and forms remain real navigations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
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.
|
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
|
### Local preview and content checks
|
||||||
|
|
||||||
With Node.js 24 or later, run `node scripts/preview.mjs` from this repository
|
With Node.js 24 or later, run `node scripts/preview.mjs` from this repository
|
||||||
|
|||||||
@@ -323,6 +323,7 @@
|
|||||||
</main>
|
</main>
|
||||||
<script type="module" src="/static/peertube-feeds.mjs"></script>
|
<script type="module" src="/static/peertube-feeds.mjs"></script>
|
||||||
<script type="module" src="/static/gitea-projects.mjs"></script>
|
<script type="module" src="/static/gitea-projects.mjs"></script>
|
||||||
|
<script type="module" src="/static/theme-player.mjs"></script>
|
||||||
<!--#include virtual="/common/footer.html" -->
|
<!--#include virtual="/common/footer.html" -->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 = '<span class="bars" aria-hidden="true"><i></i><i></i><i></i></span><span class="label"></span>';
|
||||||
|
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();
|
||||||
+1
-1
@@ -24,7 +24,7 @@ const MIME = new Map([
|
|||||||
['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'],
|
['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'],
|
||||||
['.webp', 'image/webp'], ['.avif', 'image/avif'], ['.woff', 'font/woff'],
|
['.webp', 'image/webp'], ['.avif', 'image/avif'], ['.woff', 'font/woff'],
|
||||||
['.woff2', 'font/woff2'], ['.ttf', 'font/ttf'], ['.otf', 'font/otf'],
|
['.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'],
|
['.mp4', 'video/mp4'], ['.webm', 'video/webm'], ['.pdf', 'application/pdf'],
|
||||||
['.wasm', 'application/wasm'], ['.pub', 'text/plain; charset=utf-8'],
|
['.wasm', 'application/wasm'], ['.pub', 'text/plain; charset=utf-8'],
|
||||||
]);
|
]);
|
||||||
|
|||||||
+451
-431
@@ -1,431 +1,451 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { request } from 'node:http';
|
import { request } from 'node:http';
|
||||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { startPreview, parsePort } from '../scripts/preview.mjs';
|
import { startPreview, parsePort } from '../scripts/preview.mjs';
|
||||||
|
|
||||||
const repository = new URL('../', import.meta.url);
|
const repository = new URL('../', import.meta.url);
|
||||||
|
|
||||||
// Small scanner for this site's static markup, not a general-purpose HTML validator.
|
// Small scanner for this site's static markup, not a general-purpose HTML validator.
|
||||||
function attributes(tag) {
|
function attributes(tag) {
|
||||||
return new Map([...tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu)]
|
return new Map([...tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu)]
|
||||||
.map(([, name, double, single]) => [name.toLowerCase(), double ?? single]));
|
.map(([, name, double, single]) => [name.toLowerCase(), double ?? single]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function text(html) {
|
function text(html) {
|
||||||
return html.replace(/<[^>]*>/gu, ' ').replace(/&/gu, '&')
|
return html.replace(/<[^>]*>/gu, ' ').replace(/&/gu, '&')
|
||||||
.replace(/&(?:nbsp|#160);/gu, ' ').replace(/\s+/gu, ' ').trim();
|
.replace(/&(?:nbsp|#160);/gu, ' ').replace(/\s+/gu, ' ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function links(html) {
|
function links(html) {
|
||||||
return [...html.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/giu)]
|
return [...html.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/giu)]
|
||||||
.map(([, tag, inner]) => ({ attrs: attributes(tag), inner }));
|
.map(([, tag, inner]) => ({ attrs: attributes(tag), inner }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function get(server, path = '/', method = 'GET', headers = {}) {
|
function get(server, path = '/', method = 'GET', headers = {}) {
|
||||||
// Raw request paths preserve traversal probes that fetch/URL would normalize away.
|
// Raw request paths preserve traversal probes that fetch/URL would normalize away.
|
||||||
return new Promise((accept, reject) => {
|
return new Promise((accept, reject) => {
|
||||||
const req = request({ host: '127.0.0.1', port: server.address().port, path,
|
const req = request({ host: '127.0.0.1', port: server.address().port, path,
|
||||||
method, headers, agent: false }, res => {
|
method, headers, agent: false }, res => {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
res.on('data', chunk => chunks.push(chunk));
|
res.on('data', chunk => chunks.push(chunk));
|
||||||
res.on('error', reject);
|
res.on('error', reject);
|
||||||
res.on('end', () => accept({ status: res.statusCode, headers: res.headers,
|
res.on('end', () => accept({ status: res.statusCode, headers: res.headers,
|
||||||
body: Buffer.concat(chunks) }));
|
body: Buffer.concat(chunks) }));
|
||||||
});
|
});
|
||||||
req.on('error', reject);
|
req.on('error', reject);
|
||||||
req.setTimeout(3_000, () => req.destroy(new Error('Preview request timed out')));
|
req.setTimeout(3_000, () => req.destroy(new Error('Preview request timed out')));
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function preview(t, options) {
|
async function preview(t, options) {
|
||||||
const server = await startPreview({ ...options, port: 0 });
|
const server = await startPreview({ ...options, port: 0 });
|
||||||
t.after(() => new Promise((accept, reject) => {
|
t.after(() => new Promise((accept, reject) => {
|
||||||
server.close(error => error ? reject(error) : accept());
|
server.close(error => error ? reject(error) : accept());
|
||||||
server.closeAllConnections();
|
server.closeAllConnections();
|
||||||
}));
|
}));
|
||||||
assert.equal(server.address().address, '127.0.0.1');
|
assert.equal(server.address().address, '127.0.0.1');
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function homepage(t) {
|
async function homepage(t) {
|
||||||
const response = await get(await preview(t));
|
const response = await get(await preview(t));
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
return response.body.toString('utf8');
|
return response.body.toString('utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
test('homepage assembles only the three common includes, with complete metadata and JSON-LD', async t => {
|
test('homepage assembles only the three common includes, with complete metadata and JSON-LD', async t => {
|
||||||
const html = await homepage(t);
|
const html = await homepage(t);
|
||||||
assert.doesNotMatch(html, /<!--\s*#/u);
|
assert.doesNotMatch(html, /<!--\s*#/u);
|
||||||
assert.match(html, /<!doctype html>/iu);
|
assert.match(html, /<!doctype html>/iu);
|
||||||
assert.match(html, /<html\s+lang="en"/u);
|
assert.match(html, /<html\s+lang="en"/u);
|
||||||
assert.equal([...html.matchAll(/<h1\b/giu)].length, 1);
|
assert.equal([...html.matchAll(/<h1\b/giu)].length, 1);
|
||||||
assert.equal([...html.matchAll(/<head>/giu)].length, 1);
|
assert.equal([...html.matchAll(/<head>/giu)].length, 1);
|
||||||
assert.equal([...html.matchAll(/<footer>/giu)].length, 1);
|
assert.equal([...html.matchAll(/<footer>/giu)].length, 1);
|
||||||
assert.match(text(html.match(/<title>([\s\S]*?)<\/title>/iu)?.[1] ?? ''), /Eric.*Meehan/iu);
|
assert.match(text(html.match(/<title>([\s\S]*?)<\/title>/iu)?.[1] ?? ''), /Eric.*Meehan/iu);
|
||||||
const metas = [...html.matchAll(/<meta\b[^>]*>/giu)].map(([tag]) => attributes(tag));
|
const metas = [...html.matchAll(/<meta\b[^>]*>/giu)].map(([tag]) => attributes(tag));
|
||||||
assert.ok(metas.some(meta => meta.get('charset')?.toLowerCase() === 'utf-8'));
|
assert.ok(metas.some(meta => meta.get('charset')?.toLowerCase() === 'utf-8'));
|
||||||
assert.ok(metas.some(meta => meta.get('name') === 'viewport' && meta.get('content')));
|
assert.ok(metas.some(meta => meta.get('name') === 'viewport' && meta.get('content')));
|
||||||
for (const name of ['description', 'og:title', 'og:description', 'twitter:title', 'twitter:description']) {
|
for (const name of ['description', 'og:title', 'og:description', 'twitter:title', 'twitter:description']) {
|
||||||
const meta = metas.find(item => (item.get('name') ?? item.get('property')) === name);
|
const meta = metas.find(item => (item.get('name') ?? item.get('property')) === name);
|
||||||
assert.ok(meta?.get('content'), `Missing ${name}`);
|
assert.ok(meta?.get('content'), `Missing ${name}`);
|
||||||
for (const topic of [/music/iu, /films?/iu, /engineering/iu]) assert.match(meta.get('content'), topic);
|
for (const topic of [/music/iu, /films?/iu, /engineering/iu]) assert.match(meta.get('content'), topic);
|
||||||
}
|
}
|
||||||
const canonical = [...html.matchAll(/<link\b[^>]*>/giu)].map(([tag]) => attributes(tag))
|
const canonical = [...html.matchAll(/<link\b[^>]*>/giu)].map(([tag]) => attributes(tag))
|
||||||
.find(attrs => attrs.get('rel') === 'canonical');
|
.find(attrs => attrs.get('rel') === 'canonical');
|
||||||
assert.equal(canonical?.get('href'), 'https://www.eom.dev/');
|
assert.equal(canonical?.get('href'), 'https://www.eom.dev/');
|
||||||
for (const [name, expected] of [['og:url', canonical.get('href')], ['og:type', 'website'],
|
for (const [name, expected] of [['og:url', canonical.get('href')], ['og:type', 'website'],
|
||||||
['og:site_name', 'eom.dev'], ['twitter:card', 'summary']]) {
|
['og:site_name', 'eom.dev'], ['twitter:card', 'summary']]) {
|
||||||
assert.equal(metas.find(meta => (meta.get('name') ?? meta.get('property')) === name)?.get('content'), expected);
|
assert.equal(metas.find(meta => (meta.get('name') ?? meta.get('property')) === name)?.get('content'), expected);
|
||||||
}
|
}
|
||||||
assert.equal(new URL(metas.find(meta => meta.get('property') === 'og:image')?.get('content')).protocol, 'https:');
|
assert.equal(new URL(metas.find(meta => meta.get('property') === 'og:image')?.get('content')).protocol, 'https:');
|
||||||
assert.ok(metas.find(meta => meta.get('property') === 'og:image:alt')?.get('content'));
|
assert.ok(metas.find(meta => meta.get('property') === 'og:image:alt')?.get('content'));
|
||||||
const schemas = [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/giu)]
|
const schemas = [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/giu)]
|
||||||
.filter(([, tag]) => attributes(tag).get('type') === 'application/ld+json')
|
.filter(([, tag]) => attributes(tag).get('type') === 'application/ld+json')
|
||||||
.map(([, , json]) => JSON.parse(json));
|
.map(([, , json]) => JSON.parse(json));
|
||||||
assert.ok(schemas.length > 0, 'Missing JSON-LD');
|
assert.ok(schemas.length > 0, 'Missing JSON-LD');
|
||||||
for (const schema of schemas) {
|
for (const schema of schemas) {
|
||||||
assert.equal(schema['@context'], 'https://schema.org');
|
assert.equal(schema['@context'], 'https://schema.org');
|
||||||
assert.equal(schema['@type'], 'Person');
|
assert.equal(schema['@type'], 'Person');
|
||||||
assert.match(schema.name, /Meehan/u);
|
assert.match(schema.name, /Meehan/u);
|
||||||
assert.equal(schema.url, canonical.get('href'));
|
assert.equal(schema.url, canonical.get('href'));
|
||||||
assert.match(schema.jobTitle, /musician/iu);
|
assert.match(schema.jobTitle, /musician/iu);
|
||||||
assert.ok(Array.isArray(schema.sameAs) && schema.sameAs.length > 0);
|
assert.ok(Array.isArray(schema.sameAs) && schema.sameAs.length > 0);
|
||||||
for (const url of [schema.url, schema.image, ...schema.sameAs]) {
|
for (const url of [schema.url, schema.image, ...schema.sameAs]) {
|
||||||
assert.equal(new URL(url).protocol, 'https:');
|
assert.equal(new URL(url).protocol, 'https:');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unique anchors, valid local fragments, and four creative paths ahead of services', async t => {
|
test('unique anchors, valid local fragments, and four creative paths ahead of services', async t => {
|
||||||
const html = await homepage(t);
|
const html = await homepage(t);
|
||||||
const ids = [...html.matchAll(/<[a-z][^>]*>/giu)].map(([tag]) => attributes(tag).get('id'))
|
const ids = [...html.matchAll(/<[a-z][^>]*>/giu)].map(([tag]) => attributes(tag).get('id'))
|
||||||
.filter(id => id !== undefined);
|
.filter(id => id !== undefined);
|
||||||
assert.equal(new Set(ids).size, ids.length, 'Duplicate IDs');
|
assert.equal(new Set(ids).size, ids.length, 'Duplicate IDs');
|
||||||
assert.ok(ids.every(id => id && !/\s/u.test(id)), 'IDs must be nonempty and whitespace-free');
|
assert.ok(ids.every(id => id && !/\s/u.test(id)), 'IDs must be nonempty and whitespace-free');
|
||||||
const destinations = new Set(links(html).map(link => link.attrs.get('href')));
|
const destinations = new Set(links(html).map(link => link.attrs.get('href')));
|
||||||
for (const href of destinations) {
|
for (const href of destinations) {
|
||||||
assert.ok(href?.trim(), 'Every link needs a destination');
|
assert.ok(href?.trim(), 'Every link needs a destination');
|
||||||
const url = new URL(href.replaceAll('&', '&'), 'https://www.eom.dev/');
|
const url = new URL(href.replaceAll('&', '&'), 'https://www.eom.dev/');
|
||||||
assert.ok(['https:', 'mailto:'].includes(url.protocol), `Unsafe link: ${href}`);
|
assert.ok(['https:', 'mailto:'].includes(url.protocol), `Unsafe link: ${href}`);
|
||||||
if (url.origin === 'https://www.eom.dev' && url.pathname === '/' && url.hash) {
|
if (url.origin === 'https://www.eom.dev' && url.pathname === '/' && url.hash) {
|
||||||
assert.ok(ids.includes(decodeURIComponent(url.hash.slice(1))), `Broken fragment: ${href}`);
|
assert.ok(ids.includes(decodeURIComponent(url.hash.slice(1))), `Broken fragment: ${href}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const id of ['films', 'music', 'projects', 'gaming']) {
|
for (const id of ['films', 'music', 'projects', 'gaming']) {
|
||||||
assert.ok(destinations.has(`#${id}`), `Missing creative path ${id}`);
|
assert.ok(destinations.has(`#${id}`), `Missing creative path ${id}`);
|
||||||
assert.ok(ids.indexOf(id) >= 0 && ids.indexOf(id) < ids.indexOf('services'), `${id} must precede services`);
|
assert.ok(ids.indexOf(id) >= 0 && ids.indexOf(id) < ids.indexOf('services'), `${id} must precede services`);
|
||||||
}
|
}
|
||||||
const cards = [...html.matchAll(/<article\b([^>]*)>([\s\S]*?)<\/article>/giu)]
|
const cards = [...html.matchAll(/<article\b([^>]*)>([\s\S]*?)<\/article>/giu)]
|
||||||
.filter(([, tag]) => attributes(tag).get('class')?.split(/\s/u).includes('path-card'));
|
.filter(([, tag]) => attributes(tag).get('class')?.split(/\s/u).includes('path-card'));
|
||||||
assert.equal(cards.length, 4);
|
assert.equal(cards.length, 4);
|
||||||
for (const id of ['films', 'music', 'projects', 'gaming']) {
|
for (const id of ['films', 'music', 'projects', 'gaming']) {
|
||||||
assert.ok(cards.some(([, , card]) => links(card).some(link => link.attrs.get('href') === `#${id}`)));
|
assert.ok(cards.some(([, , card]) => links(card).some(link => link.attrs.get('href') === `#${id}`)));
|
||||||
}
|
}
|
||||||
for (const [, tag] of html.matchAll(/<[a-z]\w*\b([^>]*)>/giu)) {
|
for (const [, tag] of html.matchAll(/<[a-z]\w*\b([^>]*)>/giu)) {
|
||||||
for (const id of (attributes(tag).get('aria-labelledby') ?? '').split(/\s/u).filter(Boolean)) {
|
for (const id of (attributes(tag).get('aria-labelledby') ?? '').split(/\s/u).filter(Boolean)) {
|
||||||
assert.ok(ids.includes(id), `Broken aria-labelledby: ${id}`);
|
assert.ok(ids.includes(id), `Broken aria-labelledby: ${id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('featured films and music retain actual video destinations and labeled image links', async t => {
|
test('featured films and music retain actual video destinations and labeled image links', async t => {
|
||||||
const html = await homepage(t);
|
const html = await homepage(t);
|
||||||
const anchors = links(html);
|
const anchors = links(html);
|
||||||
for (const [id, label] of [
|
for (const [id, label] of [
|
||||||
['b16e17cc-2545-40ad-b9fa-b9200400aff5', /Great Smokey Mountains/iu],
|
['b16e17cc-2545-40ad-b9fa-b9200400aff5', /Great Smokey Mountains/iu],
|
||||||
['678e7a68-9beb-48b2-b4cc-8cfede773a81', /La Banda Maguey/iu],
|
['678e7a68-9beb-48b2-b4cc-8cfede773a81', /La Banda Maguey/iu],
|
||||||
['f9613754-234e-4053-b194-341154f007c5', /Tidal Waltz/iu],
|
['f9613754-234e-4053-b194-341154f007c5', /Tidal Waltz/iu],
|
||||||
]) {
|
]) {
|
||||||
const href = `https://peertube.eom.dev/videos/watch/${id}`;
|
const href = `https://peertube.eom.dev/videos/watch/${id}`;
|
||||||
const matching = anchors.filter(link => link.attrs.get('href') === href);
|
const matching = anchors.filter(link => link.attrs.get('href') === href);
|
||||||
assert.ok(matching.length > 0, `Missing featured video ${id}`);
|
assert.ok(matching.length > 0, `Missing featured video ${id}`);
|
||||||
const imageLink = matching.find(link => /<img\b/iu.test(link.inner));
|
const imageLink = matching.find(link => /<img\b/iu.test(link.inner));
|
||||||
assert.ok(imageLink, `Video ${id} needs an image link`);
|
assert.ok(imageLink, `Video ${id} needs an image link`);
|
||||||
assert.match(imageLink.attrs.get('aria-label') ?? text(imageLink.inner), label);
|
assert.match(imageLink.attrs.get('aria-label') ?? text(imageLink.inner), label);
|
||||||
}
|
}
|
||||||
for (const [tag] of html.matchAll(/<img\b[^>]*>/giu)) {
|
for (const [tag] of html.matchAll(/<img\b[^>]*>/giu)) {
|
||||||
const attrs = attributes(tag);
|
const attrs = attributes(tag);
|
||||||
assert.ok(attrs.has('alt'), 'Images need alt text, even if decorative');
|
assert.ok(attrs.has('alt'), 'Images need alt text, even if decorative');
|
||||||
assert.ok(Number(attrs.get('width')) > 0 && Number(attrs.get('height')) > 0);
|
assert.ok(Number(attrs.get('width')) > 0 && Number(attrs.get('height')) > 0);
|
||||||
assert.ok(attrs.get('src'));
|
assert.ok(attrs.get('src'));
|
||||||
}
|
}
|
||||||
for (const { attrs, inner } of anchors) {
|
for (const { attrs, inner } of anchors) {
|
||||||
const visible = text(inner.replace(/<[^>]*\baria-hidden="true"[^>]*>[\s\S]*?<\/[^>]+>/giu, ''));
|
const visible = text(inner.replace(/<[^>]*\baria-hidden="true"[^>]*>[\s\S]*?<\/[^>]+>/giu, ''));
|
||||||
const imageLabels = [...inner.matchAll(/<img\b[^>]*>/giu)]
|
const imageLabels = [...inner.matchAll(/<img\b[^>]*>/giu)]
|
||||||
.map(([tag]) => attributes(tag).get('alt') ?? '').join('');
|
.map(([tag]) => attributes(tag).get('alt') ?? '').join('');
|
||||||
assert.ok(attrs.get('aria-label')?.trim() || visible || imageLabels.trim(), `Unlabeled link ${attrs.get('href')}`);
|
assert.ok(attrs.get('aria-label')?.trim() || visible || imageLabels.trim(), `Unlabeled link ${attrs.get('href')}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prior game, service, account and donation destinations remain intact', async t => {
|
test('prior game, service, account and donation destinations remain intact', async t => {
|
||||||
const html = await homepage(t);
|
const html = await homepage(t);
|
||||||
const destinations = new Set(links(html).map(link => link.attrs.get('href')));
|
const destinations = new Set(links(html).map(link => link.attrs.get('href')));
|
||||||
for (const href of [
|
for (const href of [
|
||||||
'https://gitea.eom.dev/WikiDeck/wikideck', 'https://button.eom.dev/',
|
'https://gitea.eom.dev/WikiDeck/wikideck', 'https://button.eom.dev/',
|
||||||
'https://gitea.eom.dev/Gaming/the-button', 'https://discourse.eom.dev/t/luanti-season-v/690',
|
'https://gitea.eom.dev/Gaming/the-button', 'https://discourse.eom.dev/t/luanti-season-v/690',
|
||||||
'https://gitea.eom.dev/explore/repos', 'https://discourse.eom.dev/',
|
'https://gitea.eom.dev/explore/repos', 'https://discourse.eom.dev/',
|
||||||
'https://element.eom.dev/', 'https://gitea.eom.dev/', 'https://mastodon.eom.dev/',
|
'https://element.eom.dev/', 'https://gitea.eom.dev/', 'https://mastodon.eom.dev/',
|
||||||
'https://peertube.eom.dev/', 'https://owncast.eom.dev/', 'https://discourse.eom.dev/latest',
|
'https://peertube.eom.dev/', 'https://owncast.eom.dev/', 'https://discourse.eom.dev/latest',
|
||||||
'https://peertube.eom.dev/videos/browse', 'https://mastodon.eom.dev/@eric/',
|
'https://peertube.eom.dev/videos/browse', 'https://mastodon.eom.dev/@eric/',
|
||||||
'mailto:eric@eom.dev', '/eric@eom.dev.pub', '/members/', '/members/register/',
|
'mailto:eric@eom.dev', '/eric@eom.dev.pub', '/members/', '/members/register/',
|
||||||
'https://donate.stripe.com/dRm28qczOcDx05Nfd9cQU00', '/xmrig.json',
|
'https://donate.stripe.com/dRm28qczOcDx05Nfd9cQU00', '/xmrig.json',
|
||||||
]) assert.ok(destinations.has(href), `Missing preserved destination: ${href}`);
|
]) assert.ok(destinations.has(href), `Missing preserved destination: ${href}`);
|
||||||
assert.match(html, /luanti\.eom\.dev:30000/u);
|
assert.match(html, /luanti\.eom\.dev:30000/u);
|
||||||
assert.match(html, /48mFvxYDSCzMy5a3Zaj7ewApbX4GwXVXX9T1JC8mqBC19s9UcrNq8eaKkQ6GKdhHGQGmhdpvMd3mcbjf4eQbP8Yv7KdAREw/u);
|
assert.match(html, /48mFvxYDSCzMy5a3Zaj7ewApbX4GwXVXX9T1JC8mqBC19s9UcrNq8eaKkQ6GKdhHGQGmhdpvMd3mcbjf4eQbP8Yv7KdAREw/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('live publication regions and automatic collection browser are wired to the API module', async t => {
|
test('live publication regions and automatic collection browser are wired to the API module', async t => {
|
||||||
const html = await homepage(t);
|
const html = await homepage(t);
|
||||||
const regions = [...html.matchAll(/<(?:section|div)\b[^>]*\bdata-peertube-feed\b[^>]*>/giu)]
|
const regions = [...html.matchAll(/<(?:section|div)\b[^>]*\bdata-peertube-feed\b[^>]*>/giu)]
|
||||||
.map(([tag]) => attributes(tag));
|
.map(([tag]) => attributes(tag));
|
||||||
assert.deepEqual(regions.map(region => region.get('data-channels')),
|
assert.deepEqual(regions.map(region => region.get('data-channels')),
|
||||||
['eric_channel', 'eric_music', 'eric_tech', 'eric_gaming']);
|
['eric_channel', 'eric_music', 'eric_tech', 'eric_gaming']);
|
||||||
assert.equal([...html.matchAll(/\bdata-feed-items\b/gu)].length, 4);
|
assert.equal([...html.matchAll(/\bdata-feed-items\b/gu)].length, 4);
|
||||||
assert.equal([...html.matchAll(/\bdata-feed-status\b/gu)].length, 4);
|
assert.equal([...html.matchAll(/\bdata-feed-status\b/gu)].length, 4);
|
||||||
assert.match(html, /<script type="module" src="\/static\/peertube-feeds\.mjs"><\/script>/u);
|
assert.match(html, /<script type="module" src="\/static\/peertube-feeds\.mjs"><\/script>/u);
|
||||||
assert.match(html, /<label for="peertube-collection">/u);
|
assert.match(html, /<label for="peertube-collection">/u);
|
||||||
assert.match(html, /<select id="peertube-collection" disabled>/u);
|
assert.match(html, /<select id="peertube-collection" disabled>/u);
|
||||||
assert.match(html, /data-library-items/u);
|
assert.match(html, /data-library-items/u);
|
||||||
assert.match(html, /data-library-status role="status" aria-live="polite"/u);
|
assert.match(html, /data-library-status role="status" aria-live="polite"/u);
|
||||||
assert.match(html, /<noscript>/u);
|
assert.match(html, /<noscript>/u);
|
||||||
const response = await get(await preview(t), '/static/peertube-feeds.mjs');
|
const response = await get(await preview(t), '/static/peertube-feeds.mjs');
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
assert.equal(response.headers['content-type'], 'text/javascript; charset=utf-8');
|
assert.equal(response.headers['content-type'], 'text/javascript; charset=utf-8');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('gaming is separate from engineering and writing links to Discourse', async t => {
|
test('homepage theme music loads its module and seamless loop assets', async t => {
|
||||||
const html = await homepage(t);
|
const html = await homepage(t);
|
||||||
const sections = [...html.matchAll(/<section\b([^>]*)>([\s\S]*?)<\/section>/giu)]
|
assert.match(html, /<script type="module" src="\/static\/theme-player\.mjs"><\/script>/u);
|
||||||
.map(([, tag, content]) => ({ id: attributes(tag).get('id'), content }));
|
const server = await preview(t);
|
||||||
const engineering = sections.find(section => section.id === 'projects').content;
|
for (const [path, type] of [
|
||||||
const gaming = sections.find(section => section.id === 'gaming').content;
|
['/static/theme-player.mjs', 'text/javascript; charset=utf-8'],
|
||||||
assert.match(engineering, /data-channels="eric_tech"/u);
|
['/static/audio/eom-theme-loop.ogg', 'audio/ogg'],
|
||||||
assert.doesNotMatch(engineering, /eric_gaming|luanti\.eom\.dev:30000/u);
|
['/static/audio/eom-theme-loop.m4a', 'audio/mp4'],
|
||||||
assert.match(gaming, /data-channels="eric_gaming"/u);
|
['/static/audio/eom-theme.mp3', 'audio/mpeg'],
|
||||||
assert.match(gaming, /luanti\.eom\.dev:30000/u);
|
]) {
|
||||||
assert.match(gaming, /https:\/\/button\.eom\.dev\//u);
|
const response = await get(server, path);
|
||||||
assert.doesNotMatch(engineering, /<h3>Wikideck<\/h3>/u);
|
assert.equal(response.status, 200, path);
|
||||||
assert.match(gaming, /<h3>Wikideck<\/h3>/u);
|
assert.equal(response.headers['content-type'], type, path);
|
||||||
assert.ok(gaming.indexOf('<h3>Luanti</h3>') < gaming.indexOf('<h3>The Button</h3>'));
|
}
|
||||||
assert.ok(gaming.indexOf('<h3>Luanti</h3>') < gaming.indexOf('<h3>Wikideck</h3>'));
|
const player = await readFile(new URL('htdocs/static/theme-player.mjs', repository), 'utf8');
|
||||||
assert.ok(links(html).some(link => link.attrs.get('href') === 'https://discourse.eom.dev/' && /writing/iu.test(text(link.inner))));
|
// Sign-in, checkout and billing must stay real navigations, never fetched in place.
|
||||||
assert.match(html, /href="\/#gaming">Gaming/u);
|
for (const route of ['login', 'logout', 'oidc', 'checkout', 'billing', 'stripe', 'shkeeper'])
|
||||||
});
|
assert.match(player, new RegExp(`HARD_NAV = .*\\b${route}\\b`, 'u'), route);
|
||||||
|
});
|
||||||
test('my projects follows public cross-organization Gitea activity', async t => {
|
|
||||||
const html = await homepage(t);
|
test('gaming is separate from engineering and writing links to Discourse', async t => {
|
||||||
assert.match(html, /<h2 id="projects-title">My projects\.<\/h2>/u);
|
const html = await homepage(t);
|
||||||
assert.doesNotMatch(html, /Things I wanted to build/u);
|
const sections = [...html.matchAll(/<section\b([^>]*)>([\s\S]*?)<\/section>/giu)]
|
||||||
assert.match(html, /id="gitea-projects"/u);
|
.map(([, tag, content]) => ({ id: attributes(tag).get('id'), content }));
|
||||||
assert.match(html, /data-project-items/u);
|
const engineering = sections.find(section => section.id === 'projects').content;
|
||||||
assert.match(html, /data-project-status role="status" aria-live="polite"/u);
|
const gaming = sections.find(section => section.id === 'gaming').content;
|
||||||
assert.ok(links(html).some(link => link.attrs.get('href') === 'https://gitea.eom.dev/eric?tab=activity'));
|
assert.match(engineering, /data-channels="eric_tech"/u);
|
||||||
assert.match(html, /<script type="module" src="\/static\/gitea-projects\.mjs"><\/script>/u);
|
assert.doesNotMatch(engineering, /eric_gaming|luanti\.eom\.dev:30000/u);
|
||||||
const response = await get(await preview(t), '/static/gitea-projects.mjs');
|
assert.match(gaming, /data-channels="eric_gaming"/u);
|
||||||
assert.equal(response.status, 200);
|
assert.match(gaming, /luanti\.eom\.dev:30000/u);
|
||||||
assert.equal(response.headers['content-type'], 'text/javascript; charset=utf-8');
|
assert.match(gaming, /https:\/\/button\.eom\.dev\//u);
|
||||||
});
|
assert.doesNotMatch(engineering, /<h3>Wikideck<\/h3>/u);
|
||||||
|
assert.match(gaming, /<h3>Wikideck<\/h3>/u);
|
||||||
test('membership templates and routes preserve account and payment actions without emulation', async () => {
|
assert.ok(gaming.indexOf('<h3>Luanti</h3>') < gaming.indexOf('<h3>The Button</h3>'));
|
||||||
// Inspect only: no Django rendering, database, authentication, or network calls.
|
assert.ok(gaming.indexOf('<h3>Luanti</h3>') < gaming.indexOf('<h3>Wikideck</h3>'));
|
||||||
const template = await readFile(new URL('membership/templates/members.html', repository), 'utf8');
|
assert.ok(links(html).some(link => link.attrs.get('href') === 'https://discourse.eom.dev/' && /writing/iu.test(text(link.inner))));
|
||||||
for (const name of ['home', 'register', 'login', 'profile', 'portal', 'logout', 'checkout', 'crypto_checkout']) {
|
assert.match(html, /href="\/#gaming">Gaming/u);
|
||||||
assert.match(template, new RegExp(`\\{% url 'members:${name}'`), `Missing portal action ${name}`);
|
});
|
||||||
}
|
|
||||||
const routes = await readFile(new URL('membership/members/urls.py', repository), 'utf8');
|
test('my projects follows public cross-organization Gitea activity', async t => {
|
||||||
for (const route of ['register/', 'login/', 'logout/', 'profile/', 'billing/', 'checkout/<slug:tier>/']) {
|
const html = await homepage(t);
|
||||||
assert.ok(routes.includes(`path("${route}"`), `Missing account route ${route}`);
|
assert.match(html, /<h2 id="projects-title">My projects\.<\/h2>/u);
|
||||||
}
|
assert.doesNotMatch(html, /Things I wanted to build/u);
|
||||||
});
|
assert.match(html, /id="gitea-projects"/u);
|
||||||
|
assert.match(html, /data-project-items/u);
|
||||||
test('homepage groups creative sections before the library and membership distinguishes tier benefits', async t => {
|
assert.match(html, /data-project-status role="status" aria-live="polite"/u);
|
||||||
const html = await homepage(t);
|
assert.ok(links(html).some(link => link.attrs.get('href') === 'https://gitea.eom.dev/eric?tab=activity'));
|
||||||
const order = ['films', 'music', 'projects', 'gaming', 'peertube-library'];
|
assert.match(html, /<script type="module" src="\/static\/gitea-projects\.mjs"><\/script>/u);
|
||||||
const positions = order.map(id => html.indexOf(`id="${id}"`));
|
const response = await get(await preview(t), '/static/gitea-projects.mjs');
|
||||||
assert.ok(positions.every((position, index) => position >= 0 && (!index || position > positions[index - 1])));
|
assert.equal(response.status, 200);
|
||||||
const template = await readFile(new URL('membership/templates/members.html', repository), 'utf8');
|
assert.equal(response.headers['content-type'], 'text/javascript; charset=utf-8');
|
||||||
assert.match(template, /services included at your membership level/u);
|
});
|
||||||
assert.match(template, /Bronze adds exclusive Discourse and Matrix content; Silver adds previews when available; Gold adds project-topic polls/u);
|
|
||||||
assert.ok(template.indexOf('class="member-grid"') < template.indexOf('Community services by level'));
|
test('membership templates and routes preserve account and payment actions without emulation', async () => {
|
||||||
assert.match(template, /No paid-exclusive game servers are currently included/u);
|
// Inspect only: no Django rendering, database, authentication, or network calls.
|
||||||
assert.match(template, /There is no fixed release schedule/u);
|
const template = await readFile(new URL('membership/templates/members.html', repository), 'utf8');
|
||||||
assert.match(template, /personal consulting, managed hosting, guaranteed support/u);
|
for (const name of ['home', 'register', 'login', 'profile', 'portal', 'logout', 'checkout', 'crypto_checkout']) {
|
||||||
assert.doesNotMatch(template, /One account for all of eom\.dev/u);
|
assert.match(template, new RegExp(`\\{% url 'members:${name}'`), `Missing portal action ${name}`);
|
||||||
});
|
}
|
||||||
|
const routes = await readFile(new URL('membership/members/urls.py', repository), 'utf8');
|
||||||
test('creative positioning has no appointments or consulting sales calls', async t => {
|
for (const route of ['register/', 'login/', 'logout/', 'profile/', 'billing/', 'checkout/<slug:tier>/']) {
|
||||||
const html = await homepage(t);
|
assert.ok(routes.includes(`path("${route}"`), `Missing account route ${route}`);
|
||||||
const template = await readFile(new URL('membership/templates/members.html', repository), 'utf8');
|
}
|
||||||
for (const page of [html, template]) {
|
});
|
||||||
assert.doesNotMatch(page, /apps\/appointments|calendly\.com|cal\.com\//iu);
|
|
||||||
assert.doesNotMatch(text(page), /(?:book|schedule|buy|request)\s+(?:a\s+)?(?:free\s+)?(?:introductory\s+|sales\s+|consulting\s+)?(?:call|consultation)|hire me (?:for|to provide)/iu);
|
test('homepage groups creative sections before the library and membership distinguishes tier benefits', async t => {
|
||||||
}
|
const html = await homepage(t);
|
||||||
assert.match(text(html), /music/iu);
|
const order = ['films', 'music', 'projects', 'gaming', 'peertube-library'];
|
||||||
assert.match(text(html), /films/iu);
|
const positions = order.map(id => html.indexOf(`id="${id}"`));
|
||||||
assert.match(text(html), /creative engineering/iu);
|
assert.ok(positions.every((position, index) => position >= 0 && (!index || position > positions[index - 1])));
|
||||||
});
|
const template = await readFile(new URL('membership/templates/members.html', repository), 'utf8');
|
||||||
|
assert.match(template, /services included at your membership level/u);
|
||||||
test('preview serves static MIME, no-store/nosniff and HEAD without a body', async t => {
|
assert.match(template, /Bronze adds exclusive Discourse and Matrix content; Silver adds previews when available; Gold adds project-topic polls/u);
|
||||||
const server = await preview(t);
|
assert.ok(template.indexOf('class="member-grid"') < template.indexOf('Community services by level'));
|
||||||
for (const [path, mime] of [
|
assert.match(template, /No paid-exclusive game servers are currently included/u);
|
||||||
['/', 'text/html; charset=utf-8'], ['/index.html', 'text/html; charset=utf-8'],
|
assert.match(template, /There is no fixed release schedule/u);
|
||||||
['/static/home.css', 'text/css; charset=utf-8'], ['/static/favicon.svg', 'image/svg+xml'],
|
assert.match(template, /personal consulting, managed hosting, guaranteed support/u);
|
||||||
['/static/profile_2026.jpg', 'image/jpeg'], ['/favicon.ico', 'image/x-icon'],
|
assert.doesNotMatch(template, /One account for all of eom\.dev/u);
|
||||||
['/xmrig.json', 'application/json; charset=utf-8'], ['/robots.txt', 'text/plain; charset=utf-8'],
|
});
|
||||||
['/sitemap.xml', 'application/xml; charset=utf-8'],
|
|
||||||
]) {
|
test('creative positioning has no appointments or consulting sales calls', async t => {
|
||||||
const response = await get(server, path);
|
const html = await homepage(t);
|
||||||
assert.equal(response.status, 200, path);
|
const template = await readFile(new URL('membership/templates/members.html', repository), 'utf8');
|
||||||
assert.equal(response.headers['content-type'], mime, path);
|
for (const page of [html, template]) {
|
||||||
assert.equal(response.headers['cache-control'], 'no-store');
|
assert.doesNotMatch(page, /apps\/appointments|calendly\.com|cal\.com\//iu);
|
||||||
assert.equal(response.headers['x-content-type-options'], 'nosniff');
|
assert.doesNotMatch(text(page), /(?:book|schedule|buy|request)\s+(?:a\s+)?(?:free\s+)?(?:introductory\s+|sales\s+|consulting\s+)?(?:call|consultation)|hire me (?:for|to provide)/iu);
|
||||||
assert.equal(Number(response.headers['content-length']), response.body.length);
|
}
|
||||||
const head = await get(server, path, 'HEAD');
|
assert.match(text(html), /music/iu);
|
||||||
assert.equal(head.status, response.status);
|
assert.match(text(html), /films/iu);
|
||||||
assert.equal(head.headers['content-type'], mime);
|
assert.match(text(html), /creative engineering/iu);
|
||||||
assert.equal(head.headers['content-length'], response.headers['content-length']);
|
});
|
||||||
assert.equal(head.body.length, 0);
|
|
||||||
}
|
test('preview serves static MIME, no-store/nosniff and HEAD without a body', async t => {
|
||||||
assert.deepEqual((await get(server, '/?preview=true')).body, (await get(server, '/')).body);
|
const server = await preview(t);
|
||||||
});
|
for (const [path, mime] of [
|
||||||
|
['/', 'text/html; charset=utf-8'], ['/index.html', 'text/html; charset=utf-8'],
|
||||||
test('preview rejects traversal, dotfiles, malformed encodings, private files and unsafe methods', async t => {
|
['/static/home.css', 'text/css; charset=utf-8'], ['/static/favicon.svg', 'image/svg+xml'],
|
||||||
const server = await preview(t);
|
['/static/profile_2026.jpg', 'image/jpeg'], ['/favicon.ico', 'image/x-icon'],
|
||||||
for (const path of [
|
['/xmrig.json', 'application/json; charset=utf-8'], ['/robots.txt', 'text/plain; charset=utf-8'],
|
||||||
'/../README.md', '/static/../../membership/config/settings.py', '/%2e%2e/README.md',
|
['/sitemap.xml', 'application/xml; charset=utf-8'],
|
||||||
'/static/%2E%2E/index.html', '/%252e%252e/README.md', '/%2f../README.md',
|
]) {
|
||||||
'/static%5c..%5cindex.html', '/static\\..\\index.html', '/.env', '/%2eenv',
|
const response = await get(server, path);
|
||||||
'/.git/config', '/static/.secret', '/%', '/%GG', '/%C0%AF', '/%00',
|
assert.equal(response.status, 200, path);
|
||||||
'/index.html:secret', '/index.html.', '/index.html%20', '/CON', '//README.md',
|
assert.equal(response.headers['content-type'], mime, path);
|
||||||
]) {
|
assert.equal(response.headers['cache-control'], 'no-store');
|
||||||
const response = await get(server, path);
|
assert.equal(response.headers['x-content-type-options'], 'nosniff');
|
||||||
assert.ok([400, 403].includes(response.status), `${path}: ${response.status}`);
|
assert.equal(Number(response.headers['content-length']), response.body.length);
|
||||||
assert.equal(response.headers['x-content-type-options'], 'nosniff');
|
const head = await get(server, path, 'HEAD');
|
||||||
assert.equal(response.headers['cache-control'], 'no-store');
|
assert.equal(head.status, response.status);
|
||||||
assert.doesNotMatch(response.body.toString(), /Z:\\|Repositories|Traceback/u);
|
assert.equal(head.headers['content-type'], mime);
|
||||||
}
|
assert.equal(head.headers['content-length'], response.headers['content-length']);
|
||||||
for (const path of ['/README.md', '/membership/config/settings.py', '/conf/httpd.conf',
|
assert.equal(head.body.length, 0);
|
||||||
'/scripts/preview.mjs', '/tests/site.test.mjs', '/static/', '/missing.html']) {
|
}
|
||||||
assert.equal((await get(server, path)).status, 404, path);
|
assert.deepEqual((await get(server, '/?preview=true')).body, (await get(server, '/')).body);
|
||||||
assert.equal((await get(server, path, 'HEAD')).body.length, 0);
|
});
|
||||||
}
|
|
||||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE']) {
|
test('preview rejects traversal, dotfiles, malformed encodings, private files and unsafe methods', async t => {
|
||||||
const response = await get(server, '/', method);
|
const server = await preview(t);
|
||||||
assert.equal(response.status, 405, method);
|
for (const path of [
|
||||||
assert.equal(response.headers.allow, 'GET, HEAD');
|
'/../README.md', '/static/../../membership/config/settings.py', '/%2e%2e/README.md',
|
||||||
}
|
'/static/%2E%2E/index.html', '/%252e%252e/README.md', '/%2f../README.md',
|
||||||
assert.equal((await get(server, '/', 'GET', { Host: 'attacker.example' })).status, 403);
|
'/static%5c..%5cindex.html', '/static\\..\\index.html', '/.env', '/%2eenv',
|
||||||
});
|
'/.git/config', '/static/.secret', '/%', '/%GG', '/%C0%AF', '/%00',
|
||||||
|
'/index.html:secret', '/index.html.', '/index.html%20', '/CON', '//README.md',
|
||||||
test('members routes are explicitly preview-only, not authentication or billing emulation', async t => {
|
]) {
|
||||||
const server = await preview(t);
|
const response = await get(server, path);
|
||||||
for (const path of ['/members', '/members/', '/members/register/', '/members/login/',
|
assert.ok([400, 403].includes(response.status), `${path}: ${response.status}`);
|
||||||
'/members/profile/', '/members/billing/', '/members/checkout/gold/']) {
|
assert.equal(response.headers['x-content-type-options'], 'nosniff');
|
||||||
const response = await get(server, path);
|
assert.equal(response.headers['cache-control'], 'no-store');
|
||||||
assert.equal(response.status, 503, path);
|
assert.doesNotMatch(response.body.toString(), /Z:\\|Repositories|Traceback/u);
|
||||||
assert.match(response.body.toString(), /local preview only/iu);
|
}
|
||||||
assert.match(response.body.toString(), /dynamic membership portal is unavailable/iu);
|
for (const path of ['/README.md', '/membership/config/settings.py', '/conf/httpd.conf',
|
||||||
assert.doesNotMatch(response.body.toString(), /<form|<input|csrf|\{%/iu);
|
'/scripts/preview.mjs', '/tests/site.test.mjs', '/static/', '/missing.html']) {
|
||||||
assert.equal(response.headers['set-cookie'], undefined);
|
assert.equal((await get(server, path)).status, 404, path);
|
||||||
const head = await get(server, path, 'HEAD');
|
assert.equal((await get(server, path, 'HEAD')).body.length, 0);
|
||||||
assert.equal(head.status, 503);
|
}
|
||||||
assert.equal(head.body.length, 0);
|
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE']) {
|
||||||
assert.equal(head.headers['content-length'], response.headers['content-length']);
|
const response = await get(server, '/', method);
|
||||||
assert.equal((await get(server, path, 'POST')).status, 405);
|
assert.equal(response.status, 405, method);
|
||||||
}
|
assert.equal(response.headers.allow, 'GET, HEAD');
|
||||||
});
|
}
|
||||||
|
assert.equal((await get(server, '/', 'GET', { Host: 'attacker.example' })).status, 403);
|
||||||
async function fixture(t) {
|
});
|
||||||
const directory = await mkdtemp(join(tmpdir(), 'www-preview-'));
|
|
||||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
test('members routes are explicitly preview-only, not authentication or billing emulation', async t => {
|
||||||
const root = join(directory, 'htdocs');
|
const server = await preview(t);
|
||||||
await mkdir(join(root, 'common'), { recursive: true });
|
for (const path of ['/members', '/members/', '/members/register/', '/members/login/',
|
||||||
await writeFile(join(directory, 'private.txt'), 'outside-public-root');
|
'/members/profile/', '/members/billing/', '/members/checkout/gold/']) {
|
||||||
return { directory, root };
|
const response = await get(server, path);
|
||||||
}
|
assert.equal(response.status, 503, path);
|
||||||
|
assert.match(response.body.toString(), /local preview only/iu);
|
||||||
test('SSI is literal, nonrecursive and cannot include arbitrary files or execute directives', async t => {
|
assert.match(response.body.toString(), /dynamic membership portal is unavailable/iu);
|
||||||
const { root } = await fixture(t);
|
assert.doesNotMatch(response.body.toString(), /<form|<input|csrf|\{%/iu);
|
||||||
for (const name of ['header', 'topnav', 'footer']) {
|
assert.equal(response.headers['set-cookie'], undefined);
|
||||||
await writeFile(join(root, 'common', `${name}.html`), `<p>${name} $&</p>`);
|
const head = await get(server, path, 'HEAD');
|
||||||
}
|
assert.equal(head.status, 503);
|
||||||
const server = await preview(t, { publicRoot: root });
|
assert.equal(head.body.length, 0);
|
||||||
await writeFile(join(root, 'index.html'), ['header', 'topnav', 'footer']
|
assert.equal(head.headers['content-length'], response.headers['content-length']);
|
||||||
.map(name => `<!--#include virtual="/common/${name}.html" -->`).join('\n'));
|
assert.equal((await get(server, path, 'POST')).status, 405);
|
||||||
const response = await get(server);
|
}
|
||||||
assert.equal(response.status, 200);
|
});
|
||||||
assert.doesNotMatch(response.body.toString(), /<!--#/u);
|
|
||||||
assert.match(response.body.toString(), /header \$&/u);
|
async function fixture(t) {
|
||||||
for (const directive of [
|
const directory = await mkdtemp(join(tmpdir(), 'www-preview-'));
|
||||||
'<!--#include virtual="/../private.txt" -->', '<!--#include file="../private.txt" -->',
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||||
'<!--#exec cmd="echo should-never-execute" -->', '<!--#include virtual="/common/other.html" -->',
|
const root = join(directory, 'htdocs');
|
||||||
]) {
|
await mkdir(join(root, 'common'), { recursive: true });
|
||||||
await writeFile(join(root, 'index.html'), directive);
|
await writeFile(join(directory, 'private.txt'), 'outside-public-root');
|
||||||
const rejected = await get(server);
|
return { directory, root };
|
||||||
assert.equal(rejected.status, 500);
|
}
|
||||||
assert.doesNotMatch(rejected.body.toString(), /outside-public-root|should-never-execute/u);
|
|
||||||
}
|
test('SSI is literal, nonrecursive and cannot include arbitrary files or execute directives', async t => {
|
||||||
await writeFile(join(root, 'index.html'), '<!--#include virtual="/common/header.html" -->');
|
const { root } = await fixture(t);
|
||||||
await writeFile(join(root, 'common', 'header.html'), '<!--#include virtual="/common/footer.html" -->');
|
for (const name of ['header', 'topnav', 'footer']) {
|
||||||
assert.equal((await get(server)).status, 500);
|
await writeFile(join(root, 'common', `${name}.html`), `<p>${name} $&</p>`);
|
||||||
});
|
}
|
||||||
|
const server = await preview(t, { publicRoot: root });
|
||||||
test('realpath containment blocks directory symlinks/junctions, including common SSI directories', async t => {
|
await writeFile(join(root, 'index.html'), ['header', 'topnav', 'footer']
|
||||||
const { directory, root } = await fixture(t);
|
.map(name => `<!--#include virtual="/common/${name}.html" -->`).join('\n'));
|
||||||
const outside = join(directory, 'outside');
|
const response = await get(server);
|
||||||
await mkdir(outside);
|
assert.equal(response.status, 200);
|
||||||
await writeFile(join(outside, 'header.html'), 'outside-public-root');
|
assert.doesNotMatch(response.body.toString(), /<!--#/u);
|
||||||
try {
|
assert.match(response.body.toString(), /header \$&/u);
|
||||||
await symlink(outside, join(root, 'escape-dir'), process.platform === 'win32' ? 'junction' : 'dir');
|
for (const directive of [
|
||||||
} catch (error) {
|
'<!--#include virtual="/../private.txt" -->', '<!--#include file="../private.txt" -->',
|
||||||
if (['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) {
|
'<!--#exec cmd="echo should-never-execute" -->', '<!--#include virtual="/common/other.html" -->',
|
||||||
t.skip(`Directory symlinks unavailable on this host (${error.code}).`);
|
]) {
|
||||||
return;
|
await writeFile(join(root, 'index.html'), directive);
|
||||||
}
|
const rejected = await get(server);
|
||||||
throw error;
|
assert.equal(rejected.status, 500);
|
||||||
}
|
assert.doesNotMatch(rejected.body.toString(), /outside-public-root|should-never-execute/u);
|
||||||
const server = await preview(t, { publicRoot: root });
|
}
|
||||||
assert.equal((await get(server, '/escape-dir/header.html')).status, 403);
|
await writeFile(join(root, 'index.html'), '<!--#include virtual="/common/header.html" -->');
|
||||||
await rm(join(root, 'common'), { recursive: true });
|
await writeFile(join(root, 'common', 'header.html'), '<!--#include virtual="/common/footer.html" -->');
|
||||||
await symlink(outside, join(root, 'common'), process.platform === 'win32' ? 'junction' : 'dir');
|
assert.equal((await get(server)).status, 500);
|
||||||
await writeFile(join(root, 'index.html'), '<!--#include virtual="/common/header.html" -->');
|
});
|
||||||
const response = await get(server);
|
|
||||||
assert.equal(response.status, 403);
|
test('realpath containment blocks directory symlinks/junctions, including common SSI directories', async t => {
|
||||||
assert.doesNotMatch(response.body.toString(), /outside-public-root/u);
|
const { directory, root } = await fixture(t);
|
||||||
});
|
const outside = join(directory, 'outside');
|
||||||
|
await mkdir(outside);
|
||||||
test('realpath containment blocks file symlinks and hidden targets, including SSI', async t => {
|
await writeFile(join(outside, 'header.html'), 'outside-public-root');
|
||||||
const { directory, root } = await fixture(t);
|
try {
|
||||||
await writeFile(join(root, '.hidden.txt'), 'hidden-target');
|
await symlink(outside, join(root, 'escape-dir'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||||
try {
|
} catch (error) {
|
||||||
await symlink(join(directory, 'private.txt'), join(root, 'escape.txt'), 'file');
|
if (['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) {
|
||||||
await symlink(join(root, '.hidden.txt'), join(root, 'alias.txt'), 'file');
|
t.skip(`Directory symlinks unavailable on this host (${error.code}).`);
|
||||||
await symlink(join(directory, 'private.txt'), join(root, 'common', 'header.html'), 'file');
|
return;
|
||||||
} catch (error) {
|
}
|
||||||
if (['EPERM', 'EACCES', 'ENOSYS'].includes(error.code)) {
|
throw error;
|
||||||
t.skip(`File symlinks unavailable on this host (${error.code}); run on a symlink-capable host too.`);
|
}
|
||||||
return;
|
const server = await preview(t, { publicRoot: root });
|
||||||
}
|
assert.equal((await get(server, '/escape-dir/header.html')).status, 403);
|
||||||
throw error;
|
await rm(join(root, 'common'), { recursive: true });
|
||||||
}
|
await symlink(outside, join(root, 'common'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||||
await writeFile(join(root, 'index.html'), '<!--#include virtual="/common/header.html" -->');
|
await writeFile(join(root, 'index.html'), '<!--#include virtual="/common/header.html" -->');
|
||||||
const server = await preview(t, { publicRoot: root });
|
const response = await get(server);
|
||||||
for (const path of ['/escape.txt', '/alias.txt', '/']) {
|
assert.equal(response.status, 403);
|
||||||
const response = await get(server, path);
|
assert.doesNotMatch(response.body.toString(), /outside-public-root/u);
|
||||||
assert.equal(response.status, 403, path);
|
});
|
||||||
assert.doesNotMatch(response.body.toString(), /outside-public-root|hidden-target/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');
|
||||||
test('port parsing defaults to 8082 and accepts only numeric ports in range', () => {
|
try {
|
||||||
assert.equal(parsePort(), 8082);
|
await symlink(join(directory, 'private.txt'), join(root, 'escape.txt'), 'file');
|
||||||
assert.equal(parsePort('0'), 0);
|
await symlink(join(root, '.hidden.txt'), join(root, 'alias.txt'), 'file');
|
||||||
assert.equal(parsePort('65535'), 65535);
|
await symlink(join(directory, 'private.txt'), join(root, 'common', 'header.html'), 'file');
|
||||||
for (const value of ['', '-1', '65536', '1.5', '8082x', ' 8082', 'Infinity', '127.0.0.1:8082']) {
|
} catch (error) {
|
||||||
assert.throws(() => parsePort(value), /Port must be numeric/u);
|
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'), '<!--#include virtual="/common/header.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user