diff --git a/src/ui/server-root.html b/src/ui/server-root.html index cbcc35e..f5733cc 100644 --- a/src/ui/server-root.html +++ b/src/ui/server-root.html @@ -3,68 +3,129 @@ - {{title}} - + JSS Solid pod +
-

{{heading}}

-
{{subtitle}}
-

{{description}}

+

Welcome

+ +

Your JSS Solid pod is running at this server.

+

This is a Solid pod server. Solid is an open standard for personal data — your data lives in pods you own, and apps connect to it.

- {{actions}} - -
-
Version{{version}}
-
Mode{{mode}}
-
{{features}}
+ + + +
- Powered by JSS + Powered by JSS · GitHub + Customise this page: edit /index.html in your data dir
+ + diff --git a/src/ui/server-root.js b/src/ui/server-root.js index d406163..08fa8bb 100644 --- a/src/ui/server-root.js +++ b/src/ui/server-root.js @@ -18,100 +18,49 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATE_PATH = join(__dirname, 'server-root.html'); /** - * Collect the list of enabled features for display on the landing page. - */ -function listFeatures(options = {}) { - const f = []; - if (options.idp) f.push('idp'); - if (options.nostr) f.push('nostr'); - if (options.webrtc) f.push('webrtc'); - if (options.activitypub) f.push('activitypub'); - if (options.git) f.push('git'); - if (options.pay) f.push('payments'); - if (options.notifications) f.push('notifications'); - if (options.mashlib) f.push('mashlib'); - if (options.mongo) f.push('mongo'); - if (options.tunnel) f.push('tunnel'); - if (options.terminal) f.push('terminal'); - return f; -} - -/** - * Build an HTML snippet of action buttons based on server mode. + * Read the landing page template and return it as an HTML string. + * + * The seeded HTML is fully static — no template substitution. Anything + * we used to render in (mode, enabled features, version) would have + * gone stale on the next mode change or upgrade because the seed is + * skip-if-exists. They've been dropped from the template; the CLI + * banner lists them at startup, and Sign up / Sign in adapt at load + * time via the inline HEAD probe (see decideRevealForRegisterStatus + * below for the matrix that the inline script implements). + * + * The function still takes (and ignores) a `_ctx` arg for forward + * compatibility — callers (seedServerRoot, server.js) pass one. + * + * @param {object} [_ctx] - Reserved; currently unused. + * @returns {string} HTML */ -function renderActions({ singleUser, idp }) { - const buttons = []; - if (!singleUser && idp) { - buttons.push('Create a pod'); - buttons.push('Sign in'); - } else if (singleUser && idp) { - buttons.push('Sign in'); - } - buttons.push('Docs'); - return `
${buttons.join('\n ')}
`; +// eslint-disable-next-line no-unused-vars +export function renderServerRoot(_ctx = {}) { + return readFileSync(TEMPLATE_PATH, 'utf8'); } /** - * Render the landing page as an HTML string. + * Decide which conditional buttons (Sign up, Sign in) to reveal based + * on the response status of `HEAD /idp/register`. Pure function so the + * 200 / 403 / 404 matrix can be unit-tested without DOM. The inline + * script in server-root.html implements the same matrix literally; + * keep them in sync. * - * @param {object} ctx - * @param {string} ctx.version - JSS version - * @param {boolean} [ctx.singleUser] - * @param {boolean} [ctx.idp] - * @param {string} [ctx.singleUserName] - * @param {object} [ctx.enabled] - Map of feature flags - * @returns {string} HTML + * 200 → registration open: reveal both Sign up and Sign in + * 403 → IDP enabled but registration disabled (single-user mode): + * reveal Sign in only + * anything else (404, network error) → reveal neither (no IDP) + * + * @param {number|undefined} status - HTTP status code, or undefined for + * network error. + * @returns {{ register: boolean, login: boolean }} */ -export function renderServerRoot(ctx = {}) { - const { version = 'unknown', singleUser = false, idp = false, singleUserName, enabled = {} } = ctx; - - const tpl = readFileSync(TEMPLATE_PATH, 'utf8'); - const mode = singleUser ? 'single-user' : 'multi-user'; - const features = listFeatures(enabled) - .map(f => `${f}`) - .join(' '); - - const heading = 'JSS'; - const subtitle = singleUser - ? `Personal pod${singleUserName && singleUserName !== '/' ? ` for ${escape(singleUserName)}` : ''}` - : 'A personal data server'; - const description = singleUser - ? 'This server hosts a personal data pod. Apps come to the data rather than the other way around.' - : 'This server hosts personal data pods on the web. Each pod is a space you own, with your own identity and access control.'; - - // Single-pass token substitution. Sequential .replace() calls would - // re-scan already-substituted values, so a `singleUserName` of e.g. - // `{{actions}}` would land inside `subtitle`, then get expanded by - // the later `.replace(/{{actions}}/g, …)` — letting a pod owner - // inject other template fragments via their name. With a single - // pass over the original template, each {{token}} is matched once - // and replaced with its value; `$` inside any value is also harmless - // because the function form of replace skips substitution patterns. - // See #433 review thread. - const values = { - title: heading, - heading, - subtitle, - description, - actions: renderActions({ singleUser, idp }), - version: escape(version), - mode, - features - }; - return tpl.replace(/{{(\w+)}}/g, (match, key) => - Object.prototype.hasOwnProperty.call(values, key) ? values[key] : match - ); -} - -function escape(s = '') { - return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); +export function decideRevealForRegisterStatus(status) { + if (status === 200) return { register: true, login: true }; + if (status === 403) return { register: false, login: true }; + return { register: false, login: false }; } - /** * Seed DATA_ROOT/index.html, DATA_ROOT/.acl and DATA_ROOT/index.html.acl * if they don't already exist. Operator's own files are never overwritten. diff --git a/test/server-root.test.js b/test/server-root.test.js index 1ae701c..8003901 100644 --- a/test/server-root.test.js +++ b/test/server-root.test.js @@ -1,12 +1,18 @@ /** - * Server-root landing page seed (#276). + * Server-root landing page seed (#276 / #433 / #435). + * + * Phase 3 (#433) seeded a mode-specific landing page that went stale on + * mode change. Phase 3 refinement (#435) replaced the mode-specific copy + * with a single mode-agnostic page that adapts at load time via a HEAD + * probe against /idp/register, so the same seeded HTML keeps working + * across modes without regenerating the file. */ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert'; import fs from 'fs-extra'; import { createServer } from '../src/server.js'; -import { renderServerRoot } from '../src/ui/server-root.js'; +import { renderServerRoot, decideRevealForRegisterStatus } from '../src/ui/server-root.js'; import { startTestServer, stopTestServer, request, assertStatus } from './helpers.js'; describe('Server-root landing page', () => { @@ -22,8 +28,8 @@ describe('Server-root landing page', () => { const res = await request('/', { headers: { Accept: 'text/html' } }); assertStatus(res, 200); const body = await res.text(); - assert.match(body, /JSS<\/title>/); - assert.match(body, /A personal data server/); + assert.match(body, /<title>JSS Solid pod<\/title>/); + assert.match(body, /Your JSS Solid pod is running/); }); it('landing page is publicly readable (no auth required)', async () => { @@ -97,96 +103,147 @@ describe('Server-root landing — operator override', () => { }); }); -describe('renderServerRoot — mode-specific output', () => { - it('multi-user + IDP shows Create a pod + Sign in', () => { - const html = renderServerRoot({ version: '1.0.0', singleUser: false, idp: true }); - assert.match(html, /Create a pod/); - assert.match(html, /href="\/idp\/register"/); - assert.match(html, /href="\/idp"/); +describe('renderServerRoot', () => { + // The seeded HTML is fully static — no template substitution, no + // values vary by request. This is deliberate: anything dynamic + // (mode, features, version) goes stale on the next mode change or + // upgrade because the seed is skip-if-exists. Static = honest. + it('renders byte-identical HTML regardless of the ctx passed', () => { + const a = renderServerRoot({ version: '1.0.0', singleUser: true, enabled: { idp: true } }); + const b = renderServerRoot({ version: '99.0.0', singleUser: false, enabled: {} }); + const c = renderServerRoot(); + assert.strictEqual(a, b); + assert.strictEqual(b, c); + assert.match(a, /<h1>Welcome<\/h1>/); + assert.match(a, /Your JSS Solid pod is running/); + assert.match(a, /open standard for personal data/); + }); + + it('does not bake mode, feature, or version pills into the seeded HTML', () => { + // All three would go stale across mode changes / upgrades because + // the seed is skip-if-exists. The CLI banner already lists them + // at startup. Excluded from the seed entirely. + const html = renderServerRoot({ version: '1.2.3', singleUser: true, enabled: { idp: true, nostr: true } }); + assert.doesNotMatch(html, /<code>single-user<\/code>/); + assert.doesNotMatch(html, /<span>idp<\/span>/); + assert.doesNotMatch(html, /<span>nostr<\/span>/); + assert.doesNotMatch(html, /<code>1\.2\.3<\/code>/); + // No info box at all — there's nothing left to put in it. + assert.doesNotMatch(html, /class="info"/); + }); + + it('always emits the Get started button pointing at the docs introduction', () => { + const html = renderServerRoot({ version: '1.0.0' }); + // The canonical URL is the introduction page, not the category. + // Docusaurus 3 doesn't auto-generate a category index page, so + // /docs/getting-started/ would 404. Link to the real document. + assert.match(html, /href="https:\/\/jss\.live\/docs\/getting-started\/introduction"/); + assert.match(html, /Get started/); + }); + + it('emits Sign up + Sign in buttons hidden for the HEAD probe to reveal, with page-relative hrefs', () => { + const html = renderServerRoot({ version: '1.0.0' }); + // Page-relative (./idp/...) so a reverse-proxy mount under a path + // prefix sends visitors into the correct prefix instead of the + // origin root. Same rationale as the ./ ACL targets from #428. + assert.match(html, /<a href="\.\/idp\/register"[^>]*data-cond="register"[^>]*hidden/); + assert.match(html, /<a href="\.\/idp"[^>]*data-cond="login"[^>]*hidden/); + assert.doesNotMatch(html, /<a href="\/idp\/register"/, + 'Sign up href must be page-relative for path-prefix portability'); + assert.doesNotMatch(html, /<a href="\/idp"/, + 'Sign in href must be page-relative for path-prefix portability'); + assert.match(html, /Sign up/); assert.match(html, /Sign in/); }); - it('single-user + IDP shows Sign in only (no Create a pod)', () => { - const html = renderServerRoot({ version: '1.0.0', singleUser: true, idp: true, singleUserName: 'alice' }); - assert.doesNotMatch(html, /Create a pod/); - assert.match(html, /Sign in/); - }); - - it('multi-user without IDP shows only the Docs link', () => { - const html = renderServerRoot({ version: '1.0.0', singleUser: false, idp: false }); - assert.doesNotMatch(html, /Create a pod/); - assert.doesNotMatch(html, /Sign in/); - assert.match(html, /Docs/); - }); - - it('single-user subtitle includes the pod name when provided', () => { - const html = renderServerRoot({ version: '1.0.0', singleUser: true, idp: false, singleUserName: 'alice' }); - assert.match(html, /Personal pod for alice/); + it('overrides the .btn display rule for the [hidden] attribute so the buttons actually start hidden', () => { + // Without an explicit !important [hidden] rule, the .btn class's + // display:inline-block beats the UA stylesheet's [hidden]{display:none} + // and the Sign up / Sign in anchors flash visible before the HEAD probe + // finishes. The CSS rule is the load-bearing piece; assert it's there. + const html = renderServerRoot({ version: '1.0.0' }); + assert.match(html, /\[hidden\]\s*\{[^}]*display:\s*none\s*!important/); + }); + + it('includes the HEAD-adaptive script targeting ./idp/register (page-relative)', () => { + const html = renderServerRoot({ version: '1.0.0' }); + // Same path-prefix portability concern as the anchor hrefs. + assert.match(html, /fetch\(['"]\.\/idp\/register['"]/); + assert.doesNotMatch(html, /fetch\(['"]\/idp\/register['"]/, + 'HEAD probe URL must be page-relative for path-prefix portability'); + assert.match(html, /method:\s*['"]HEAD['"]/); + assert.match(html, /res\.status === 200/); + assert.match(html, /res\.status === 403/); + }); + + it('includes the live-URL script that resolves the document base, not just origin', () => { + const html = renderServerRoot({ version: '1.0.0' }); + assert.match(html, /id="server-url"/); + // Must use new URL('./', window.location.href) so a reverse-proxy + // mount at a path prefix is preserved. window.location.origin alone + // would drop the prefix and display the proxy origin instead. + assert.match(html, /new URL\(['"]\.\/['"],\s*window\.location\.href\)/); + assert.doesNotMatch(html, /textContent\s*=\s*window\.location\.origin/, + 'live-URL must not display origin-only — it would drop the path prefix on reverse-proxy mounts'); + }); + + it('uses a page-relative fallback href on the live-URL anchor', () => { + // Before the script runs (or if scripts are blocked / blocked by CSP), + // the anchor is still clickable. A href="/" fallback would escape + // any reverse-proxy path prefix; use href="./" so the link stays + // inside the mount. + const html = renderServerRoot({ version: '1.0.0' }); + assert.match(html, /<a id="server-url" href="\.\/"/); + assert.doesNotMatch(html, /<a id="server-url" href="\/"/, + 'fallback href must be page-relative for path-prefix portability'); + }); + + it('points the footer at the GitHub repo and the customise hint', () => { + const html = renderServerRoot({ version: '1.0.0' }); + assert.match(html, /href="https:\/\/github\.com\/JavaScriptSolidServer\/JavaScriptSolidServer"/); + assert.match(html, /Customise this page/); + assert.match(html, /<code>\/index\.html<\/code>/); }); +}); - it('lists enabled features', () => { - const html = renderServerRoot({ - version: '1.0.0', - singleUser: false, - idp: true, - enabled: { idp: true, nostr: true, webrtc: true, terminal: true } - }); - assert.match(html, /<span>idp<\/span>/); - assert.match(html, /<span>nostr<\/span>/); - assert.match(html, /<span>webrtc<\/span>/); - assert.match(html, /<span>terminal<\/span>/); - }); - - it('interpolates version', () => { - const html = renderServerRoot({ version: '9.9.9' }); - assert.match(html, /<code>9\.9\.9<\/code>/); - }); - - it('escapes version to prevent injection', () => { - const html = renderServerRoot({ version: '<script>alert(1)</script>' }); - assert.doesNotMatch(html, /<script>alert\(1\)<\/script>/); - assert.match(html, /<script>/); - }); - - // Regression for token re-scanning (#433 review thread): if the - // renderer ran a chain of sequential .replace() calls, a value - // containing a literal `{{actions}}` would land inside the subtitle - // and then get expanded by the later `.replace(/{{actions}}/g, ...)`, - // letting any pod owner inject other template fragments via their - // singleUserName. The single-pass substitution prevents that. - it('does not re-scan substituted values for further template tokens', () => { - const html = renderServerRoot({ - version: '1.0.0', - singleUser: true, - idp: false, - // The HTML escape only touches & < > " — { } pass through, so the - // token would land in the output verbatim if the substitution were - // multi-pass. - singleUserName: 'evil{{actions}}name' - }); - assert.match(html, /Personal pod for evil\{\{actions\}\}name/, - 'singleUserName containing a template token should appear as plain text, not be re-templated'); - // Sanity: the real {{actions}} slot is still resolved (Docs link is always present). - assert.match(html, /href="https:\/\/javascriptsolidserver\.github\.io\/docs/); - }); - - // Regression for the `$&` substitution gotcha (#433): a string used as - // the second argument of String.prototype.replace interprets `$&`, - // `$1`, etc. as substitution patterns. Interpolated values can contain - // `$` (notably a singleUserName), so the renderer uses the function - // form of replace instead. Asserting the literal `$&` survives the - // round-trip would mean it survived as plain text. - it('preserves $-patterns in singleUserName instead of treating them as replacement specials', () => { - const html = renderServerRoot({ - version: '1.0.0', - singleUser: true, - idp: false, - singleUserName: 'foo$&bar' - }); - // The HTML escape converts `&` to `&`; the rest must stay verbatim, - // not be replaced by the matched template token. - assert.match(html, /Personal pod for foo\$&bar/, - 'singleUserName containing "$&" should land as-is, not trigger String.replace substitution'); - assert.doesNotMatch(html, /\{\{subtitle\}\}/, 'subtitle token should be fully consumed'); +// Pure-function unit tests for the HEAD response → button-reveal matrix. +// The inline script in server-root.html implements the same matrix by +// hand; a regex check on the script text (above) catches outright drops +// of the literals, but only this helper test pins down the *behaviour* +// of the matrix without needing a DOM. +describe('decideRevealForRegisterStatus', () => { + it('reveals both Sign up and Sign in for HTTP 200 (registration open)', () => { + assert.deepStrictEqual( + decideRevealForRegisterStatus(200), + { register: true, login: true } + ); + }); + + it('reveals only Sign in for HTTP 403 (single-user — registration disabled)', () => { + assert.deepStrictEqual( + decideRevealForRegisterStatus(403), + { register: false, login: true } + ); + }); + + it('reveals neither for HTTP 404 (no IDP)', () => { + assert.deepStrictEqual( + decideRevealForRegisterStatus(404), + { register: false, login: false } + ); + }); + + it('reveals neither for any other status (e.g. 500)', () => { + assert.deepStrictEqual( + decideRevealForRegisterStatus(500), + { register: false, login: false } + ); + }); + + it('reveals neither when status is undefined (network error)', () => { + assert.deepStrictEqual( + decideRevealForRegisterStatus(undefined), + { register: false, login: false } + ); }); });