Skip to content

Commit a1aea75

Browse files
Merge pull request JavaScriptSolidServer#434 from JavaScriptSolidServer/issue-433-landing-page-seeding
feat: default landing page + ACL at server root (JavaScriptSolidServer#433, supersedes JavaScriptSolidServer#303)
2 parents fd04b60 + 69b74ba commit a1aea75

4 files changed

Lines changed: 478 additions & 0 deletions

File tree

src/server.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { webrtcPlugin } from './webrtc/index.js';
2828
import { tunnelPlugin } from './tunnel/index.js';
2929
import { terminalPlugin } from './terminal/index.js';
3030
import { registerErrorHandler } from './utils/error-handler.js';
31+
import { seedServerRoot } from './ui/server-root.js';
3132

3233
const __dirname = dirname(fileURLToPath(import.meta.url));
3334

@@ -762,6 +763,48 @@ export function createServer(options = {}) {
762763
fastify.options('/', handleOptions);
763764
fastify.post('/', writeRateLimit, handlePost);
764765

766+
// Server-root landing page: seed /index.html and a public-read /.acl
767+
// on first start (skip-if-exists, so operator-provided files are
768+
// preserved). See #433 / #276. Skipped in read-only deployments so
769+
// startup never mutates DATA_ROOT.
770+
if (!options.readOnly) {
771+
fastify.addHook('onReady', async () => {
772+
// A missing or unreadable package.json (some production bundles
773+
// omit it) shouldn't block seeding; fall back to "unknown".
774+
let version = 'unknown';
775+
try {
776+
const pkg = await readFile(join(__dirname, '..', 'package.json'), 'utf8');
777+
({ version } = JSON.parse(pkg));
778+
} catch (err) {
779+
fastify.log.warn({ err }, 'Failed to read package.json version; seeding server root with version=unknown');
780+
}
781+
782+
try {
783+
await seedServerRoot({
784+
version,
785+
singleUser,
786+
idp: idpEnabled,
787+
singleUserName,
788+
enabled: {
789+
idp: idpEnabled,
790+
nostr: nostrEnabled,
791+
webrtc: webrtcEnabled,
792+
activitypub: activitypubEnabled,
793+
git: gitEnabled,
794+
pay: payEnabled,
795+
notifications: notificationsEnabled,
796+
mashlib: mashlibEnabled,
797+
mongo: mongoEnabled,
798+
tunnel: tunnelEnabled,
799+
terminal: terminalEnabled
800+
}
801+
});
802+
} catch (err) {
803+
fastify.log.warn({ err }, 'Failed to seed server root');
804+
}
805+
});
806+
}
807+
765808
// Single-user mode: create pod on startup if it doesn't exist
766809
if (singleUser) {
767810
fastify.addHook('onReady', async () => {

src/ui/server-root.html

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>{{title}}</title>
7+
<meta name="description" content="A personal data server powered by JSS">
8+
<style>
9+
* { margin: 0; padding: 0; box-sizing: border-box; }
10+
body {
11+
font-family: Georgia, 'Times New Roman', serif;
12+
background: #fafaf8;
13+
color: #2c2c2c;
14+
line-height: 1.7;
15+
min-height: 100vh;
16+
display: flex;
17+
align-items: center;
18+
justify-content: center;
19+
padding: 2rem;
20+
}
21+
.container { max-width: 560px; width: 100%; }
22+
h1 { font-size: 2.2rem; font-weight: 400; margin-bottom: 0.25rem; }
23+
.subtitle { color: #666; font-size: 1.05rem; margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #ddd; }
24+
p { margin-bottom: 1.25rem; }
25+
.actions { display: flex; gap: 0.75rem; margin: 1.5rem 0 2rem; flex-wrap: wrap; }
26+
.btn {
27+
display: inline-block;
28+
padding: 0.6rem 1.2rem;
29+
border-radius: 4px;
30+
text-decoration: none;
31+
font-family: Georgia, serif;
32+
font-size: 0.95rem;
33+
border: 1px solid transparent;
34+
cursor: pointer;
35+
transition: all 0.1s;
36+
}
37+
.btn-primary { background: #7c3aed; color: #fff; }
38+
.btn-primary:hover { background: #6025c0; }
39+
.btn-secondary { background: #fff; color: #2c2c2c; border-color: #ccc; }
40+
.btn-secondary:hover { background: #f0efeb; }
41+
.info { background: #f5f4f0; border-radius: 4px; padding: 1rem; font-size: 0.85rem; color: #666; margin-top: 1.5rem; }
42+
.info .row { display: flex; justify-content: space-between; padding: 0.2rem 0; }
43+
.info .label { color: #999; }
44+
.info code { font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.9em; color: #555; }
45+
footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd; color: #999; font-size: 0.8rem; text-align: center; }
46+
footer a { color: #888; }
47+
.features { font-size: 0.85rem; color: #666; margin-top: 0.5rem; }
48+
.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; }
49+
</style>
50+
</head>
51+
<body>
52+
<div class="container">
53+
<h1>{{heading}}</h1>
54+
<div class="subtitle">{{subtitle}}</div>
55+
<p>{{description}}</p>
56+
57+
{{actions}}
58+
59+
<div class="info">
60+
<div class="row"><span class="label">Version</span><code>{{version}}</code></div>
61+
<div class="row"><span class="label">Mode</span><code>{{mode}}</code></div>
62+
<div class="features">{{features}}</div>
63+
</div>
64+
65+
<footer>
66+
Powered by <a href="https://jss.live">JSS</a>
67+
</footer>
68+
</div>
69+
</body>
70+
</html>

src/ui/server-root.js

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* Server-root landing page.
3+
*
4+
* Renders src/ui/server-root.html with runtime values, and seeds
5+
* DATA_ROOT/index.html + DATA_ROOT/.acl on first start (skip-if-exists,
6+
* so operator customisation is preserved).
7+
*
8+
* See issue #276.
9+
*/
10+
11+
import { readFileSync } from 'fs';
12+
import { fileURLToPath } from 'url';
13+
import { dirname, join } from 'path';
14+
import * as storage from '../storage/filesystem.js';
15+
import { generatePublicReadAcl, serializeAcl } from '../wac/parser.js';
16+
17+
const __dirname = dirname(fileURLToPath(import.meta.url));
18+
const TEMPLATE_PATH = join(__dirname, 'server-root.html');
19+
20+
/**
21+
* Collect the list of enabled features for display on the landing page.
22+
*/
23+
function listFeatures(options = {}) {
24+
const f = [];
25+
if (options.idp) f.push('idp');
26+
if (options.nostr) f.push('nostr');
27+
if (options.webrtc) f.push('webrtc');
28+
if (options.activitypub) f.push('activitypub');
29+
if (options.git) f.push('git');
30+
if (options.pay) f.push('payments');
31+
if (options.notifications) f.push('notifications');
32+
if (options.mashlib) f.push('mashlib');
33+
if (options.mongo) f.push('mongo');
34+
if (options.tunnel) f.push('tunnel');
35+
if (options.terminal) f.push('terminal');
36+
return f;
37+
}
38+
39+
/**
40+
* Build an HTML snippet of action buttons based on server mode.
41+
*/
42+
function renderActions({ singleUser, idp }) {
43+
const buttons = [];
44+
if (!singleUser && idp) {
45+
buttons.push('<a href="/idp/register" class="btn btn-primary">Create a pod</a>');
46+
buttons.push('<a href="/idp" class="btn btn-secondary">Sign in</a>');
47+
} else if (singleUser && idp) {
48+
buttons.push('<a href="/idp" class="btn btn-primary">Sign in</a>');
49+
}
50+
buttons.push('<a href="https://javascriptsolidserver.github.io/docs/" class="btn btn-secondary">Docs</a>');
51+
return `<div class="actions">${buttons.join('\n ')}</div>`;
52+
}
53+
54+
/**
55+
* Render the landing page as an HTML string.
56+
*
57+
* @param {object} ctx
58+
* @param {string} ctx.version - JSS version
59+
* @param {boolean} [ctx.singleUser]
60+
* @param {boolean} [ctx.idp]
61+
* @param {string} [ctx.singleUserName]
62+
* @param {object} [ctx.enabled] - Map of feature flags
63+
* @returns {string} HTML
64+
*/
65+
export function renderServerRoot(ctx = {}) {
66+
const { version = 'unknown', singleUser = false, idp = false, singleUserName, enabled = {} } = ctx;
67+
68+
const tpl = readFileSync(TEMPLATE_PATH, 'utf8');
69+
const mode = singleUser ? 'single-user' : 'multi-user';
70+
const features = listFeatures(enabled)
71+
.map(f => `<span>${f}</span>`)
72+
.join(' ');
73+
74+
const heading = 'JSS';
75+
const subtitle = singleUser
76+
? `Personal pod${singleUserName && singleUserName !== '/' ? ` for ${escape(singleUserName)}` : ''}`
77+
: 'A personal data server';
78+
const description = singleUser
79+
? 'This server hosts a personal data pod. Apps come to the data rather than the other way around.'
80+
: 'This server hosts personal data pods on the web. Each pod is a space you own, with your own identity and access control.';
81+
82+
// Single-pass token substitution. Sequential .replace() calls would
83+
// re-scan already-substituted values, so a `singleUserName` of e.g.
84+
// `{{actions}}` would land inside `subtitle`, then get expanded by
85+
// the later `.replace(/{{actions}}/g, …)` — letting a pod owner
86+
// inject other template fragments via their name. With a single
87+
// pass over the original template, each {{token}} is matched once
88+
// and replaced with its value; `$` inside any value is also harmless
89+
// because the function form of replace skips substitution patterns.
90+
// See #433 review thread.
91+
const values = {
92+
title: heading,
93+
heading,
94+
subtitle,
95+
description,
96+
actions: renderActions({ singleUser, idp }),
97+
version: escape(version),
98+
mode,
99+
features
100+
};
101+
return tpl.replace(/{{(\w+)}}/g, (match, key) =>
102+
Object.prototype.hasOwnProperty.call(values, key) ? values[key] : match
103+
);
104+
}
105+
106+
function escape(s = '') {
107+
return String(s)
108+
.replace(/&/g, '&amp;')
109+
.replace(/</g, '&lt;')
110+
.replace(/>/g, '&gt;')
111+
.replace(/"/g, '&quot;');
112+
}
113+
114+
115+
/**
116+
* Seed DATA_ROOT/index.html, DATA_ROOT/.acl and DATA_ROOT/index.html.acl
117+
* if they don't already exist. Operator's own files are never overwritten.
118+
*
119+
* Default ACL: public read. No write access — the operator edits
120+
* /index.html on disk, not via the web.
121+
*
122+
* If the HTML write fails (permissions, full disk, read-only DATA_ROOT),
123+
* ACL seeding is aborted to avoid leaving the server with a public-read
124+
* root ACL and no index page.
125+
*
126+
* @param {object} ctx - Same context passed to renderServerRoot
127+
* @returns {Promise<{seededHtml: boolean, seededAcl: boolean, seededPageAcl: boolean}>}
128+
*/
129+
export async function seedServerRoot(ctx = {}) {
130+
let seededHtml = false;
131+
let seededAcl = false;
132+
let seededPageAcl = false;
133+
134+
// Seed /index.html if operator hasn't written one.
135+
if (!(await storage.exists('/index.html'))) {
136+
const html = renderServerRoot(ctx);
137+
const ok = await storage.write('/index.html', html);
138+
if (!ok) {
139+
// Don't proceed with ACLs if the page itself failed to write —
140+
// leaves us in a consistent unchanged state.
141+
return { seededHtml: false, seededAcl: false, seededPageAcl: false };
142+
}
143+
seededHtml = true;
144+
}
145+
146+
// Seed /.acl if one doesn't already exist. Public read on the container
147+
// itself — so GET / serves the landing page. Independent of index.html.
148+
//
149+
// Use './' (relative to the .acl's own URL) rather than '/' (the
150+
// origin root). The two coincide when JSS is mounted at the origin
151+
// root, but only the relative form survives reverse-proxy mounts at
152+
// a path prefix (e.g. https://example/jss/). This matches the
153+
// pattern used by createPodStructure / createRootPodStructure since
154+
// #428 / #430.
155+
//
156+
// (createRootPodStructure in single-user mode writes its own ACL and
157+
// runs in a later hook, which will overwrite this if needed.)
158+
if (!(await storage.exists('/.acl'))) {
159+
const ok = await storage.write('/.acl', serializeAcl(generatePublicReadAcl('./')));
160+
if (ok) seededAcl = true;
161+
}
162+
163+
// Dedicated ACL for the landing page itself — public read. The container
164+
// ACL above has no acl:default (we don't want to implicitly publish all
165+
// children), so /index.html needs its own rule when fetched directly.
166+
// Same relative-form rationale as above.
167+
if (!(await storage.exists('/index.html.acl'))) {
168+
const ok = await storage.write('/index.html.acl', serializeAcl(generatePublicReadAcl('./index.html')));
169+
if (ok) seededPageAcl = true;
170+
}
171+
172+
return { seededHtml, seededAcl, seededPageAcl };
173+
}

0 commit comments

Comments
 (0)