diff --git a/src/server.js b/src/server.js index 548cf94..47f04ed 100644 --- a/src/server.js +++ b/src/server.js @@ -28,6 +28,7 @@ import { webrtcPlugin } from './webrtc/index.js'; import { tunnelPlugin } from './tunnel/index.js'; import { terminalPlugin } from './terminal/index.js'; import { registerErrorHandler } from './utils/error-handler.js'; +import { seedServerRoot } from './ui/server-root.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -762,6 +763,48 @@ export function createServer(options = {}) { fastify.options('/', handleOptions); fastify.post('/', writeRateLimit, handlePost); + // Server-root landing page: seed /index.html and a public-read /.acl + // on first start (skip-if-exists, so operator-provided files are + // preserved). See #433 / #276. Skipped in read-only deployments so + // startup never mutates DATA_ROOT. + if (!options.readOnly) { + fastify.addHook('onReady', async () => { + // A missing or unreadable package.json (some production bundles + // omit it) shouldn't block seeding; fall back to "unknown". + let version = 'unknown'; + try { + const pkg = await readFile(join(__dirname, '..', 'package.json'), 'utf8'); + ({ version } = JSON.parse(pkg)); + } catch (err) { + fastify.log.warn({ err }, 'Failed to read package.json version; seeding server root with version=unknown'); + } + + try { + await seedServerRoot({ + version, + singleUser, + idp: idpEnabled, + singleUserName, + enabled: { + idp: idpEnabled, + nostr: nostrEnabled, + webrtc: webrtcEnabled, + activitypub: activitypubEnabled, + git: gitEnabled, + pay: payEnabled, + notifications: notificationsEnabled, + mashlib: mashlibEnabled, + mongo: mongoEnabled, + tunnel: tunnelEnabled, + terminal: terminalEnabled + } + }); + } catch (err) { + fastify.log.warn({ err }, 'Failed to seed server root'); + } + }); + } + // Single-user mode: create pod on startup if it doesn't exist if (singleUser) { fastify.addHook('onReady', async () => { diff --git a/src/ui/server-root.html b/src/ui/server-root.html new file mode 100644 index 0000000..cbcc35e --- /dev/null +++ b/src/ui/server-root.html @@ -0,0 +1,70 @@ + + + + + + {{title}} + + + + +
+

{{heading}}

+
{{subtitle}}
+

{{description}}

+ + {{actions}} + +
+
Version{{version}}
+
Mode{{mode}}
+
{{features}}
+
+ + +
+ + diff --git a/src/ui/server-root.js b/src/ui/server-root.js new file mode 100644 index 0000000..d406163 --- /dev/null +++ b/src/ui/server-root.js @@ -0,0 +1,173 @@ +/** + * Server-root landing page. + * + * Renders src/ui/server-root.html with runtime values, and seeds + * DATA_ROOT/index.html + DATA_ROOT/.acl on first start (skip-if-exists, + * so operator customisation is preserved). + * + * See issue #276. + */ + +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import * as storage from '../storage/filesystem.js'; +import { generatePublicReadAcl, serializeAcl } from '../wac/parser.js'; + +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. + */ +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 ')}
`; +} + +/** + * Render the landing page as an HTML string. + * + * @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 + */ +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, '"'); +} + + +/** + * 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. + * + * Default ACL: public read. No write access — the operator edits + * /index.html on disk, not via the web. + * + * If the HTML write fails (permissions, full disk, read-only DATA_ROOT), + * ACL seeding is aborted to avoid leaving the server with a public-read + * root ACL and no index page. + * + * @param {object} ctx - Same context passed to renderServerRoot + * @returns {Promise<{seededHtml: boolean, seededAcl: boolean, seededPageAcl: boolean}>} + */ +export async function seedServerRoot(ctx = {}) { + let seededHtml = false; + let seededAcl = false; + let seededPageAcl = false; + + // Seed /index.html if operator hasn't written one. + if (!(await storage.exists('/index.html'))) { + const html = renderServerRoot(ctx); + const ok = await storage.write('/index.html', html); + if (!ok) { + // Don't proceed with ACLs if the page itself failed to write — + // leaves us in a consistent unchanged state. + return { seededHtml: false, seededAcl: false, seededPageAcl: false }; + } + seededHtml = true; + } + + // Seed /.acl if one doesn't already exist. Public read on the container + // itself — so GET / serves the landing page. Independent of index.html. + // + // Use './' (relative to the .acl's own URL) rather than '/' (the + // origin root). The two coincide when JSS is mounted at the origin + // root, but only the relative form survives reverse-proxy mounts at + // a path prefix (e.g. https://example/jss/). This matches the + // pattern used by createPodStructure / createRootPodStructure since + // #428 / #430. + // + // (createRootPodStructure in single-user mode writes its own ACL and + // runs in a later hook, which will overwrite this if needed.) + if (!(await storage.exists('/.acl'))) { + const ok = await storage.write('/.acl', serializeAcl(generatePublicReadAcl('./'))); + if (ok) seededAcl = true; + } + + // Dedicated ACL for the landing page itself — public read. The container + // ACL above has no acl:default (we don't want to implicitly publish all + // children), so /index.html needs its own rule when fetched directly. + // Same relative-form rationale as above. + if (!(await storage.exists('/index.html.acl'))) { + const ok = await storage.write('/index.html.acl', serializeAcl(generatePublicReadAcl('./index.html'))); + if (ok) seededPageAcl = true; + } + + return { seededHtml, seededAcl, seededPageAcl }; +} diff --git a/test/server-root.test.js b/test/server-root.test.js new file mode 100644 index 0000000..1ae701c --- /dev/null +++ b/test/server-root.test.js @@ -0,0 +1,192 @@ +/** + * Server-root landing page seed (#276). + */ + +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 { startTestServer, stopTestServer, request, assertStatus } from './helpers.js'; + +describe('Server-root landing page', () => { + before(async () => { + await startTestServer(); + }); + + after(async () => { + await stopTestServer(); + }); + + it('seeds /index.html so GET / serves HTML', async () => { + 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/); + }); + + it('landing page is publicly readable (no auth required)', async () => { + const res = await request('/index.html'); + assertStatus(res, 200); + }); + + // Portability regression: the seeded ACLs must use './' (resolved + // against the .acl's own URL) rather than '/' (the origin root). + // The two coincide when JSS sits at the origin root, so a request + // smoke-test would pass either way; only direct inspection of the + // serialized accessTo catches a regression to the absolute form. + // Without this, JSS mounted under a reverse-proxy path prefix would + // see the seeded ACL match the origin root rather than the prefix. + it('seeded ACLs use relative resourceUrl ("./" / "./index.html"), not absolute paths', async () => { + const rootAcl = JSON.parse(await fs.readFile('./data/.acl', 'utf8')); + const pageAcl = JSON.parse(await fs.readFile('./data/index.html.acl', 'utf8')); + const rootAccessTo = rootAcl['@graph'][0]['acl:accessTo']['@id']; + const pageAccessTo = pageAcl['@graph'][0]['acl:accessTo']['@id']; + assert.strictEqual(rootAccessTo, './', + `Expected /.acl accessTo to be relative './', got '${rootAccessTo}'`); + assert.strictEqual(pageAccessTo, './index.html', + `Expected /index.html.acl accessTo to be relative './index.html', got '${pageAccessTo}'`); + }); +}); + +// Operator's existing /index.html is preserved — dedicated server + data dir. +describe('Server-root landing — operator override', () => { + let server; + let baseUrl; + let savedDataRoot; + const DATA_DIR = './test-data-server-root-override'; + const CUSTOM_HTML = '<!doctype html><html><body>my custom page</body></html>'; + + before(async () => { + // Capture process.env.DATA_ROOT — createServer mutates it when options.root + // is provided. Restore in after() to avoid cross-test interference with + // suites that rely on the default ./data dir. + savedDataRoot = process.env.DATA_ROOT; + + await fs.remove(DATA_DIR); + await fs.ensureDir(DATA_DIR); + await fs.writeFile(`${DATA_DIR}/index.html`, CUSTOM_HTML); + + server = createServer({ + logger: false, + root: DATA_DIR, + forceCloseConnections: true, + }); + await server.listen({ port: 0, host: '127.0.0.1' }); + baseUrl = `http://127.0.0.1:${server.server.address().port}`; + }); + + after(async () => { + await server.close(); + await fs.remove(DATA_DIR); + if (savedDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = savedDataRoot; + }); + + it('does not overwrite operator-provided /index.html', async () => { + const current = await fs.readFile(`${DATA_DIR}/index.html`, 'utf8'); + assert.strictEqual(current, CUSTOM_HTML); + }); + + it('GET / serves operator custom page', async () => { + const res = await fetch(`${baseUrl}/`, { headers: { Accept: 'text/html' } }); + assert.strictEqual(res.status, 200); + const body = await res.text(); + assert.match(body, /my custom page/); + }); +}); + +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"/); + 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('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'); + }); +});