Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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 () => {
Expand Down
70 changes: 70 additions & 0 deletions src/ui/server-root.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}}</title>
<meta name="description" content="A personal data server powered by JSS">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: Georgia, 'Times New Roman', serif;
background: #fafaf8;
color: #2c2c2c;
line-height: 1.7;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.container { max-width: 560px; width: 100%; }
h1 { font-size: 2.2rem; font-weight: 400; margin-bottom: 0.25rem; }
.subtitle { color: #666; font-size: 1.05rem; margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #ddd; }
p { margin-bottom: 1.25rem; }
.actions { display: flex; gap: 0.75rem; margin: 1.5rem 0 2rem; flex-wrap: wrap; }
.btn {
display: inline-block;
padding: 0.6rem 1.2rem;
border-radius: 4px;
text-decoration: none;
font-family: Georgia, serif;
font-size: 0.95rem;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.1s;
}
.btn-primary { background: #7c3aed; color: #fff; }
.btn-primary:hover { background: #6025c0; }
.btn-secondary { background: #fff; color: #2c2c2c; border-color: #ccc; }
.btn-secondary:hover { background: #f0efeb; }
.info { background: #f5f4f0; border-radius: 4px; padding: 1rem; font-size: 0.85rem; color: #666; margin-top: 1.5rem; }
.info .row { display: flex; justify-content: space-between; padding: 0.2rem 0; }
.info .label { color: #999; }
.info code { font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.9em; color: #555; }
footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd; color: #999; font-size: 0.8rem; text-align: center; }
footer a { color: #888; }
.features { font-size: 0.85rem; color: #666; margin-top: 0.5rem; }
.features span { display: inline-block; background: #eee; padding: 0.15rem 0.5rem; border-radius: 3px; margin-right: 0.3rem; margin-bottom: 0.3rem; font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.8rem; }
</style>
</head>
<body>
<div class="container">
<h1>{{heading}}</h1>
<div class="subtitle">{{subtitle}}</div>
<p>{{description}}</p>

{{actions}}

<div class="info">
<div class="row"><span class="label">Version</span><code>{{version}}</code></div>
<div class="row"><span class="label">Mode</span><code>{{mode}}</code></div>
<div class="features">{{features}}</div>
</div>

<footer>
Powered by <a href="https://jss.live">JSS</a>
</footer>
</div>
</body>
</html>
151 changes: 151 additions & 0 deletions src/ui/server-root.js
Original file line number Diff line number Diff line change
@@ -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');
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The landing page feature list omits the terminal feature flag even though the server supports options.terminal (and other features are listed). This means the UI can incorrectly report enabled features. Add a terminal entry (and any other intended flags) in listFeatures() so the landing page accurately reflects the configured server capabilities.

Suggested change
if (options.mongo) f.push('mongo');
if (options.mongo) f.push('mongo');
if (options.terminal) f.push('terminal');

Copilot uses AI. Check for mistakes.
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('<a href="/idp/register" class="btn btn-primary">Create a pod</a>');
buttons.push('<a href="/idp" class="btn btn-secondary">Sign in</a>');
Comment on lines +42 to +46
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mode-specific rendering is implemented in renderActions() (e.g. “Create a pod”/“Sign in” buttons), but the added tests only assert generic text and don’t verify these mode-dependent outputs. Adding a few unit tests for renderServerRoot() with different ctx combinations (multi-user+IdP, single-user+IdP, etc.) would lock in the behavior described in the PR.

Copilot uses AI. Check for mistakes.
} else if (singleUser && idp) {
buttons.push('<a href="/idp" class="btn btn-primary">Sign in</a>');
}
buttons.push('<a href="https://javascriptsolidserver.github.io/docs/" class="btn btn-secondary">Docs</a>');
return `<div class="actions">${buttons.join('\n ')}</div>`;
}

/**
* 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 => `<span>${f}</span>`)
.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);
Comment on lines +83 to +90
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String.prototype.replace treats $&, $1, etc. in the replacement string as special substitution patterns. Since subtitle (and potentially other interpolated values) can include user-configurable data like singleUserName, values containing $ (e.g. $&) will be mangled during the template replacement. Use a replacer function (e.g. replace(/{{subtitle}}/g, () => subtitle)) or escape $ in replacement values before calling replace to ensure literal output.

Suggested change
.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);
.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);

Copilot uses AI. Check for mistakes.
}

function escape(s = '') {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}


/**
* 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 };
}
Loading