javascript-solid-server 0.0.138 → 0.0.140
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/ap/index.js +9 -9
- package/src/ap/keys.js +2 -2
- package/src/ap/routes/actor.js +1 -1
- package/src/ap/routes/collections.js +1 -1
- package/src/ap/routes/inbox.js +1 -1
- package/src/ap/routes/mastodon.js +2 -2
- package/src/ap/routes/outbox.js +2 -2
- package/src/db/index.js +2 -2
- package/src/handlers/container.js +22 -23
- package/src/idp/index.js +1 -1
- package/src/idp/interactions.js +51 -20
- package/src/idp/views.js +102 -8
- package/src/server.js +18 -14
- package/src/webid/profile.js +21 -80
- package/test/auth.test.js +1 -1
- package/test/idp.test.js +119 -0
- package/test/pod.test.js +5 -5
- package/test/webid.test.js +18 -26
package/package.json
CHANGED
package/src/ap/index.js
CHANGED
|
@@ -68,7 +68,7 @@ export async function activityPubPlugin(fastify, options = {}) {
|
|
|
68
68
|
const getActorId = (request) => {
|
|
69
69
|
const protocol = getProtocol(request)
|
|
70
70
|
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
71
|
-
return `${protocol}://${host}/profile/card#me`
|
|
71
|
+
return `${protocol}://${host}/profile/card.jsonld#me`
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
// Helper to get base URL
|
|
@@ -96,11 +96,11 @@ export async function activityPubPlugin(fastify, options = {}) {
|
|
|
96
96
|
return reply.code(404).send({ error: 'Not found' })
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
// For now, accept any username and map to /profile/card#me
|
|
99
|
+
// For now, accept any username and map to /profile/card.jsonld#me
|
|
100
100
|
// In multi-user mode, we'd look up the user
|
|
101
101
|
const baseUrl = getBaseUrl(request)
|
|
102
|
-
const actorUrl = `${baseUrl}/profile/card#me`
|
|
103
|
-
const profileUrl = `${baseUrl}/profile/card`
|
|
102
|
+
const actorUrl = `${baseUrl}/profile/card.jsonld#me`
|
|
103
|
+
const profileUrl = `${baseUrl}/profile/card.jsonld`
|
|
104
104
|
|
|
105
105
|
const response = webfinger.createResponse(
|
|
106
106
|
`${parsed.username}@${parsed.domain}`,
|
|
@@ -174,18 +174,18 @@ export async function activityPubPlugin(fastify, options = {}) {
|
|
|
174
174
|
// Inbox endpoint
|
|
175
175
|
const inboxHandler = createInboxHandler(config, keypair)
|
|
176
176
|
fastify.post('/inbox', inboxHandler)
|
|
177
|
-
fastify.post('/profile/card/inbox', inboxHandler)
|
|
177
|
+
fastify.post('/profile/card.jsonld/inbox', inboxHandler)
|
|
178
178
|
|
|
179
179
|
// Outbox endpoint
|
|
180
180
|
const outboxHandler = createOutboxHandler(config, keypair)
|
|
181
181
|
const outboxPostHandler = createOutboxPostHandler(config, keypair)
|
|
182
|
-
fastify.get('/profile/card/outbox', outboxHandler)
|
|
183
|
-
fastify.post('/profile/card/outbox', outboxPostHandler)
|
|
182
|
+
fastify.get('/profile/card.jsonld/outbox', outboxHandler)
|
|
183
|
+
fastify.post('/profile/card.jsonld/outbox', outboxPostHandler)
|
|
184
184
|
|
|
185
185
|
// Followers/Following collections
|
|
186
186
|
const collectionsHandler = createCollectionsHandler(config)
|
|
187
|
-
fastify.get('/profile/card/followers', (req, reply) => collectionsHandler(req, reply, 'followers'))
|
|
188
|
-
fastify.get('/profile/card/following', (req, reply) => collectionsHandler(req, reply, 'following'))
|
|
187
|
+
fastify.get('/profile/card.jsonld/followers', (req, reply) => collectionsHandler(req, reply, 'followers'))
|
|
188
|
+
fastify.get('/profile/card.jsonld/following', (req, reply) => collectionsHandler(req, reply, 'following'))
|
|
189
189
|
|
|
190
190
|
// Mastodon-compatible API endpoints
|
|
191
191
|
fastify.post('/api/v1/apps', createAppsHandler())
|
package/src/ap/keys.js
CHANGED
|
@@ -52,8 +52,8 @@ export function loadOrCreateKeypair(path = DEFAULT_KEY_PATH) {
|
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Get key ID for HTTP Signatures
|
|
55
|
-
* @param {string} actorId - Actor URL (e.g., https://example.com/profile/card#me)
|
|
56
|
-
* @returns {string} Key ID (e.g., https://example.com/profile/card#main-key)
|
|
55
|
+
* @param {string} actorId - Actor URL (e.g., https://example.com/profile/card.jsonld#me)
|
|
56
|
+
* @returns {string} Key ID (e.g., https://example.com/profile/card.jsonld#main-key)
|
|
57
57
|
*/
|
|
58
58
|
export function getKeyId(actorId) {
|
|
59
59
|
// Strip fragment and add #main-key
|
package/src/ap/routes/actor.js
CHANGED
|
@@ -30,7 +30,7 @@ export function createActorHandler(config, keypair) {
|
|
|
30
30
|
}
|
|
31
31
|
protocol = protocol || request.protocol
|
|
32
32
|
const baseUrl = `${protocol}://${host}`
|
|
33
|
-
const profileUrl = `${baseUrl}/profile/card`
|
|
33
|
+
const profileUrl = `${baseUrl}/profile/card.jsonld`
|
|
34
34
|
const actorId = `${profileUrl}#me`
|
|
35
35
|
|
|
36
36
|
const actor = {
|
|
@@ -15,7 +15,7 @@ export function createCollectionsHandler(config) {
|
|
|
15
15
|
const protocol = request.headers['x-forwarded-proto'] || request.protocol
|
|
16
16
|
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
17
17
|
const baseUrl = `${protocol}://${host}`
|
|
18
|
-
const profileUrl = `${baseUrl}/profile/card`
|
|
18
|
+
const profileUrl = `${baseUrl}/profile/card.jsonld`
|
|
19
19
|
|
|
20
20
|
let items, totalItems
|
|
21
21
|
|
package/src/ap/routes/inbox.js
CHANGED
|
@@ -150,7 +150,7 @@ export function createInboxHandler(config, keypair) {
|
|
|
150
150
|
const protocol = request.headers['x-forwarded-proto'] || request.protocol
|
|
151
151
|
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
152
152
|
const baseUrl = `${protocol}://${host}`
|
|
153
|
-
const profileUrl = `${baseUrl}/profile/card`
|
|
153
|
+
const profileUrl = `${baseUrl}/profile/card.jsonld`
|
|
154
154
|
const actorId = `${profileUrl}#me`
|
|
155
155
|
|
|
156
156
|
request.log.info(`Received ${activity.type} from ${activity.actor}`)
|
|
@@ -78,8 +78,8 @@ export function createVerifyCredentialsHandler (config) {
|
|
|
78
78
|
acct: config.username,
|
|
79
79
|
display_name: config.displayName,
|
|
80
80
|
note: config.summary ? `<p>${escapeHtml(config.summary)}</p>` : '',
|
|
81
|
-
url: `${baseUrl}/profile/card`,
|
|
82
|
-
uri: `${baseUrl}/profile/card#me`,
|
|
81
|
+
url: `${baseUrl}/profile/card.jsonld`,
|
|
82
|
+
uri: `${baseUrl}/profile/card.jsonld#me`,
|
|
83
83
|
avatar: `${baseUrl}/profile/avatar.png`,
|
|
84
84
|
header: '',
|
|
85
85
|
locked: false,
|
package/src/ap/routes/outbox.js
CHANGED
|
@@ -19,7 +19,7 @@ export function createOutboxHandler(config, keypair) {
|
|
|
19
19
|
const protocol = request.headers['x-forwarded-proto'] || request.protocol
|
|
20
20
|
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
21
21
|
const baseUrl = `${protocol}://${host}`
|
|
22
|
-
const profileUrl = `${baseUrl}/profile/card`
|
|
22
|
+
const profileUrl = `${baseUrl}/profile/card.jsonld`
|
|
23
23
|
const actorId = `${profileUrl}#me`
|
|
24
24
|
|
|
25
25
|
const posts = getPosts(20)
|
|
@@ -63,7 +63,7 @@ export function createOutboxPostHandler(config, keypair) {
|
|
|
63
63
|
const protocol = request.headers['x-forwarded-proto'] || request.protocol
|
|
64
64
|
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
65
65
|
const baseUrl = `${protocol}://${host}`
|
|
66
|
-
const profileUrl = `${baseUrl}/profile/card`
|
|
66
|
+
const profileUrl = `${baseUrl}/profile/card.jsonld`
|
|
67
67
|
const actorId = `${profileUrl}#me`
|
|
68
68
|
|
|
69
69
|
// Parse body
|
package/src/db/index.js
CHANGED
|
@@ -59,8 +59,8 @@ export async function dbPlugin(fastify, options) {
|
|
|
59
59
|
if (podName) {
|
|
60
60
|
// Build expected WebID for both path and subdomain modes
|
|
61
61
|
const expectedWebId = request.subdomainsEnabled && request.baseDomain
|
|
62
|
-
? `${request.protocol}://${podName}.${request.baseDomain}/profile/card#me`
|
|
63
|
-
: `${request.protocol}://${request.hostname}/${podName}/profile/card#me`;
|
|
62
|
+
? `${request.protocol}://${podName}.${request.baseDomain}/profile/card.jsonld#me`
|
|
63
|
+
: `${request.protocol}://${request.hostname}/${podName}/profile/card.jsonld#me`;
|
|
64
64
|
if (webId !== expectedWebId) {
|
|
65
65
|
return reply.code(403).send({ error: 'Forbidden', message: 'You can only write to your own /db/ space' });
|
|
66
66
|
}
|
|
@@ -173,20 +173,20 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo
|
|
|
173
173
|
await storage.createContainer(`${podPath}settings/`);
|
|
174
174
|
await storage.createContainer(`${podPath}profile/`);
|
|
175
175
|
|
|
176
|
-
// Generate and write WebID profile at /profile/card
|
|
177
|
-
const
|
|
178
|
-
await storage.write(`${podPath}profile/card`,
|
|
176
|
+
// Generate and write WebID profile at /profile/card.jsonld
|
|
177
|
+
const profile = generateProfile({ webId, name, podUri, issuer });
|
|
178
|
+
await storage.write(`${podPath}profile/card.jsonld`, serialize(profile));
|
|
179
179
|
|
|
180
|
-
// Generate and write preferences
|
|
180
|
+
// Generate and write preferences
|
|
181
181
|
const prefs = generatePreferences({ webId, podUri });
|
|
182
|
-
await storage.write(`${podPath}settings/
|
|
182
|
+
await storage.write(`${podPath}settings/prefs.jsonld`, serialize(prefs));
|
|
183
183
|
|
|
184
|
-
// Generate and write type indexes
|
|
185
|
-
const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex.
|
|
186
|
-
await storage.write(`${podPath}settings/publicTypeIndex.
|
|
184
|
+
// Generate and write type indexes
|
|
185
|
+
const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex.jsonld`);
|
|
186
|
+
await storage.write(`${podPath}settings/publicTypeIndex.jsonld`, serialize(publicTypeIndex));
|
|
187
187
|
|
|
188
|
-
const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex.
|
|
189
|
-
await storage.write(`${podPath}settings/privateTypeIndex.
|
|
188
|
+
const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex.jsonld`);
|
|
189
|
+
await storage.write(`${podPath}settings/privateTypeIndex.jsonld`, serialize(privateTypeIndex));
|
|
190
190
|
|
|
191
191
|
// Create default ACL files
|
|
192
192
|
// Pod root: owner full control, public read
|
|
@@ -229,13 +229,13 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo
|
|
|
229
229
|
*
|
|
230
230
|
* Creates the following structure:
|
|
231
231
|
* /{name}/
|
|
232
|
-
* /{name}/profile/card
|
|
233
|
-
* /{name}/inbox/
|
|
234
|
-
* /{name}/public/
|
|
235
|
-
* /{name}/private/
|
|
236
|
-
* /{name}/settings/prefs
|
|
237
|
-
* /{name}/settings/publicTypeIndex
|
|
238
|
-
* /{name}/settings/privateTypeIndex
|
|
232
|
+
* /{name}/profile/card.jsonld - WebID profile
|
|
233
|
+
* /{name}/inbox/ - Notifications
|
|
234
|
+
* /{name}/public/ - Public files
|
|
235
|
+
* /{name}/private/ - Private files
|
|
236
|
+
* /{name}/settings/prefs.jsonld - Preferences
|
|
237
|
+
* /{name}/settings/publicTypeIndex.jsonld
|
|
238
|
+
* /{name}/settings/privateTypeIndex.jsonld
|
|
239
239
|
*/
|
|
240
240
|
export async function handleCreatePod(request, reply) {
|
|
241
241
|
// Read-only mode - block pod creation
|
|
@@ -272,23 +272,22 @@ export async function handleCreatePod(request, reply) {
|
|
|
272
272
|
return reply.code(409).send({ error: 'Pod already exists' });
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
// Build URIs
|
|
276
|
-
// WebID follows standard Solid convention: /alice/profile/card#me
|
|
275
|
+
// Build URIs. WebID is the JSON-LD profile with an #me fragment.
|
|
277
276
|
const subdomainsEnabled = request.subdomainsEnabled;
|
|
278
277
|
const baseDomain = request.baseDomain;
|
|
279
278
|
|
|
280
279
|
let baseUri, podUri, webId;
|
|
281
280
|
if (subdomainsEnabled && baseDomain) {
|
|
282
|
-
// Subdomain mode: alice.example.com/profile/card#me
|
|
281
|
+
// Subdomain mode: alice.example.com/profile/card.jsonld#me
|
|
283
282
|
const podHost = `${name}.${baseDomain}`;
|
|
284
283
|
baseUri = `${request.protocol}://${baseDomain}`;
|
|
285
284
|
podUri = `${request.protocol}://${podHost}/`;
|
|
286
|
-
webId = `${podUri}profile/card#me`;
|
|
285
|
+
webId = `${podUri}profile/card.jsonld#me`;
|
|
287
286
|
} else {
|
|
288
|
-
// Path mode: example.com/alice/profile/card#me
|
|
287
|
+
// Path mode: example.com/alice/profile/card.jsonld#me
|
|
289
288
|
baseUri = `${request.protocol}://${request.hostname}`;
|
|
290
289
|
podUri = `${baseUri}${podPath}`;
|
|
291
|
-
webId = `${podUri}profile/card#me`;
|
|
290
|
+
webId = `${podUri}profile/card.jsonld#me`;
|
|
292
291
|
}
|
|
293
292
|
|
|
294
293
|
// Issuer needs trailing slash for CTH compatibility
|
package/src/idp/index.js
CHANGED
|
@@ -296,7 +296,7 @@ export async function idpPlugin(fastify, options) {
|
|
|
296
296
|
});
|
|
297
297
|
} else {
|
|
298
298
|
fastify.get('/idp/register', async (request, reply) => {
|
|
299
|
-
return handleRegisterGet(request, reply, inviteOnly);
|
|
299
|
+
return handleRegisterGet(request, reply, issuer, inviteOnly);
|
|
300
300
|
});
|
|
301
301
|
|
|
302
302
|
// Registration - rate limited to prevent spam accounts
|
package/src/idp/interactions.js
CHANGED
|
@@ -329,9 +329,21 @@ export async function handleAbort(request, reply, provider) {
|
|
|
329
329
|
* Handle GET /idp/register
|
|
330
330
|
* Shows registration page
|
|
331
331
|
*/
|
|
332
|
-
export async function handleRegisterGet(request, reply, inviteOnly = false) {
|
|
332
|
+
export async function handleRegisterGet(request, reply, issuer, inviteOnly = false) {
|
|
333
333
|
const uid = request.query.uid || null;
|
|
334
|
-
|
|
334
|
+
const ctx = previewContext(request, issuer);
|
|
335
|
+
return reply.type('text/html').send(registerPage(uid, null, null, inviteOnly, ctx));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Live-preview context for the register page: lets the client-side script
|
|
339
|
+
// build the WebID + storage URL the user is about to claim, before submit.
|
|
340
|
+
function previewContext(request, issuer) {
|
|
341
|
+
const baseUri = (issuer || `${request.protocol}://${request.hostname}`).replace(/\/$/, '');
|
|
342
|
+
return {
|
|
343
|
+
baseUri,
|
|
344
|
+
subdomainsEnabled: !!request.subdomainsEnabled,
|
|
345
|
+
baseDomain: request.baseDomain || null,
|
|
346
|
+
};
|
|
335
347
|
}
|
|
336
348
|
|
|
337
349
|
/**
|
|
@@ -340,6 +352,7 @@ export async function handleRegisterGet(request, reply, inviteOnly = false) {
|
|
|
340
352
|
*/
|
|
341
353
|
export async function handleRegisterPost(request, reply, issuer, inviteOnly = false) {
|
|
342
354
|
const uid = request.query.uid || null;
|
|
355
|
+
const ctx = previewContext(request, issuer);
|
|
343
356
|
|
|
344
357
|
// Parse body
|
|
345
358
|
let parsedBody = request.body || {};
|
|
@@ -348,7 +361,7 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
|
|
|
348
361
|
if (Buffer.isBuffer(parsedBody)) {
|
|
349
362
|
// Security: check body size
|
|
350
363
|
if (parsedBody.length > MAX_BODY_SIZE) {
|
|
351
|
-
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly));
|
|
364
|
+
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly, ctx));
|
|
352
365
|
}
|
|
353
366
|
const bodyStr = parsedBody.toString();
|
|
354
367
|
if (contentType.includes('application/json')) {
|
|
@@ -364,7 +377,7 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
|
|
|
364
377
|
} else if (typeof parsedBody === 'string') {
|
|
365
378
|
// Security: check body size
|
|
366
379
|
if (parsedBody.length > MAX_BODY_SIZE) {
|
|
367
|
-
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly));
|
|
380
|
+
return reply.code(413).type('text/html').send(registerPage(null, 'Request body exceeds maximum size.', null, inviteOnly, ctx));
|
|
368
381
|
}
|
|
369
382
|
const params = new URLSearchParams(parsedBody);
|
|
370
383
|
parsedBody = Object.fromEntries(params.entries());
|
|
@@ -376,56 +389,74 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
|
|
|
376
389
|
if (inviteOnly) {
|
|
377
390
|
const inviteResult = await validateInvite(invite);
|
|
378
391
|
if (!inviteResult.valid) {
|
|
379
|
-
return reply.code(403).type('text/html').send(registerPage(uid, inviteResult.error, null, inviteOnly));
|
|
392
|
+
return reply.code(403).type('text/html').send(registerPage(uid, inviteResult.error, null, inviteOnly, ctx));
|
|
380
393
|
}
|
|
381
394
|
}
|
|
382
395
|
|
|
383
396
|
// Validate input
|
|
384
397
|
if (!username || !password) {
|
|
385
|
-
return reply.type('text/html').send(registerPage(uid, 'Username and password are required', null, inviteOnly));
|
|
398
|
+
return reply.type('text/html').send(registerPage(uid, 'Username and password are required', null, inviteOnly, ctx));
|
|
386
399
|
}
|
|
387
400
|
|
|
388
|
-
// Validate username format
|
|
389
|
-
|
|
401
|
+
// Validate username format. Must start and end alphanumeric; the middle
|
|
402
|
+
// can contain dot, dash, underscore — covers `alice-smith`, `alice.smith`,
|
|
403
|
+
// `alice_work`, and so on. No leading/trailing separators (avoids the
|
|
404
|
+
// `.hidden` / trailing-dot footguns), no `..` (path traversal hygiene
|
|
405
|
+
// even though storage already guards against it).
|
|
406
|
+
//
|
|
407
|
+
// In subdomain mode the username becomes a single-level subdomain — DNS
|
|
408
|
+
// hostnames don't allow `.` or `_`, and `server.js` already refuses to
|
|
409
|
+
// route multi-level subdomains as pods. So we restrict to alphanumeric +
|
|
410
|
+
// hyphen there to keep the username and the pod actually addressable.
|
|
411
|
+
const subdomainMode = !!(request.subdomainsEnabled && request.baseDomain);
|
|
412
|
+
const usernameRegex = subdomainMode
|
|
413
|
+
? /^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])?$/
|
|
414
|
+
: /^[a-z0-9]([a-z0-9._-]{1,30}[a-z0-9])?$/;
|
|
390
415
|
if (!usernameRegex.test(username)) {
|
|
391
|
-
|
|
416
|
+
const msg = subdomainMode
|
|
417
|
+
? 'Username must be lowercase letters, numbers, or - (subdomain mode disallows . and _)'
|
|
418
|
+
: 'Username must be lowercase letters, numbers, or . _ - (start and end alphanumeric)';
|
|
419
|
+
return reply.type('text/html').send(registerPage(uid, msg, null, inviteOnly, ctx));
|
|
420
|
+
}
|
|
421
|
+
if (username.includes('..')) {
|
|
422
|
+
return reply.type('text/html').send(registerPage(uid, 'Username cannot contain ".."', null, inviteOnly, ctx));
|
|
392
423
|
}
|
|
393
424
|
|
|
394
425
|
if (username.length < 3) {
|
|
395
|
-
return reply.type('text/html').send(registerPage(uid, 'Username must be at least 3 characters', null, inviteOnly));
|
|
426
|
+
return reply.type('text/html').send(registerPage(uid, 'Username must be at least 3 characters', null, inviteOnly, ctx));
|
|
396
427
|
}
|
|
397
428
|
|
|
398
429
|
// Password strength validation
|
|
399
430
|
if (password.length < 8) {
|
|
400
|
-
return reply.type('text/html').send(registerPage(uid, 'Password must be at least 8 characters', null, inviteOnly));
|
|
431
|
+
return reply.type('text/html').send(registerPage(uid, 'Password must be at least 8 characters', null, inviteOnly, ctx));
|
|
401
432
|
}
|
|
402
433
|
|
|
403
434
|
if (password !== confirmPassword) {
|
|
404
|
-
return reply.type('text/html').send(registerPage(uid, 'Passwords do not match', null, inviteOnly));
|
|
435
|
+
return reply.type('text/html').send(registerPage(uid, 'Passwords do not match', null, inviteOnly, ctx));
|
|
405
436
|
}
|
|
406
437
|
|
|
407
438
|
try {
|
|
408
|
-
// Build URLs
|
|
439
|
+
// Build URLs. WebID is the JSON-LD profile with an #me fragment.
|
|
409
440
|
const subdomainsEnabled = request.subdomainsEnabled;
|
|
410
441
|
const baseDomain = request.baseDomain;
|
|
411
442
|
const baseUrl = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
|
|
412
443
|
|
|
413
444
|
let podUri, webId;
|
|
414
445
|
if (subdomainsEnabled && baseDomain) {
|
|
415
|
-
// Subdomain mode: alice.example.com/profile/card#me
|
|
446
|
+
// Subdomain mode: alice.example.com/profile/card.jsonld#me
|
|
416
447
|
podUri = `${request.protocol}://${username}.${baseDomain}/`;
|
|
417
|
-
webId = `${podUri}profile/card#me`;
|
|
448
|
+
webId = `${podUri}profile/card.jsonld#me`;
|
|
418
449
|
} else {
|
|
419
|
-
// Path mode: example.com/alice/profile/card#me
|
|
450
|
+
// Path mode: example.com/alice/profile/card.jsonld#me
|
|
420
451
|
podUri = `${baseUrl}/${username}/`;
|
|
421
|
-
webId = `${podUri}profile/card#me`;
|
|
452
|
+
webId = `${podUri}profile/card.jsonld#me`;
|
|
422
453
|
}
|
|
423
454
|
|
|
424
455
|
// Check if pod already exists
|
|
425
456
|
const podPath = `${username}/`;
|
|
426
457
|
const podExists = await storage.exists(podPath);
|
|
427
458
|
if (podExists) {
|
|
428
|
-
return reply.type('text/html').send(registerPage(uid, 'Username is already taken', null, inviteOnly));
|
|
459
|
+
return reply.type('text/html').send(registerPage(uid, 'Username is already taken', null, inviteOnly, ctx));
|
|
429
460
|
}
|
|
430
461
|
|
|
431
462
|
// Create pod structure
|
|
@@ -445,11 +476,11 @@ export async function handleRegisterPost(request, reply, issuer, inviteOnly = fa
|
|
|
445
476
|
if (uid) {
|
|
446
477
|
return reply.redirect(`/idp/interaction/${uid}`);
|
|
447
478
|
} else {
|
|
448
|
-
return reply.type('text/html').send(registerPage(null, null, `Account created! You can now sign in as "${username}".`, inviteOnly));
|
|
479
|
+
return reply.type('text/html').send(registerPage(null, null, `Account created! You can now sign in as "${username}".`, inviteOnly, ctx));
|
|
449
480
|
}
|
|
450
481
|
} catch (err) {
|
|
451
482
|
request.log.error(err, 'Registration error');
|
|
452
|
-
return reply.type('text/html').send(registerPage(uid, err.message, null, inviteOnly));
|
|
483
|
+
return reply.type('text/html').send(registerPage(uid, err.message, null, inviteOnly, ctx));
|
|
453
484
|
}
|
|
454
485
|
}
|
|
455
486
|
|
package/src/idp/views.js
CHANGED
|
@@ -553,13 +553,36 @@ export function errorPage(title, message) {
|
|
|
553
553
|
/**
|
|
554
554
|
* Registration page HTML
|
|
555
555
|
*/
|
|
556
|
-
export function registerPage(uid = null, error = null, success = null, inviteOnly = false) {
|
|
556
|
+
export function registerPage(uid = null, error = null, success = null, inviteOnly = false, ctx = {}) {
|
|
557
557
|
const inviteField = inviteOnly ? `
|
|
558
558
|
<label for="invite">Invite Code</label>
|
|
559
559
|
<input type="text" id="invite" name="invite" required
|
|
560
560
|
placeholder="Enter your invite code" style="text-transform: uppercase;">
|
|
561
561
|
` : '';
|
|
562
562
|
|
|
563
|
+
// Embed the values the live preview needs. Escape characters that are
|
|
564
|
+
// unsafe in inline <script> contexts so values like "</script>" or
|
|
565
|
+
// U+2028 / U+2029 line separators can't terminate the script tag or
|
|
566
|
+
// confuse the parser when template-substituted.
|
|
567
|
+
const previewConfig = JSON.stringify({
|
|
568
|
+
baseUri: ctx.baseUri || '',
|
|
569
|
+
subdomainsEnabled: !!ctx.subdomainsEnabled,
|
|
570
|
+
baseDomain: ctx.baseDomain || '',
|
|
571
|
+
})
|
|
572
|
+
.replace(/</g, '\\u003c')
|
|
573
|
+
.replace(/\u2028/g, '\\u2028')
|
|
574
|
+
.replace(/\u2029/g, '\\u2029');
|
|
575
|
+
|
|
576
|
+
// Server validates more strictly than the HTML pattern can express; mirror
|
|
577
|
+
// as much as possible client-side so the browser catches obvious mistakes
|
|
578
|
+
// before submit. Subdomain mode drops dot/underscore (DNS hostname rules).
|
|
579
|
+
const usernamePattern = (ctx.subdomainsEnabled && ctx.baseDomain)
|
|
580
|
+
? '[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?'
|
|
581
|
+
: '(?!.*\\.\\.)[a-z0-9](?:[a-z0-9._-]{1,30}[a-z0-9])?';
|
|
582
|
+
const usernameTitle = (ctx.subdomainsEnabled && ctx.baseDomain)
|
|
583
|
+
? 'Lowercase letters, numbers, or - (start and end alphanumeric, 3–32 chars). Subdomain mode disallows . and _.'
|
|
584
|
+
: 'Lowercase letters, numbers, or . _ - (start and end alphanumeric, 3–32 chars, no consecutive dots)';
|
|
585
|
+
|
|
563
586
|
return `
|
|
564
587
|
<!DOCTYPE html>
|
|
565
588
|
<html lang="en">
|
|
@@ -567,13 +590,38 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
|
|
|
567
590
|
<meta charset="UTF-8">
|
|
568
591
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
569
592
|
<title>Register - Solid IdP</title>
|
|
570
|
-
<style>${styles}
|
|
593
|
+
<style>${styles}
|
|
594
|
+
/* registerPage local polish (#284) */
|
|
595
|
+
.container.register { padding-top: 32px; }
|
|
596
|
+
.register-header {
|
|
597
|
+
margin: -40px -40px 24px;
|
|
598
|
+
padding: 28px 40px 22px;
|
|
599
|
+
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
|
600
|
+
color: #fff;
|
|
601
|
+
border-radius: 12px 12px 0 0;
|
|
602
|
+
}
|
|
603
|
+
.register-header h1 { color: #fff; margin: 0 0 4px; font-size: 22px; }
|
|
604
|
+
.register-header .subtitle { color: rgba(255,255,255,.85); margin: 0; font-size: 13px; }
|
|
605
|
+
.preview {
|
|
606
|
+
margin: 4px 0 18px;
|
|
607
|
+
padding: 12px 14px;
|
|
608
|
+
background: #f8fafc;
|
|
609
|
+
border: 1px solid #e2e8f0;
|
|
610
|
+
border-radius: 8px;
|
|
611
|
+
font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
612
|
+
color: #475569;
|
|
613
|
+
word-break: break-all;
|
|
614
|
+
}
|
|
615
|
+
.preview .label { color: #64748b; font-weight: 600; margin-right: 6px; }
|
|
616
|
+
.preview .placeholder { color: #94a3b8; font-style: italic; }
|
|
617
|
+
</style>
|
|
571
618
|
</head>
|
|
572
619
|
<body>
|
|
573
|
-
<div class="container">
|
|
574
|
-
<div class="
|
|
575
|
-
|
|
576
|
-
|
|
620
|
+
<div class="container register">
|
|
621
|
+
<div class="register-header">
|
|
622
|
+
<h1>Create Account</h1>
|
|
623
|
+
<p class="subtitle">Register for a new Solid Pod${inviteOnly ? ' (invite required)' : ''}</p>
|
|
624
|
+
</div>
|
|
577
625
|
|
|
578
626
|
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
579
627
|
${success ? `<div class="error" style="background: #efe; border-color: #cfc; color: #060;">${escapeHtml(success)}</div>` : ''}
|
|
@@ -583,8 +631,14 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
|
|
|
583
631
|
|
|
584
632
|
<label for="username">Username</label>
|
|
585
633
|
<input type="text" id="username" name="username" required ${!inviteOnly ? 'autofocus' : ''}
|
|
586
|
-
placeholder="Choose a username"
|
|
587
|
-
|
|
634
|
+
placeholder="Choose a username" minlength="3" maxlength="32"
|
|
635
|
+
pattern="${usernamePattern}"
|
|
636
|
+
title="${usernameTitle}">
|
|
637
|
+
|
|
638
|
+
<div class="preview" id="preview" aria-live="polite">
|
|
639
|
+
<div><span class="label">WebID</span><span id="preview-webid" class="placeholder">choose a username to preview</span></div>
|
|
640
|
+
<div style="margin-top: 4px;"><span class="label">Storage</span><span id="preview-storage" class="placeholder">—</span></div>
|
|
641
|
+
</div>
|
|
588
642
|
|
|
589
643
|
<label for="password">Password</label>
|
|
590
644
|
<input type="password" id="password" name="password" required
|
|
@@ -601,6 +655,46 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
|
|
|
601
655
|
Already have an account? <a href="${uid ? `/idp/interaction/${uid}` : '/idp/auth'}" style="color: #0066cc;">Sign In</a>
|
|
602
656
|
</p>
|
|
603
657
|
</div>
|
|
658
|
+
|
|
659
|
+
<script>
|
|
660
|
+
(function () {
|
|
661
|
+
var cfg = ${previewConfig};
|
|
662
|
+
var input = document.getElementById('username');
|
|
663
|
+
var webEl = document.getElementById('preview-webid');
|
|
664
|
+
var storEl = document.getElementById('preview-storage');
|
|
665
|
+
if (!input || !webEl || !storEl) return;
|
|
666
|
+
|
|
667
|
+
function render() {
|
|
668
|
+
// Server rejects uppercase outright, so normalise the field as the
|
|
669
|
+
// user types — keeps the preview honest and avoids a confusing
|
|
670
|
+
// post-submit error.
|
|
671
|
+
var normalised = (input.value || '').toLowerCase();
|
|
672
|
+
if (input.value !== normalised) input.value = normalised;
|
|
673
|
+
var u = normalised.trim();
|
|
674
|
+
if (!u) {
|
|
675
|
+
webEl.textContent = 'choose a username to preview';
|
|
676
|
+
webEl.className = 'placeholder';
|
|
677
|
+
storEl.textContent = '—';
|
|
678
|
+
storEl.className = 'placeholder';
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
var pod, webid;
|
|
682
|
+
if (cfg.subdomainsEnabled && cfg.baseDomain) {
|
|
683
|
+
var origin = cfg.baseUri.split('://')[0] + '://';
|
|
684
|
+
pod = origin + u + '.' + cfg.baseDomain + '/';
|
|
685
|
+
} else {
|
|
686
|
+
pod = (cfg.baseUri || (location.protocol + '//' + location.host)) + '/' + u + '/';
|
|
687
|
+
}
|
|
688
|
+
webid = pod + 'profile/card.jsonld#me';
|
|
689
|
+
webEl.textContent = webid;
|
|
690
|
+
webEl.className = '';
|
|
691
|
+
storEl.textContent = pod;
|
|
692
|
+
storEl.className = '';
|
|
693
|
+
}
|
|
694
|
+
input.addEventListener('input', render);
|
|
695
|
+
render();
|
|
696
|
+
})();
|
|
697
|
+
</script>
|
|
604
698
|
</body>
|
|
605
699
|
</html>
|
|
606
700
|
`;
|
package/src/server.js
CHANGED
|
@@ -314,12 +314,12 @@ export function createServer(options = {}) {
|
|
|
314
314
|
// Note: OPTIONS requests are handled by handleOptions to include Accept-* headers
|
|
315
315
|
});
|
|
316
316
|
|
|
317
|
-
// ActivityPub actor endpoint - dedicated route for /profile/card with AP Accept header
|
|
317
|
+
// ActivityPub actor endpoint - dedicated route for /profile/card.jsonld with AP Accept header
|
|
318
318
|
// Registered before wildcard routes to take priority
|
|
319
319
|
if (activitypubEnabled) {
|
|
320
320
|
fastify.route({
|
|
321
321
|
method: 'GET',
|
|
322
|
-
url: '/profile/card',
|
|
322
|
+
url: '/profile/card.jsonld',
|
|
323
323
|
handler: async (request, reply) => {
|
|
324
324
|
const accept = request.headers.accept || '';
|
|
325
325
|
const wantsAP = accept.includes('activity+json') ||
|
|
@@ -426,13 +426,13 @@ export function createServer(options = {}) {
|
|
|
426
426
|
fastify.addHook('preHandler', async (request, reply) => {
|
|
427
427
|
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, nostr, git, and AP
|
|
428
428
|
const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
|
|
429
|
-
const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following',
|
|
429
|
+
const apPaths = ['/inbox', '/profile/card.jsonld/inbox', '/profile/card.jsonld/outbox', '/profile/card.jsonld/followers', '/profile/card.jsonld/following',
|
|
430
430
|
'/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials',
|
|
431
431
|
'/oauth/authorize', '/oauth/token'];
|
|
432
432
|
// Check if request wants ActivityPub content for profile
|
|
433
433
|
const accept = request.headers.accept || '';
|
|
434
434
|
const wantsAP = accept.includes('activity+json') || accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"');
|
|
435
|
-
const isProfileAP = activitypubEnabled && wantsAP && (request.url === '/profile/card' || request.url.startsWith('/profile/card?'));
|
|
435
|
+
const isProfileAP = activitypubEnabled && wantsAP && (request.url === '/profile/card.jsonld' || request.url.startsWith('/profile/card.jsonld?'));
|
|
436
436
|
if (request.url === '/.pods' ||
|
|
437
437
|
request.url === '/.notifications' ||
|
|
438
438
|
request.method === 'OPTIONS' ||
|
|
@@ -557,11 +557,15 @@ export function createServer(options = {}) {
|
|
|
557
557
|
const isRootPod = !singleUserName || singleUserName === '/';
|
|
558
558
|
const podPath = isRootPod ? '/' : `/${singleUserName}/`;
|
|
559
559
|
const podUri = isRootPod ? `${baseUrl}/` : `${baseUrl}/${singleUserName}/`;
|
|
560
|
-
const webId = `${podUri}profile/card#me`;
|
|
560
|
+
const webId = `${podUri}profile/card.jsonld#me`;
|
|
561
561
|
const displayName = isRootPod ? 'me' : singleUserName;
|
|
562
562
|
|
|
563
|
-
// Check if pod already exists
|
|
564
|
-
|
|
563
|
+
// Check if pod already exists. Accept either the new `card.jsonld`
|
|
564
|
+
// or legacy extensionless `card` layout so we don't re-seed a pod
|
|
565
|
+
// that was created by an older JSS version.
|
|
566
|
+
const profileExists =
|
|
567
|
+
await storage.exists(`${podPath}profile/card.jsonld`) ||
|
|
568
|
+
await storage.exists(`${podPath}profile/card`);
|
|
565
569
|
|
|
566
570
|
if (!profileExists) {
|
|
567
571
|
fastify.log.info(`Creating single-user pod at ${podUri}...`);
|
|
@@ -593,18 +597,18 @@ export function createServer(options = {}) {
|
|
|
593
597
|
await storage.createContainer('/profile/');
|
|
594
598
|
|
|
595
599
|
// Generate profile
|
|
596
|
-
const
|
|
597
|
-
await storage.write('/profile/card',
|
|
600
|
+
const profile = generateProfile({ webId, name: displayName, podUri, issuer });
|
|
601
|
+
await storage.write('/profile/card.jsonld', serialize(profile));
|
|
598
602
|
|
|
599
603
|
// Preferences and type indexes
|
|
600
604
|
const prefs = generatePreferences({ webId, podUri });
|
|
601
|
-
await storage.write('/settings/
|
|
605
|
+
await storage.write('/settings/prefs.jsonld', serialize(prefs));
|
|
602
606
|
|
|
603
|
-
const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex.
|
|
604
|
-
await storage.write('/settings/publicTypeIndex.
|
|
607
|
+
const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex.jsonld`);
|
|
608
|
+
await storage.write('/settings/publicTypeIndex.jsonld', serialize(publicTypeIndex));
|
|
605
609
|
|
|
606
|
-
const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex.
|
|
607
|
-
await storage.write('/settings/privateTypeIndex.
|
|
610
|
+
const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex.jsonld`);
|
|
611
|
+
await storage.write('/settings/privateTypeIndex.jsonld', serialize(privateTypeIndex));
|
|
608
612
|
|
|
609
613
|
// ACL files
|
|
610
614
|
const rootAcl = generateOwnerAcl(podUri, webId, true);
|
package/src/webid/profile.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* WebID Profile generation
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Creates profile documents following Solid conventions. Default profile
|
|
5
|
+
* shape is now plain JSON-LD at `profile/card.jsonld` — operators who
|
|
6
|
+
* want a human-readable HTML shell can serve their own `index.html` with
|
|
7
|
+
* an embedded `<script type="application/ld+json">` data island.
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
10
|
const FOAF = 'http://xmlns.com/foaf/0.1/';
|
|
@@ -45,95 +48,33 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
|
|
|
45
48
|
'inbox': `${pod}inbox/`,
|
|
46
49
|
'storage': pod,
|
|
47
50
|
'oidcIssuer': issuer,
|
|
48
|
-
'preferencesFile': `${pod}settings/
|
|
49
|
-
'publicTypeIndex': `${pod}settings/publicTypeIndex.
|
|
50
|
-
'privateTypeIndex': `${pod}settings/privateTypeIndex.
|
|
51
|
+
'preferencesFile': `${pod}settings/prefs.jsonld`,
|
|
52
|
+
'publicTypeIndex': `${pod}settings/publicTypeIndex.jsonld`,
|
|
53
|
+
'privateTypeIndex': `${pod}settings/privateTypeIndex.jsonld`
|
|
51
54
|
};
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
/**
|
|
55
|
-
* Generate
|
|
56
|
-
*
|
|
58
|
+
* Generate the profile document as a plain JSON-LD object.
|
|
59
|
+
*
|
|
60
|
+
* Previously returned an HTML shell with an embedded data island; that
|
|
61
|
+
* shell still exists for hand-curated personal sites, but server-default
|
|
62
|
+
* profiles are now plain JSON-LD for predictability and easier
|
|
63
|
+
* post-processing by clients.
|
|
64
|
+
*
|
|
57
65
|
* @param {object} options
|
|
58
66
|
* @param {string} options.webId - Full WebID URI
|
|
59
67
|
* @param {string} options.name - Display name
|
|
60
68
|
* @param {string} options.podUri - Pod root URI
|
|
61
69
|
* @param {string} options.issuer - OIDC issuer URI
|
|
62
|
-
* @returns {
|
|
70
|
+
* @returns {object} JSON-LD profile document
|
|
63
71
|
*/
|
|
64
72
|
export function generateProfile({ webId, name, podUri, issuer }) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return `<!DOCTYPE html>
|
|
68
|
-
<html lang="en">
|
|
69
|
-
<head>
|
|
70
|
-
<meta charset="utf-8">
|
|
71
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
72
|
-
<title>${escapeHtml(name)}'s Profile</title>
|
|
73
|
-
<link rel="stylesheet" href="https://javascriptsolidserver.github.io/mashlib-jss/dist/mash.css">
|
|
74
|
-
<script type="application/ld+json">
|
|
75
|
-
${JSON.stringify(jsonLd, null, 2)}
|
|
76
|
-
</script>
|
|
77
|
-
<style>
|
|
78
|
-
body { margin: 0; font-family: system-ui, sans-serif; }
|
|
79
|
-
.loading { padding: 2rem; text-align: center; color: #666; }
|
|
80
|
-
</style>
|
|
81
|
-
</head>
|
|
82
|
-
<body>
|
|
83
|
-
<div class="TabulatorOutline" id="DummyUUID" role="main">
|
|
84
|
-
<table id="outline"></table>
|
|
85
|
-
<div id="GlobalDashboard"></div>
|
|
86
|
-
</div>
|
|
87
|
-
<div class="loading" id="loading">Loading profile...</div>
|
|
88
|
-
|
|
89
|
-
<script src="https://javascriptsolidserver.github.io/mashlib-jss/dist/mashlib.min.js"></script>
|
|
90
|
-
<script src="https://cdn.jsdelivr.net/npm/solidos-lite/solidos-lite.js"></script>
|
|
91
|
-
<script>
|
|
92
|
-
document.addEventListener('DOMContentLoaded', function() {
|
|
93
|
-
const loadingEl = document.getElementById('loading');
|
|
94
|
-
|
|
95
|
-
// Initialize solidos-lite to handle data islands
|
|
96
|
-
const success = SolidOSLite.init({ verbose: false });
|
|
97
|
-
if (!success) {
|
|
98
|
-
loadingEl.textContent = 'Failed to initialize. Please try refreshing.';
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Parse data islands into the RDF store
|
|
103
|
-
SolidOSLite.parseAllIslands();
|
|
104
|
-
|
|
105
|
-
// Mark this document as already fetched
|
|
106
|
-
const pageBase = window.location.href.split('?')[0].split('#')[0];
|
|
107
|
-
const fetcher = SolidLogic.store.fetcher;
|
|
108
|
-
fetcher.requested[pageBase] = 'done';
|
|
109
|
-
fetcher.requested[pageBase.replace(/\\/$/, '')] = 'done';
|
|
110
|
-
|
|
111
|
-
// Navigate to #me
|
|
112
|
-
const subject = $rdf.sym(pageBase + '#me');
|
|
113
|
-
const outliner = panes.getOutliner(document);
|
|
114
|
-
outliner.GotoSubject(subject, true, undefined, true, undefined);
|
|
115
|
-
|
|
116
|
-
loadingEl.style.display = 'none';
|
|
117
|
-
});
|
|
118
|
-
</script>
|
|
119
|
-
</body>
|
|
120
|
-
</html>`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Escape HTML entities
|
|
125
|
-
*/
|
|
126
|
-
function escapeHtml(str) {
|
|
127
|
-
return str
|
|
128
|
-
.replace(/&/g, '&')
|
|
129
|
-
.replace(/</g, '<')
|
|
130
|
-
.replace(/>/g, '>')
|
|
131
|
-
.replace(/"/g, '"');
|
|
73
|
+
return generateProfileJsonLd({ webId, name, podUri, issuer });
|
|
132
74
|
}
|
|
133
75
|
|
|
134
76
|
/**
|
|
135
|
-
* Generate preferences file as JSON-LD
|
|
136
|
-
* Uses mashlib-compatible paths (settings/Preferences.ttl)
|
|
77
|
+
* Generate preferences file as JSON-LD.
|
|
137
78
|
* @param {object} options
|
|
138
79
|
* @param {string} options.webId - Full WebID URI
|
|
139
80
|
* @param {string} options.podUri - Pod root URI
|
|
@@ -149,9 +90,9 @@ export function generatePreferences({ webId, podUri }) {
|
|
|
149
90
|
'publicTypeIndex': { '@id': 'solid:publicTypeIndex', '@type': '@id' },
|
|
150
91
|
'privateTypeIndex': { '@id': 'solid:privateTypeIndex', '@type': '@id' }
|
|
151
92
|
},
|
|
152
|
-
'@id': `${pod}settings/
|
|
153
|
-
'publicTypeIndex': `${pod}settings/publicTypeIndex.
|
|
154
|
-
'privateTypeIndex': `${pod}settings/privateTypeIndex.
|
|
93
|
+
'@id': `${pod}settings/prefs.jsonld`,
|
|
94
|
+
'publicTypeIndex': `${pod}settings/publicTypeIndex.jsonld`,
|
|
95
|
+
'privateTypeIndex': `${pod}settings/privateTypeIndex.jsonld`
|
|
155
96
|
};
|
|
156
97
|
}
|
|
157
98
|
|
package/test/auth.test.js
CHANGED
|
@@ -173,7 +173,7 @@ describe('Authentication', () => {
|
|
|
173
173
|
{
|
|
174
174
|
'@id': '#owner',
|
|
175
175
|
'@type': 'acl:Authorization',
|
|
176
|
-
'acl:agent': { '@id': `${baseUrl}/authuser1/profile/card#me` },
|
|
176
|
+
'acl:agent': { '@id': `${baseUrl}/authuser1/profile/card.jsonld#me` },
|
|
177
177
|
'acl:accessTo': { '@id': `${baseUrl}/authuser1/authenticated-only/` },
|
|
178
178
|
'acl:default': { '@id': `${baseUrl}/authuser1/authenticated-only/` },
|
|
179
179
|
'acl:mode': [
|
package/test/idp.test.js
CHANGED
|
@@ -181,6 +181,125 @@ describe('Identity Provider', () => {
|
|
|
181
181
|
assert.ok(res.status >= 200 && res.status < 600, `got valid HTTP status ${res.status}`);
|
|
182
182
|
});
|
|
183
183
|
});
|
|
184
|
+
|
|
185
|
+
// Regression coverage for #284 — relaxed username regex + `..` rejection.
|
|
186
|
+
// Each register call below also exercises the .jsonld pod-creation flow
|
|
187
|
+
// from #283, since handleRegisterPost calls createPodStructure on success.
|
|
188
|
+
describe('Register username validation (path mode)', () => {
|
|
189
|
+
async function tryRegister(username) {
|
|
190
|
+
const res = await fetch(`${baseUrl}/idp/register`, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
193
|
+
body: new URLSearchParams({ username, password: 'secret-password', confirmPassword: 'secret-password' }),
|
|
194
|
+
});
|
|
195
|
+
const body = await res.text();
|
|
196
|
+
return { status: res.status, body };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
it('accepts plain alphanumeric (alice)', async () => {
|
|
200
|
+
const r = await tryRegister('alice');
|
|
201
|
+
assert.match(r.body, /Account created/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('accepts dash (alice-smith)', async () => {
|
|
205
|
+
const r = await tryRegister('alice-smith');
|
|
206
|
+
assert.match(r.body, /Account created/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('accepts dot (alice.smith)', async () => {
|
|
210
|
+
const r = await tryRegister('alice.smith');
|
|
211
|
+
assert.match(r.body, /Account created/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('accepts underscore (alice_work)', async () => {
|
|
215
|
+
const r = await tryRegister('alice_work');
|
|
216
|
+
assert.match(r.body, /Account created/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('rejects leading separator (.alice)', async () => {
|
|
220
|
+
const r = await tryRegister('.alice');
|
|
221
|
+
assert.match(r.body, /lowercase letters, numbers/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('rejects trailing separator (alice-)', async () => {
|
|
225
|
+
const r = await tryRegister('alice-');
|
|
226
|
+
assert.match(r.body, /lowercase letters, numbers/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('rejects consecutive dots (alice..bob)', async () => {
|
|
230
|
+
const r = await tryRegister('alice..bob');
|
|
231
|
+
// Quotes are HTML-escaped (") in the rendered error banner.
|
|
232
|
+
assert.match(r.body, /cannot contain (?:"|")\.\.(?:"|")/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('rejects uppercase (Alice)', async () => {
|
|
236
|
+
const r = await tryRegister('Alice');
|
|
237
|
+
assert.match(r.body, /lowercase letters, numbers/);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('rejects too short (ab)', async () => {
|
|
241
|
+
const r = await tryRegister('ab');
|
|
242
|
+
// Two-char names fail the regex (min 3 enforced by the pattern itself).
|
|
243
|
+
assert.match(r.body, /lowercase letters, numbers|at least 3/);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Subdomain mode: usernames become hostname components, so `.` and `_` are
|
|
249
|
+
// not allowed (server.js refuses to route multi-level subdomains).
|
|
250
|
+
describe('Identity Provider - Subdomain mode register validation', () => {
|
|
251
|
+
let server;
|
|
252
|
+
let baseUrl;
|
|
253
|
+
const SUBDOMAIN_DATA_DIR = './test-data-idp-subdomain';
|
|
254
|
+
|
|
255
|
+
before(async () => {
|
|
256
|
+
await fs.remove(SUBDOMAIN_DATA_DIR);
|
|
257
|
+
await fs.ensureDir(SUBDOMAIN_DATA_DIR);
|
|
258
|
+
|
|
259
|
+
const port = await getAvailablePort();
|
|
260
|
+
baseUrl = `http://${TEST_HOST}:${port}`;
|
|
261
|
+
|
|
262
|
+
server = createServer({
|
|
263
|
+
logger: false,
|
|
264
|
+
root: SUBDOMAIN_DATA_DIR,
|
|
265
|
+
idp: true,
|
|
266
|
+
idpIssuer: baseUrl,
|
|
267
|
+
subdomains: true,
|
|
268
|
+
baseDomain: TEST_HOST,
|
|
269
|
+
forceCloseConnections: true,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await server.listen({ port, host: TEST_HOST });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
after(async () => {
|
|
276
|
+
await server.close();
|
|
277
|
+
await fs.remove(SUBDOMAIN_DATA_DIR);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
async function tryRegister(username) {
|
|
281
|
+
const res = await fetch(`${baseUrl}/idp/register`, {
|
|
282
|
+
method: 'POST',
|
|
283
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
284
|
+
body: new URLSearchParams({ username, password: 'secret-password', confirmPassword: 'secret-password' }),
|
|
285
|
+
});
|
|
286
|
+
return { status: res.status, body: await res.text() };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
it('accepts dash (alice-smith)', async () => {
|
|
290
|
+
const r = await tryRegister('alice-smith');
|
|
291
|
+
assert.match(r.body, /Account created/);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('rejects dot (alice.smith) — would not be a single-level subdomain', async () => {
|
|
295
|
+
const r = await tryRegister('alice.smith');
|
|
296
|
+
assert.match(r.body, /subdomain mode disallows/);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('rejects underscore (alice_work) — invalid in DNS hostnames', async () => {
|
|
300
|
+
const r = await tryRegister('alice_work');
|
|
301
|
+
assert.match(r.body, /subdomain mode disallows/);
|
|
302
|
+
});
|
|
184
303
|
});
|
|
185
304
|
|
|
186
305
|
describe('Identity Provider - Accounts', () => {
|
package/test/pod.test.js
CHANGED
|
@@ -36,7 +36,7 @@ describe('Pod Lifecycle', () => {
|
|
|
36
36
|
|
|
37
37
|
const data = await res.json();
|
|
38
38
|
assert.strictEqual(data.name, 'alice');
|
|
39
|
-
assert.ok(data.webId.endsWith('/alice/profile/card#me'));
|
|
39
|
+
assert.ok(data.webId.endsWith('/alice/profile/card.jsonld#me'));
|
|
40
40
|
assert.ok(data.podUri.endsWith('/alice/'));
|
|
41
41
|
});
|
|
42
42
|
|
|
@@ -103,16 +103,16 @@ describe('Pod Lifecycle', () => {
|
|
|
103
103
|
it('should create settings files', async () => {
|
|
104
104
|
await createTestPod('dan');
|
|
105
105
|
|
|
106
|
-
// Check
|
|
107
|
-
const prefs = await request('/dan/settings/
|
|
106
|
+
// Check prefs.jsonld (needs auth - settings is private)
|
|
107
|
+
const prefs = await request('/dan/settings/prefs.jsonld', { auth: 'dan' });
|
|
108
108
|
assertStatus(prefs, 200);
|
|
109
109
|
|
|
110
110
|
// Check public type index (needs auth)
|
|
111
|
-
const pubIndex = await request('/dan/settings/publicTypeIndex.
|
|
111
|
+
const pubIndex = await request('/dan/settings/publicTypeIndex.jsonld', { auth: 'dan' });
|
|
112
112
|
assertStatus(pubIndex, 200);
|
|
113
113
|
|
|
114
114
|
// Check private type index (needs auth)
|
|
115
|
-
const privIndex = await request('/dan/settings/privateTypeIndex.
|
|
115
|
+
const privIndex = await request('/dan/settings/privateTypeIndex.jsonld', { auth: 'dan' });
|
|
116
116
|
assertStatus(privIndex, 200);
|
|
117
117
|
});
|
|
118
118
|
});
|
package/test/webid.test.js
CHANGED
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
assertStatus,
|
|
13
13
|
assertHeader,
|
|
14
14
|
assertHeaderContains,
|
|
15
|
-
extractJsonLdFromHtml
|
|
16
15
|
} from './helpers.js';
|
|
17
16
|
|
|
18
17
|
describe('WebID Profile', () => {
|
|
@@ -30,87 +29,80 @@ describe('WebID Profile', () => {
|
|
|
30
29
|
});
|
|
31
30
|
|
|
32
31
|
describe('Profile Document', () => {
|
|
33
|
-
// Profile is at /pod/profile/card
|
|
34
|
-
const profilePath = '/webidtest/profile/card';
|
|
32
|
+
// Profile is now a plain JSON-LD doc at /pod/profile/card.jsonld.
|
|
33
|
+
const profilePath = '/webidtest/profile/card.jsonld';
|
|
35
34
|
|
|
36
|
-
it('should serve profile as
|
|
35
|
+
it('should serve profile as JSON-LD', async () => {
|
|
37
36
|
const res = await request(profilePath);
|
|
38
37
|
|
|
39
38
|
assertStatus(res, 200);
|
|
40
|
-
assertHeaderContains(res, 'Content-Type', '
|
|
39
|
+
assertHeaderContains(res, 'Content-Type', 'application/ld+json');
|
|
41
40
|
});
|
|
42
41
|
|
|
43
|
-
it('should
|
|
42
|
+
it('should be valid JSON-LD with @context and @id', async () => {
|
|
44
43
|
const res = await request(profilePath);
|
|
45
|
-
const
|
|
44
|
+
const jsonLd = await res.json();
|
|
46
45
|
|
|
47
|
-
const jsonLd = extractJsonLdFromHtml(html);
|
|
48
46
|
assert.ok(jsonLd['@context'], 'Should have @context');
|
|
49
|
-
// Profile uses flat structure, not @graph
|
|
50
47
|
assert.ok(jsonLd['@id'], 'Should have @id');
|
|
51
48
|
});
|
|
52
49
|
|
|
53
50
|
it('should have correct WebID URI', async () => {
|
|
54
51
|
const res = await request(profilePath);
|
|
55
|
-
const
|
|
56
|
-
const jsonLd = extractJsonLdFromHtml(html);
|
|
52
|
+
const jsonLd = await res.json();
|
|
57
53
|
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
assert.ok(jsonLd['@id'].endsWith('/webidtest/profile/card.jsonld#me'),
|
|
55
|
+
`WebID should end with /profile/card.jsonld#me, got ${jsonLd['@id']}`);
|
|
60
56
|
});
|
|
61
57
|
|
|
62
58
|
it('should have foaf:name', async () => {
|
|
63
59
|
const res = await request(profilePath);
|
|
64
|
-
const
|
|
65
|
-
const jsonLd = extractJsonLdFromHtml(html);
|
|
60
|
+
const jsonLd = await res.json();
|
|
66
61
|
|
|
67
62
|
assert.strictEqual(jsonLd['foaf:name'], 'webidtest');
|
|
68
63
|
});
|
|
69
64
|
|
|
70
65
|
it('should have solid:oidcIssuer', async () => {
|
|
71
66
|
const res = await request(profilePath);
|
|
72
|
-
const
|
|
73
|
-
const jsonLd = extractJsonLdFromHtml(html);
|
|
67
|
+
const jsonLd = await res.json();
|
|
74
68
|
|
|
75
69
|
assert.ok(jsonLd['oidcIssuer'], 'Should have oidcIssuer');
|
|
76
70
|
});
|
|
77
71
|
|
|
78
72
|
it('should have pim:storage pointing to pod', async () => {
|
|
79
73
|
const res = await request(profilePath);
|
|
80
|
-
const
|
|
81
|
-
const jsonLd = extractJsonLdFromHtml(html);
|
|
74
|
+
const jsonLd = await res.json();
|
|
82
75
|
|
|
83
76
|
assert.ok(jsonLd['storage'].endsWith('/webidtest/'), 'Storage should point to pod');
|
|
84
77
|
});
|
|
85
78
|
|
|
86
79
|
it('should have ldp:inbox', async () => {
|
|
87
80
|
const res = await request(profilePath);
|
|
88
|
-
const
|
|
89
|
-
const jsonLd = extractJsonLdFromHtml(html);
|
|
81
|
+
const jsonLd = await res.json();
|
|
90
82
|
|
|
91
83
|
assert.ok(jsonLd['inbox'].endsWith('/webidtest/inbox/'), 'Should have inbox');
|
|
92
84
|
});
|
|
93
85
|
|
|
94
86
|
it('should have mainEntityOfPage', async () => {
|
|
95
87
|
const res = await request(profilePath);
|
|
96
|
-
const
|
|
97
|
-
const jsonLd = extractJsonLdFromHtml(html);
|
|
88
|
+
const jsonLd = await res.json();
|
|
98
89
|
|
|
99
|
-
// Check for mainEntityOfPage which links to the profile document
|
|
100
90
|
assert.ok(jsonLd['mainEntityOfPage'], 'Should have mainEntityOfPage');
|
|
101
91
|
});
|
|
102
92
|
});
|
|
103
93
|
|
|
104
94
|
describe('WebID Resolution', () => {
|
|
95
|
+
const profilePath = '/webidtest/profile/card.jsonld';
|
|
96
|
+
|
|
105
97
|
it('should return LDP headers', async () => {
|
|
106
|
-
const res = await request(
|
|
98
|
+
const res = await request(profilePath);
|
|
107
99
|
|
|
108
100
|
assertHeaderContains(res, 'Link', 'ldp#Resource');
|
|
109
101
|
assertHeader(res, 'WAC-Allow');
|
|
110
102
|
});
|
|
111
103
|
|
|
112
104
|
it('should return CORS headers', async () => {
|
|
113
|
-
const res = await request(
|
|
105
|
+
const res = await request(profilePath, {
|
|
114
106
|
headers: { 'Origin': 'https://example.com' }
|
|
115
107
|
});
|
|
116
108
|
|