Skip to content

Commit dcf8a91

Browse files
feat: default landing page + ACL at server root (JavaScriptSolidServer#276)
On server startup, seed DATA_ROOT/index.html with a minimal landing page and DATA_ROOT/.acl + DATA_ROOT/index.html.acl with public-read ACLs. Skip-if-exists — operator-provided files are preserved. Landing page adapts to server mode (multi-user + IDP shows Create Pod / Sign in; single-user shows pod info). Lists enabled features. For v1, the root is public-read only — no public write. Operators edit /index.html on disk. A --admin-webid flag could relax this in a future iteration. Closes JavaScriptSolidServer#276
1 parent 5c03c27 commit dcf8a91

4 files changed

Lines changed: 332 additions & 0 deletions

File tree

src/server.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { dbPlugin } from './db/index.js';
2121
import { webrtcPlugin } from './webrtc/index.js';
2222
import { tunnelPlugin } from './tunnel/index.js';
2323
import { terminalPlugin } from './terminal/index.js';
24+
import { seedServerRoot } from './ui/server-root.js';
2425

2526
const __dirname = dirname(fileURLToPath(import.meta.url));
2627

@@ -545,6 +546,35 @@ export function createServer(options = {}) {
545546
fastify.options('/', handleOptions);
546547
fastify.post('/', writeRateLimit, handlePost);
547548

549+
// Server-root landing page: seed /index.html and /.acl on first start
550+
// (skip-if-exists, operator customisations preserved). See #276.
551+
fastify.addHook('onReady', async () => {
552+
try {
553+
const pkg = await readFile(join(__dirname, '..', 'package.json'), 'utf8');
554+
const { version } = JSON.parse(pkg);
555+
await seedServerRoot({
556+
version,
557+
singleUser,
558+
idp: idpEnabled,
559+
singleUserName,
560+
enabled: {
561+
idp: idpEnabled,
562+
nostr: nostrEnabled,
563+
webrtc: webrtcEnabled,
564+
activitypub: activitypubEnabled,
565+
git: gitEnabled,
566+
pay: payEnabled,
567+
notifications: notificationsEnabled,
568+
mashlib: mashlibEnabled,
569+
mongo: mongoEnabled,
570+
tunnel: tunnelEnabled
571+
}
572+
});
573+
} catch (err) {
574+
fastify.log.warn(`Failed to seed server root: ${err.message}`);
575+
}
576+
});
577+
548578
// Single-user mode: create pod on startup if it doesn't exist
549579
if (singleUser) {
550580
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: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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+
16+
const __dirname = dirname(fileURLToPath(import.meta.url));
17+
const TEMPLATE_PATH = join(__dirname, 'server-root.html');
18+
19+
/**
20+
* Collect the list of enabled features for display on the landing page.
21+
*/
22+
function listFeatures(options = {}) {
23+
const f = [];
24+
if (options.idp) f.push('idp');
25+
if (options.nostr) f.push('nostr');
26+
if (options.webrtc) f.push('webrtc');
27+
if (options.activitypub) f.push('activitypub');
28+
if (options.git) f.push('git');
29+
if (options.pay) f.push('payments');
30+
if (options.notifications) f.push('notifications');
31+
if (options.mashlib) f.push('mashlib');
32+
if (options.mongo) f.push('mongo');
33+
if (options.tunnel) f.push('tunnel');
34+
return f;
35+
}
36+
37+
/**
38+
* Build an HTML snippet of action buttons based on server mode.
39+
*/
40+
function renderActions({ singleUser, idp }) {
41+
const buttons = [];
42+
if (!singleUser && idp) {
43+
buttons.push('<a href="/.account/new" class="btn btn-primary">Create a pod</a>');
44+
buttons.push('<a href="/idp/auth" class="btn btn-secondary">Sign in</a>');
45+
} else if (singleUser && idp) {
46+
buttons.push('<a href="/idp/auth" class="btn btn-primary">Sign in</a>');
47+
}
48+
buttons.push('<a href="https://javascriptsolidserver.github.io/docs/" class="btn btn-secondary">Docs</a>');
49+
return `<div class="actions">${buttons.join('\n ')}</div>`;
50+
}
51+
52+
/**
53+
* Render the landing page as an HTML string.
54+
*
55+
* @param {object} ctx
56+
* @param {string} ctx.version - JSS version
57+
* @param {boolean} [ctx.singleUser]
58+
* @param {boolean} [ctx.idp]
59+
* @param {string} [ctx.singleUserName]
60+
* @param {object} [ctx.enabled] - Map of feature flags
61+
* @returns {string} HTML
62+
*/
63+
export function renderServerRoot(ctx = {}) {
64+
const { version = 'unknown', singleUser = false, idp = false, singleUserName, enabled = {} } = ctx;
65+
66+
const tpl = readFileSync(TEMPLATE_PATH, 'utf8');
67+
const mode = singleUser ? 'single-user' : 'multi-user';
68+
const features = listFeatures(enabled)
69+
.map(f => `<span>${f}</span>`)
70+
.join(' ');
71+
72+
const heading = 'JSS';
73+
const subtitle = singleUser
74+
? `Personal pod${singleUserName && singleUserName !== '/' ? ` for ${escape(singleUserName)}` : ''}`
75+
: 'A personal data server';
76+
const description = singleUser
77+
? 'This server hosts a personal data pod. Apps come to the data rather than the other way around.'
78+
: 'This server hosts personal data pods on the web. Each pod is a space you own, with your own identity and access control.';
79+
80+
return tpl
81+
.replace(/{{title}}/g, heading)
82+
.replace(/{{heading}}/g, heading)
83+
.replace(/{{subtitle}}/g, subtitle)
84+
.replace(/{{description}}/g, description)
85+
.replace(/{{actions}}/g, renderActions({ singleUser, idp }))
86+
.replace(/{{version}}/g, escape(version))
87+
.replace(/{{mode}}/g, mode)
88+
.replace(/{{features}}/g, features);
89+
}
90+
91+
function escape(s = '') {
92+
return String(s)
93+
.replace(/&/g, '&amp;')
94+
.replace(/</g, '&lt;')
95+
.replace(/>/g, '&gt;')
96+
.replace(/"/g, '&quot;');
97+
}
98+
99+
/**
100+
* Seed DATA_ROOT/index.html and DATA_ROOT/.acl if they don't already
101+
* exist. Operator's own files are never overwritten.
102+
*
103+
* Default ACL at root: public read. Write access is not granted — the
104+
* operator edits the file on disk, not via the web.
105+
*
106+
* @param {object} ctx - Same context passed to renderServerRoot
107+
* @returns {Promise<{seeded: boolean}>}
108+
*/
109+
export async function seedServerRoot(ctx = {}) {
110+
let seededHtml = false;
111+
let seededAcl = false;
112+
113+
// Seed /index.html if operator hasn't written one.
114+
if (!(await storage.exists('/index.html'))) {
115+
const html = renderServerRoot(ctx);
116+
await storage.write('/index.html', html);
117+
seededHtml = true;
118+
}
119+
120+
// Seed /.acl if one doesn't already exist. Public read on the container
121+
// itself — so GET / serves the landing page. Independent of index.html.
122+
// (createRootPodStructure in single-user mode writes its own ACL and
123+
// runs in a later hook, which will overwrite this if needed.)
124+
if (!(await storage.exists('/.acl'))) {
125+
const acl = JSON.stringify({
126+
'@context': { acl: 'http://www.w3.org/ns/auth/acl#', foaf: 'http://xmlns.com/foaf/0.1/' },
127+
'@graph': [
128+
{
129+
'@id': '#public',
130+
'@type': 'acl:Authorization',
131+
'acl:agentClass': { '@id': 'foaf:Agent' },
132+
'acl:accessTo': { '@id': '/' },
133+
'acl:mode': [{ '@id': 'acl:Read' }]
134+
}
135+
]
136+
}, null, 2);
137+
await storage.write('/.acl', acl);
138+
seededAcl = true;
139+
}
140+
141+
// Dedicated ACL for the landing page itself — public read. The container
142+
// ACL above has no acl:default (we don't want to implicitly publish all
143+
// children), so /index.html needs its own rule when fetched directly.
144+
if (!(await storage.exists('/index.html.acl'))) {
145+
const pageAcl = JSON.stringify({
146+
'@context': { acl: 'http://www.w3.org/ns/auth/acl#', foaf: 'http://xmlns.com/foaf/0.1/' },
147+
'@graph': [
148+
{
149+
'@id': '#public',
150+
'@type': 'acl:Authorization',
151+
'acl:agentClass': { '@id': 'foaf:Agent' },
152+
'acl:accessTo': { '@id': '/index.html' },
153+
'acl:mode': [{ '@id': 'acl:Read' }]
154+
}
155+
]
156+
}, null, 2);
157+
await storage.write('/index.html.acl', pageAcl);
158+
}
159+
160+
return { seededHtml, seededAcl };
161+
}

test/server-root.test.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Server-root landing page seed (#276).
3+
*/
4+
5+
import { describe, it, before, after } from 'node:test';
6+
import assert from 'node:assert';
7+
import fs from 'fs-extra';
8+
import { createServer } from '../src/server.js';
9+
import { startTestServer, stopTestServer, request, assertStatus } from './helpers.js';
10+
11+
describe('Server-root landing page', () => {
12+
before(async () => {
13+
await startTestServer();
14+
});
15+
16+
after(async () => {
17+
await stopTestServer();
18+
});
19+
20+
it('seeds /index.html so GET / serves HTML', async () => {
21+
const res = await request('/', { headers: { Accept: 'text/html' } });
22+
assertStatus(res, 200);
23+
const body = await res.text();
24+
assert.match(body, /<title>JSS<\/title>/);
25+
assert.match(body, /A personal data server/);
26+
});
27+
28+
it('landing page is publicly readable (no auth required)', async () => {
29+
const res = await request('/index.html');
30+
assertStatus(res, 200);
31+
});
32+
});
33+
34+
// Operator's existing /index.html is preserved — dedicated server + data dir.
35+
describe('Server-root landing — operator override', () => {
36+
let server;
37+
let baseUrl;
38+
const DATA_DIR = './test-data-server-root-override';
39+
const CUSTOM_HTML = '<!doctype html><html><body>my custom page</body></html>';
40+
41+
before(async () => {
42+
await fs.remove(DATA_DIR);
43+
await fs.ensureDir(DATA_DIR);
44+
await fs.writeFile(`${DATA_DIR}/index.html`, CUSTOM_HTML);
45+
46+
server = createServer({
47+
logger: false,
48+
root: DATA_DIR,
49+
forceCloseConnections: true,
50+
});
51+
await server.listen({ port: 0, host: '127.0.0.1' });
52+
baseUrl = `http://127.0.0.1:${server.server.address().port}`;
53+
});
54+
55+
after(async () => {
56+
await server.close();
57+
await fs.remove(DATA_DIR);
58+
});
59+
60+
it('does not overwrite operator-provided /index.html', async () => {
61+
const current = await fs.readFile(`${DATA_DIR}/index.html`, 'utf8');
62+
assert.strictEqual(current, CUSTOM_HTML);
63+
});
64+
65+
it('GET / serves operator custom page', async () => {
66+
const res = await fetch(`${baseUrl}/`, { headers: { Accept: 'text/html' } });
67+
assert.strictEqual(res.status, 200);
68+
const body = await res.text();
69+
assert.match(body, /my custom page/);
70+
});
71+
});

0 commit comments

Comments
 (0)