diff --git a/src/server.js b/src/server.js index c914b74..ee96a48 100644 --- a/src/server.js +++ b/src/server.js @@ -21,6 +21,7 @@ import { dbPlugin } from './db/index.js'; import { webrtcPlugin } from './webrtc/index.js'; import { tunnelPlugin } from './tunnel/index.js'; import { terminalPlugin } from './terminal/index.js'; +import { seedServerRoot } from './ui/server-root.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -545,6 +546,48 @@ export function createServer(options = {}) { fastify.options('/', handleOptions); fastify.post('/', writeRateLimit, handlePost); + // Server-root landing page: seed /index.html and /.acl on first start + // (skip-if-exists, operator customisations preserved). See #276. + // Skipped in read-only mode — startup must not mutate DATA_ROOT. + if (!options.readOnly) { + fastify.addHook('onReady', async () => { + // Read the version from package.json — a missing or unreadable file + // (some production bundles omit it) shouldn't block seeding; fall + // back to "unknown" and continue. + 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..5923945 --- /dev/null +++ b/src/ui/server-root.js @@ -0,0 +1,151 @@ +/** + * 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.'; + + return tpl + .replace(/{{title}}/g, heading) + .replace(/{{heading}}/g, heading) + .replace(/{{subtitle}}/g, subtitle) + .replace(/{{description}}/g, description) + .replace(/{{actions}}/g, renderActions({ singleUser, idp })) + .replace(/{{version}}/g, escape(version)) + .replace(/{{mode}}/g, mode) + .replace(/{{features}}/g, features); +} + +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. + // (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. + 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..5f67753 --- /dev/null +++ b/test/server-root.test.js @@ -0,0 +1,132 @@ +/** + * 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); + }); +}); + +// 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>/); + }); +});