javascript-solid-server 0.0.15 → 0.0.16
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/.claude/settings.local.json +2 -1
- package/README.md +61 -3
- package/bin/jss.js +6 -0
- package/package.json +1 -1
- package/src/auth/middleware.js +11 -5
- package/src/config.js +7 -0
- package/src/handlers/container.js +41 -13
- package/src/handlers/resource.js +37 -31
- package/src/server.js +24 -0
- package/src/utils/url.js +52 -0
- package/test-data-idp-accounts/.idp/accounts/3c1cd503-1d7f-4ba0-a3af-ebedf519594d.json +9 -0
- package/test-data-idp-accounts/.idp/accounts/_email_index.json +1 -1
- package/test-data-idp-accounts/.idp/accounts/_webid_index.json +1 -1
- package/test-data-idp-accounts/.idp/keys/jwks.json +8 -8
- package/test-data-idp-accounts/.idp/accounts/292738d6-3363-4f40-9a6b-884bfd17830a.json +0 -9
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
"Bash(ACCESS_TOKEN=\"eyJhbGciOiJFUzI1NiIsImtpZCI6IjQwY2U0YzIzLWY2OWQtNDU4NS05ODg2LTE4MDQzZWIyZjU2ZCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjQwMDAvIiwic3ViIjoiYjhlZjY5YWUtODc0ZS00MDg5LThiMDktOGQwY2QyM2VlZWY3IiwiYXVkIjoic29saWQiLCJ3ZWJpZCI6Imh0dHA6Ly9sb2NhbGhvc3Q0MDAwL2FsaWNlLyNtZSIsImlhdCI6MTc2NjgyOTQ5MiwiZXhwIjoxNzY2ODMzMDkyLCJqdGkiOiIwMWY4ODVlZS05ZjY2LTQ3M2MtYmZkNC05MWM4ZGU3NGJhZjYiLCJjbGllbnRfaWQiOiJjcmVkZW50aWFsc19jbGllbnQiLCJzY29wZSI6Im9wZW5pZCB3ZWJpZCJ9.DYTlSRkORyDN28XtXk-zbR7xNLViD97KkPqUKb6chV860BaIgwa1suif4TxHQDnK_ejvbvmZ46_n5WwwRnf_Zw\" curl -sI -X PUT http://localhost:4000/alice/cth-test/ -H \"Content-Type: text/turtle\" -H \"Authorization: Bearer $ACCESS_TOKEN\")",
|
|
52
52
|
"Bash(timeout 60 docker run:*)",
|
|
53
53
|
"Bash(rm:*)",
|
|
54
|
-
"Bash(mkdir:*)"
|
|
54
|
+
"Bash(mkdir:*)",
|
|
55
|
+
"WebFetch(domain:communitysolidserver.github.io)"
|
|
55
56
|
]
|
|
56
57
|
}
|
|
57
58
|
}
|
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ npm run benchmark
|
|
|
54
54
|
|
|
55
55
|
## Features
|
|
56
56
|
|
|
57
|
-
### Implemented (v0.0.
|
|
57
|
+
### Implemented (v0.0.16)
|
|
58
58
|
|
|
59
59
|
- **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
|
|
60
60
|
- **N3 Patch** - Solid's native patch format for RDF updates
|
|
@@ -64,7 +64,8 @@ npm run benchmark
|
|
|
64
64
|
- **SSL/TLS** - HTTPS support with certificate configuration
|
|
65
65
|
- **WebSocket Notifications** - Real-time updates via solid-0.1 protocol (SolidOS compatible)
|
|
66
66
|
- **Container Management** - Create, list, and manage containers
|
|
67
|
-
- **Multi-user Pods** -
|
|
67
|
+
- **Multi-user Pods** - Path-based (`/alice/`) or subdomain-based (`alice.example.com`)
|
|
68
|
+
- **Subdomain Mode** - XSS protection via origin isolation
|
|
68
69
|
- **WebID Profiles** - JSON-LD structured data in HTML at pod root
|
|
69
70
|
- **Web Access Control (WAC)** - `.acl` file-based authorization
|
|
70
71
|
- **Solid-OIDC Identity Provider** - Built-in IdP with DPoP, dynamic registration
|
|
@@ -135,6 +136,8 @@ jss --help # Show help
|
|
|
135
136
|
| `--notifications` | Enable WebSocket | false |
|
|
136
137
|
| `--idp` | Enable built-in IdP | false |
|
|
137
138
|
| `--idp-issuer <url>` | IdP issuer URL | (auto) |
|
|
139
|
+
| `--subdomains` | Enable subdomain-based pods | false |
|
|
140
|
+
| `--base-domain <domain>` | Base domain for subdomains | - |
|
|
138
141
|
| `-q, --quiet` | Suppress logs | false |
|
|
139
142
|
|
|
140
143
|
### Environment Variables
|
|
@@ -146,6 +149,8 @@ export JSS_PORT=8443
|
|
|
146
149
|
export JSS_SSL_KEY=/path/to/key.pem
|
|
147
150
|
export JSS_SSL_CERT=/path/to/cert.pem
|
|
148
151
|
export JSS_CONNEG=true
|
|
152
|
+
export JSS_SUBDOMAINS=true
|
|
153
|
+
export JSS_BASE_DOMAIN=example.com
|
|
149
154
|
jss start
|
|
150
155
|
```
|
|
151
156
|
|
|
@@ -338,13 +343,66 @@ curl -H "Authorization: DPoP ACCESS_TOKEN" \
|
|
|
338
343
|
http://localhost:3000/alice/private/
|
|
339
344
|
```
|
|
340
345
|
|
|
346
|
+
## Subdomain Mode (XSS Protection)
|
|
347
|
+
|
|
348
|
+
By default, JSS uses **path-based pods** (`/alice/`, `/bob/`). This is simple but has a security limitation: all pods share the same origin, making cross-site scripting (XSS) attacks possible between pods.
|
|
349
|
+
|
|
350
|
+
**Subdomain mode** provides **origin isolation** - each pod gets its own subdomain (`alice.example.com`, `bob.example.com`), preventing XSS attacks between pods.
|
|
351
|
+
|
|
352
|
+
### Why Subdomain Mode?
|
|
353
|
+
|
|
354
|
+
| Mode | URL | Origin | XSS Risk |
|
|
355
|
+
|------|-----|--------|----------|
|
|
356
|
+
| Path-based | `example.com/alice/` | `example.com` | Shared origin - pods can XSS each other |
|
|
357
|
+
| Subdomain | `alice.example.com/` | `alice.example.com` | Isolated - browser's Same-Origin Policy protects |
|
|
358
|
+
|
|
359
|
+
### Enabling Subdomain Mode
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
jss start --subdomains --base-domain example.com
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Or via environment variables:
|
|
366
|
+
|
|
367
|
+
```bash
|
|
368
|
+
export JSS_SUBDOMAINS=true
|
|
369
|
+
export JSS_BASE_DOMAIN=example.com
|
|
370
|
+
jss start
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### DNS Configuration
|
|
374
|
+
|
|
375
|
+
You need a **wildcard DNS record** pointing to your server:
|
|
376
|
+
|
|
377
|
+
```
|
|
378
|
+
*.example.com A <your-server-ip>
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### Pod URLs in Subdomain Mode
|
|
382
|
+
|
|
383
|
+
| Path Mode | Subdomain Mode |
|
|
384
|
+
|-----------|----------------|
|
|
385
|
+
| `example.com/alice/` | `alice.example.com/` |
|
|
386
|
+
| `example.com/alice/public/file.txt` | `alice.example.com/public/file.txt` |
|
|
387
|
+
| `example.com/alice/#me` | `alice.example.com/#me` |
|
|
388
|
+
|
|
389
|
+
Pod creation still uses the main domain:
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
curl -X POST https://example.com/.pods \
|
|
393
|
+
-H "Content-Type: application/json" \
|
|
394
|
+
-d '{"name": "alice"}'
|
|
395
|
+
```
|
|
396
|
+
|
|
341
397
|
## Configuration
|
|
342
398
|
|
|
343
399
|
```javascript
|
|
344
400
|
createServer({
|
|
345
401
|
logger: true, // Enable Fastify logging (default: true)
|
|
346
402
|
conneg: false, // Enable content negotiation (default: false)
|
|
347
|
-
notifications: false // Enable WebSocket notifications (default: false)
|
|
403
|
+
notifications: false, // Enable WebSocket notifications (default: false)
|
|
404
|
+
subdomains: false, // Enable subdomain-based pods (default: false)
|
|
405
|
+
baseDomain: null, // Base domain for subdomains (e.g., "example.com")
|
|
348
406
|
});
|
|
349
407
|
```
|
|
350
408
|
|
package/bin/jss.js
CHANGED
|
@@ -47,6 +47,9 @@ program
|
|
|
47
47
|
.option('--idp', 'Enable built-in Identity Provider')
|
|
48
48
|
.option('--no-idp', 'Disable built-in Identity Provider')
|
|
49
49
|
.option('--idp-issuer <url>', 'IdP issuer URL (defaults to server URL)')
|
|
50
|
+
.option('--subdomains', 'Enable subdomain-based pods (XSS protection)')
|
|
51
|
+
.option('--no-subdomains', 'Disable subdomain-based pods')
|
|
52
|
+
.option('--base-domain <domain>', 'Base domain for subdomain pods (e.g., "example.com")')
|
|
50
53
|
.option('-q, --quiet', 'Suppress log output')
|
|
51
54
|
.option('--print-config', 'Print configuration and exit')
|
|
52
55
|
.action(async (options) => {
|
|
@@ -80,6 +83,8 @@ program
|
|
|
80
83
|
cert: await fs.readFile(config.sslCert),
|
|
81
84
|
} : null,
|
|
82
85
|
root: config.root,
|
|
86
|
+
subdomains: config.subdomains,
|
|
87
|
+
baseDomain: config.baseDomain,
|
|
83
88
|
});
|
|
84
89
|
|
|
85
90
|
await server.listen({ port: config.port, host: config.host });
|
|
@@ -92,6 +97,7 @@ program
|
|
|
92
97
|
if (config.conneg) console.log(' Conneg: enabled');
|
|
93
98
|
if (config.notifications) console.log(' WebSocket: enabled');
|
|
94
99
|
if (config.idp) console.log(` IdP: ${idpIssuer}`);
|
|
100
|
+
if (config.subdomains) console.log(` Subdomains: ${config.baseDomain} (XSS protection enabled)`);
|
|
95
101
|
console.log('\n Press Ctrl+C to stop\n');
|
|
96
102
|
}
|
|
97
103
|
|
package/package.json
CHANGED
package/src/auth/middleware.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { getWebIdFromRequestAsync } from './token.js';
|
|
8
8
|
import { checkAccess, getRequiredMode } from '../wac/checker.js';
|
|
9
9
|
import * as storage from '../storage/filesystem.js';
|
|
10
|
+
import { getEffectiveUrlPath } from '../utils/url.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Check if request is authorized
|
|
@@ -27,27 +28,32 @@ export async function authorize(request, reply) {
|
|
|
27
28
|
// Get WebID from token (supports both simple and Solid-OIDC tokens)
|
|
28
29
|
const { webId, error: authError } = await getWebIdFromRequestAsync(request);
|
|
29
30
|
|
|
31
|
+
// Get effective storage path (includes pod name in subdomain mode)
|
|
32
|
+
const storagePath = getEffectiveUrlPath(request);
|
|
33
|
+
|
|
30
34
|
// Get resource info
|
|
31
|
-
const stats = await storage.stat(
|
|
35
|
+
const stats = await storage.stat(storagePath);
|
|
32
36
|
const resourceExists = stats !== null;
|
|
33
37
|
const isContainer = stats?.isDirectory || urlPath.endsWith('/');
|
|
34
38
|
|
|
35
|
-
// Build resource URL
|
|
39
|
+
// Build resource URL (uses actual request hostname which may be subdomain)
|
|
36
40
|
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
37
41
|
|
|
38
42
|
// Get required access mode for this method
|
|
39
43
|
const requiredMode = getRequiredMode(method);
|
|
40
44
|
|
|
41
45
|
// For write operations on non-existent resources, check parent container
|
|
42
|
-
let checkPath =
|
|
46
|
+
let checkPath = storagePath;
|
|
43
47
|
let checkUrl = resourceUrl;
|
|
44
48
|
let checkIsContainer = isContainer;
|
|
45
49
|
|
|
46
50
|
if (!resourceExists && (method === 'PUT' || method === 'POST' || method === 'PATCH')) {
|
|
47
51
|
// Check write permission on parent container
|
|
48
|
-
const parentPath = getParentPath(
|
|
52
|
+
const parentPath = getParentPath(storagePath);
|
|
49
53
|
checkPath = parentPath;
|
|
50
|
-
|
|
54
|
+
// For URL, also need to get parent
|
|
55
|
+
const parentUrlPath = getParentPath(urlPath);
|
|
56
|
+
checkUrl = `${request.protocol}://${request.hostname}${parentUrlPath}`;
|
|
51
57
|
checkIsContainer = true;
|
|
52
58
|
}
|
|
53
59
|
|
package/src/config.js
CHANGED
|
@@ -33,6 +33,10 @@ export const defaults = {
|
|
|
33
33
|
idp: false,
|
|
34
34
|
idpIssuer: null,
|
|
35
35
|
|
|
36
|
+
// Subdomain mode (XSS protection)
|
|
37
|
+
subdomains: false,
|
|
38
|
+
baseDomain: null,
|
|
39
|
+
|
|
36
40
|
// Logging
|
|
37
41
|
logger: true,
|
|
38
42
|
quiet: false,
|
|
@@ -57,6 +61,8 @@ const envMap = {
|
|
|
57
61
|
JSS_CONFIG_PATH: 'configPath',
|
|
58
62
|
JSS_IDP: 'idp',
|
|
59
63
|
JSS_IDP_ISSUER: 'idpIssuer',
|
|
64
|
+
JSS_SUBDOMAINS: 'subdomains',
|
|
65
|
+
JSS_BASE_DOMAIN: 'baseDomain',
|
|
60
66
|
};
|
|
61
67
|
|
|
62
68
|
/**
|
|
@@ -188,5 +194,6 @@ export function printConfig(config) {
|
|
|
188
194
|
console.log(` Conneg: ${config.conneg}`);
|
|
189
195
|
console.log(` Notifications: ${config.notifications}`);
|
|
190
196
|
console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`);
|
|
197
|
+
console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
|
|
191
198
|
console.log('─'.repeat(40));
|
|
192
199
|
}
|
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
import * as storage from '../storage/filesystem.js';
|
|
2
2
|
import { getAllHeaders } from '../ldp/headers.js';
|
|
3
|
-
import { isContainer } from '../utils/url.js';
|
|
3
|
+
import { isContainer, getEffectiveUrlPath } from '../utils/url.js';
|
|
4
4
|
import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
|
|
5
5
|
import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl } from '../wac/parser.js';
|
|
6
6
|
import { createToken } from '../auth/token.js';
|
|
7
7
|
import { canAcceptInput, toJsonLd, getVaryHeader, RDF_TYPES } from '../rdf/conneg.js';
|
|
8
8
|
import { emitChange } from '../notifications/events.js';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Get the storage path and resource URL for a request
|
|
12
|
+
* In subdomain mode, storage path includes pod name, URL uses subdomain
|
|
13
|
+
*/
|
|
14
|
+
function getRequestPaths(request) {
|
|
15
|
+
const urlPath = request.url.split('?')[0];
|
|
16
|
+
// Storage path - includes pod name in subdomain mode
|
|
17
|
+
const storagePath = getEffectiveUrlPath(request);
|
|
18
|
+
// Resource URL - uses the actual request hostname (subdomain in subdomain mode)
|
|
19
|
+
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
20
|
+
return { urlPath, storagePath, resourceUrl };
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
/**
|
|
11
24
|
* Handle POST request to container (create new resource)
|
|
12
25
|
*/
|
|
13
26
|
export async function handlePost(request, reply) {
|
|
14
|
-
const urlPath = request
|
|
27
|
+
const { urlPath, storagePath } = getRequestPaths(request);
|
|
15
28
|
|
|
16
29
|
// Ensure target is a container
|
|
17
30
|
if (!isContainer(urlPath)) {
|
|
@@ -32,10 +45,10 @@ export async function handlePost(request, reply) {
|
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
// Check container exists
|
|
35
|
-
const stats = await storage.stat(
|
|
48
|
+
const stats = await storage.stat(storagePath);
|
|
36
49
|
if (!stats || !stats.isDirectory) {
|
|
37
50
|
// Create container if it doesn't exist
|
|
38
|
-
await storage.createContainer(
|
|
51
|
+
await storage.createContainer(storagePath);
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
// Get slug from header or generate UUID
|
|
@@ -46,13 +59,14 @@ export async function handlePost(request, reply) {
|
|
|
46
59
|
const isCreatingContainer = linkHeader.includes('Container') || linkHeader.includes('BasicContainer');
|
|
47
60
|
|
|
48
61
|
// Generate unique filename
|
|
49
|
-
const filename = await storage.generateUniqueFilename(
|
|
50
|
-
const
|
|
51
|
-
const
|
|
62
|
+
const filename = await storage.generateUniqueFilename(storagePath, slug, isCreatingContainer);
|
|
63
|
+
const newUrlPath = urlPath + filename + (isCreatingContainer ? '/' : '');
|
|
64
|
+
const newStoragePath = storagePath + filename + (isCreatingContainer ? '/' : '');
|
|
65
|
+
const resourceUrl = `${request.protocol}://${request.hostname}${newUrlPath}`;
|
|
52
66
|
|
|
53
67
|
let success;
|
|
54
68
|
if (isCreatingContainer) {
|
|
55
|
-
success = await storage.createContainer(
|
|
69
|
+
success = await storage.createContainer(newStoragePath);
|
|
56
70
|
} else {
|
|
57
71
|
// Get content from request body
|
|
58
72
|
let content = request.body;
|
|
@@ -80,7 +94,7 @@ export async function handlePost(request, reply) {
|
|
|
80
94
|
}
|
|
81
95
|
}
|
|
82
96
|
|
|
83
|
-
success = await storage.write(
|
|
97
|
+
success = await storage.write(newStoragePath, content);
|
|
84
98
|
}
|
|
85
99
|
|
|
86
100
|
if (!success) {
|
|
@@ -153,10 +167,24 @@ export async function handleCreatePod(request, reply) {
|
|
|
153
167
|
}
|
|
154
168
|
|
|
155
169
|
// Build URIs
|
|
156
|
-
// WebID is at pod root: /alice/#me
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
170
|
+
// WebID is at pod root: /alice/#me (path mode) or alice.example.com/#me (subdomain mode)
|
|
171
|
+
const subdomainsEnabled = request.subdomainsEnabled;
|
|
172
|
+
const baseDomain = request.baseDomain;
|
|
173
|
+
|
|
174
|
+
let baseUri, podUri, webId;
|
|
175
|
+
if (subdomainsEnabled && baseDomain) {
|
|
176
|
+
// Subdomain mode: alice.example.com/
|
|
177
|
+
const podHost = `${name}.${baseDomain}`;
|
|
178
|
+
baseUri = `${request.protocol}://${baseDomain}`;
|
|
179
|
+
podUri = `${request.protocol}://${podHost}/`;
|
|
180
|
+
webId = `${podUri}#me`;
|
|
181
|
+
} else {
|
|
182
|
+
// Path mode: example.com/alice/
|
|
183
|
+
baseUri = `${request.protocol}://${request.hostname}`;
|
|
184
|
+
podUri = `${baseUri}${podPath}`;
|
|
185
|
+
webId = `${podUri}#me`;
|
|
186
|
+
}
|
|
187
|
+
|
|
160
188
|
// Issuer needs trailing slash for CTH compatibility
|
|
161
189
|
const issuer = baseUri + '/';
|
|
162
190
|
|
package/src/handlers/resource.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as storage from '../storage/filesystem.js';
|
|
2
2
|
import { getAllHeaders } from '../ldp/headers.js';
|
|
3
3
|
import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
|
|
4
|
-
import { isContainer, getContentType, isRdfContentType } from '../utils/url.js';
|
|
4
|
+
import { isContainer, getContentType, isRdfContentType, getEffectiveUrlPath } from '../utils/url.js';
|
|
5
5
|
import { parseN3Patch, applyN3Patch, validatePatch } from '../patch/n3-patch.js';
|
|
6
6
|
import { parseSparqlUpdate, applySparqlUpdate } from '../patch/sparql-update.js';
|
|
7
7
|
import {
|
|
@@ -15,12 +15,25 @@ import {
|
|
|
15
15
|
import { emitChange } from '../notifications/events.js';
|
|
16
16
|
import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Get the storage path and resource URL for a request
|
|
20
|
+
* In subdomain mode, storage path includes pod name, URL uses subdomain
|
|
21
|
+
*/
|
|
22
|
+
function getRequestPaths(request) {
|
|
23
|
+
const urlPath = request.url.split('?')[0];
|
|
24
|
+
// Storage path - includes pod name in subdomain mode
|
|
25
|
+
const storagePath = getEffectiveUrlPath(request);
|
|
26
|
+
// Resource URL - uses the actual request hostname (subdomain in subdomain mode)
|
|
27
|
+
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
28
|
+
return { urlPath, storagePath, resourceUrl };
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
/**
|
|
19
32
|
* Handle GET request
|
|
20
33
|
*/
|
|
21
34
|
export async function handleGet(request, reply) {
|
|
22
|
-
const urlPath = request
|
|
23
|
-
const stats = await storage.stat(
|
|
35
|
+
const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
|
|
36
|
+
const stats = await storage.stat(storagePath);
|
|
24
37
|
|
|
25
38
|
if (!stats) {
|
|
26
39
|
return reply.code(404).send({ error: 'Not Found' });
|
|
@@ -36,14 +49,13 @@ export async function handleGet(request, reply) {
|
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
const origin = request.headers.origin;
|
|
39
|
-
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
40
52
|
|
|
41
53
|
// Handle container
|
|
42
54
|
if (stats.isDirectory) {
|
|
43
55
|
const connegEnabled = request.connegEnabled || false;
|
|
44
56
|
|
|
45
57
|
// Check for index.html (serves as both profile and container representation)
|
|
46
|
-
const indexPath =
|
|
58
|
+
const indexPath = storagePath.endsWith('/') ? `${storagePath}index.html` : `${storagePath}/index.html`;
|
|
47
59
|
const indexExists = await storage.exists(indexPath);
|
|
48
60
|
|
|
49
61
|
if (indexExists) {
|
|
@@ -105,7 +117,7 @@ export async function handleGet(request, reply) {
|
|
|
105
117
|
}
|
|
106
118
|
|
|
107
119
|
// No index.html, return JSON-LD container listing
|
|
108
|
-
const entries = await storage.listContainer(
|
|
120
|
+
const entries = await storage.listContainer(storagePath);
|
|
109
121
|
const jsonLd = generateContainerJsonLd(resourceUrl, entries || []);
|
|
110
122
|
|
|
111
123
|
const headers = getAllHeaders({
|
|
@@ -122,12 +134,12 @@ export async function handleGet(request, reply) {
|
|
|
122
134
|
}
|
|
123
135
|
|
|
124
136
|
// Handle resource
|
|
125
|
-
const content = await storage.read(
|
|
137
|
+
const content = await storage.read(storagePath);
|
|
126
138
|
if (content === null) {
|
|
127
139
|
return reply.code(500).send({ error: 'Read error' });
|
|
128
140
|
}
|
|
129
141
|
|
|
130
|
-
const storedContentType = getContentType(
|
|
142
|
+
const storedContentType = getContentType(storagePath);
|
|
131
143
|
const connegEnabled = request.connegEnabled || false;
|
|
132
144
|
|
|
133
145
|
// Content negotiation for RDF resources
|
|
@@ -184,16 +196,15 @@ export async function handleGet(request, reply) {
|
|
|
184
196
|
* Handle HEAD request
|
|
185
197
|
*/
|
|
186
198
|
export async function handleHead(request, reply) {
|
|
187
|
-
const
|
|
188
|
-
const stats = await storage.stat(
|
|
199
|
+
const { storagePath, resourceUrl } = getRequestPaths(request);
|
|
200
|
+
const stats = await storage.stat(storagePath);
|
|
189
201
|
|
|
190
202
|
if (!stats) {
|
|
191
203
|
return reply.code(404).send();
|
|
192
204
|
}
|
|
193
205
|
|
|
194
206
|
const origin = request.headers.origin;
|
|
195
|
-
const
|
|
196
|
-
const contentType = stats.isDirectory ? 'application/ld+json' : getContentType(urlPath);
|
|
207
|
+
const contentType = stats.isDirectory ? 'application/ld+json' : getContentType(storagePath);
|
|
197
208
|
|
|
198
209
|
const headers = getAllHeaders({
|
|
199
210
|
isContainer: stats.isDirectory,
|
|
@@ -215,20 +226,19 @@ export async function handleHead(request, reply) {
|
|
|
215
226
|
* Handle PUT request
|
|
216
227
|
*/
|
|
217
228
|
export async function handlePut(request, reply) {
|
|
218
|
-
const urlPath = request
|
|
229
|
+
const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
|
|
219
230
|
const connegEnabled = request.connegEnabled || false;
|
|
220
|
-
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
221
231
|
|
|
222
232
|
// Handle container creation via PUT
|
|
223
233
|
if (isContainer(urlPath)) {
|
|
224
|
-
const stats = await storage.stat(
|
|
234
|
+
const stats = await storage.stat(storagePath);
|
|
225
235
|
if (stats?.isDirectory) {
|
|
226
236
|
// Container already exists - don't allow PUT to modify
|
|
227
237
|
return reply.code(409).send({ error: 'Cannot PUT to existing container' });
|
|
228
238
|
}
|
|
229
239
|
|
|
230
240
|
// Create the container (and any intermediate containers)
|
|
231
|
-
const success = await storage.createContainer(
|
|
241
|
+
const success = await storage.createContainer(storagePath);
|
|
232
242
|
if (!success) {
|
|
233
243
|
return reply.code(500).send({ error: 'Failed to create container' });
|
|
234
244
|
}
|
|
@@ -258,7 +268,7 @@ export async function handlePut(request, reply) {
|
|
|
258
268
|
}
|
|
259
269
|
|
|
260
270
|
// Check if resource already exists and get current ETag
|
|
261
|
-
const stats = await storage.stat(
|
|
271
|
+
const stats = await storage.stat(storagePath);
|
|
262
272
|
const existed = stats !== null;
|
|
263
273
|
const currentEtag = stats?.etag || null;
|
|
264
274
|
|
|
@@ -308,7 +318,7 @@ export async function handlePut(request, reply) {
|
|
|
308
318
|
}
|
|
309
319
|
}
|
|
310
320
|
|
|
311
|
-
const success = await storage.write(
|
|
321
|
+
const success = await storage.write(storagePath, content);
|
|
312
322
|
if (!success) {
|
|
313
323
|
return reply.code(500).send({ error: 'Write failed' });
|
|
314
324
|
}
|
|
@@ -332,10 +342,10 @@ export async function handlePut(request, reply) {
|
|
|
332
342
|
* Handle DELETE request
|
|
333
343
|
*/
|
|
334
344
|
export async function handleDelete(request, reply) {
|
|
335
|
-
const
|
|
345
|
+
const { storagePath, resourceUrl } = getRequestPaths(request);
|
|
336
346
|
|
|
337
347
|
// Check if resource exists and get current ETag
|
|
338
|
-
const stats = await storage.stat(
|
|
348
|
+
const stats = await storage.stat(storagePath);
|
|
339
349
|
if (!stats) {
|
|
340
350
|
return reply.code(404).send({ error: 'Not Found' });
|
|
341
351
|
}
|
|
@@ -349,13 +359,12 @@ export async function handleDelete(request, reply) {
|
|
|
349
359
|
}
|
|
350
360
|
}
|
|
351
361
|
|
|
352
|
-
const success = await storage.remove(
|
|
362
|
+
const success = await storage.remove(storagePath);
|
|
353
363
|
if (!success) {
|
|
354
364
|
return reply.code(500).send({ error: 'Delete failed' });
|
|
355
365
|
}
|
|
356
366
|
|
|
357
367
|
const origin = request.headers.origin;
|
|
358
|
-
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
359
368
|
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
|
|
360
369
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
361
370
|
|
|
@@ -371,11 +380,10 @@ export async function handleDelete(request, reply) {
|
|
|
371
380
|
* Handle OPTIONS request
|
|
372
381
|
*/
|
|
373
382
|
export async function handleOptions(request, reply) {
|
|
374
|
-
const urlPath = request
|
|
375
|
-
const stats = await storage.stat(
|
|
383
|
+
const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
|
|
384
|
+
const stats = await storage.stat(storagePath);
|
|
376
385
|
|
|
377
386
|
const origin = request.headers.origin;
|
|
378
|
-
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
379
387
|
const connegEnabled = request.connegEnabled || false;
|
|
380
388
|
const headers = getAllHeaders({
|
|
381
389
|
isContainer: stats?.isDirectory || isContainer(urlPath),
|
|
@@ -393,7 +401,7 @@ export async function handleOptions(request, reply) {
|
|
|
393
401
|
* Supports N3 Patch format (text/n3) and SPARQL Update for updating RDF resources
|
|
394
402
|
*/
|
|
395
403
|
export async function handlePatch(request, reply) {
|
|
396
|
-
const urlPath = request
|
|
404
|
+
const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
|
|
397
405
|
|
|
398
406
|
// Don't allow PATCH to containers
|
|
399
407
|
if (isContainer(urlPath)) {
|
|
@@ -413,7 +421,7 @@ export async function handlePatch(request, reply) {
|
|
|
413
421
|
}
|
|
414
422
|
|
|
415
423
|
// Check if resource exists
|
|
416
|
-
const stats = await storage.stat(
|
|
424
|
+
const stats = await storage.stat(storagePath);
|
|
417
425
|
if (!stats) {
|
|
418
426
|
return reply.code(404).send({ error: 'Not Found' });
|
|
419
427
|
}
|
|
@@ -428,7 +436,7 @@ export async function handlePatch(request, reply) {
|
|
|
428
436
|
}
|
|
429
437
|
|
|
430
438
|
// Read existing content
|
|
431
|
-
const existingContent = await storage.read(
|
|
439
|
+
const existingContent = await storage.read(storagePath);
|
|
432
440
|
if (existingContent === null) {
|
|
433
441
|
return reply.code(500).send({ error: 'Read error' });
|
|
434
442
|
}
|
|
@@ -449,8 +457,6 @@ export async function handlePatch(request, reply) {
|
|
|
449
457
|
? request.body.toString()
|
|
450
458
|
: request.body;
|
|
451
459
|
|
|
452
|
-
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
453
|
-
|
|
454
460
|
let updatedDocument;
|
|
455
461
|
|
|
456
462
|
if (isSparqlUpdate) {
|
|
@@ -497,7 +503,7 @@ export async function handlePatch(request, reply) {
|
|
|
497
503
|
|
|
498
504
|
// Write updated document
|
|
499
505
|
const updatedContent = JSON.stringify(updatedDocument, null, 2);
|
|
500
|
-
const success = await storage.write(
|
|
506
|
+
const success = await storage.write(storagePath, Buffer.from(updatedContent));
|
|
501
507
|
|
|
502
508
|
if (!success) {
|
|
503
509
|
return reply.code(500).send({ error: 'Write failed' });
|
package/src/server.js
CHANGED
|
@@ -16,6 +16,8 @@ import { idpPlugin } from './idp/index.js';
|
|
|
16
16
|
* @param {string} options.idpIssuer - IdP issuer URL (default: server URL)
|
|
17
17
|
* @param {object} options.ssl - SSL configuration { key, cert } (default null)
|
|
18
18
|
* @param {string} options.root - Data directory path (default from env or ./data)
|
|
19
|
+
* @param {boolean} options.subdomains - Enable subdomain-based pods for XSS protection (default false)
|
|
20
|
+
* @param {string} options.baseDomain - Base domain for subdomain pods (e.g., "example.com")
|
|
19
21
|
*/
|
|
20
22
|
export function createServer(options = {}) {
|
|
21
23
|
// Content negotiation is OFF by default - we're a JSON-LD native server
|
|
@@ -25,6 +27,9 @@ export function createServer(options = {}) {
|
|
|
25
27
|
// Identity Provider is OFF by default
|
|
26
28
|
const idpEnabled = options.idp ?? false;
|
|
27
29
|
const idpIssuer = options.idpIssuer;
|
|
30
|
+
// Subdomain mode is OFF by default - use path-based pods
|
|
31
|
+
const subdomainsEnabled = options.subdomains ?? false;
|
|
32
|
+
const baseDomain = options.baseDomain || null;
|
|
28
33
|
|
|
29
34
|
// Set data root via environment variable if provided
|
|
30
35
|
if (options.root) {
|
|
@@ -58,10 +63,29 @@ export function createServer(options = {}) {
|
|
|
58
63
|
fastify.decorateRequest('connegEnabled', null);
|
|
59
64
|
fastify.decorateRequest('notificationsEnabled', null);
|
|
60
65
|
fastify.decorateRequest('idpEnabled', null);
|
|
66
|
+
fastify.decorateRequest('subdomainsEnabled', null);
|
|
67
|
+
fastify.decorateRequest('baseDomain', null);
|
|
68
|
+
fastify.decorateRequest('podName', null);
|
|
61
69
|
fastify.addHook('onRequest', async (request) => {
|
|
62
70
|
request.connegEnabled = connegEnabled;
|
|
63
71
|
request.notificationsEnabled = notificationsEnabled;
|
|
64
72
|
request.idpEnabled = idpEnabled;
|
|
73
|
+
request.subdomainsEnabled = subdomainsEnabled;
|
|
74
|
+
request.baseDomain = baseDomain;
|
|
75
|
+
|
|
76
|
+
// Extract pod name from subdomain if enabled
|
|
77
|
+
if (subdomainsEnabled && baseDomain) {
|
|
78
|
+
const host = request.hostname;
|
|
79
|
+
// Check if host is a subdomain of baseDomain
|
|
80
|
+
if (host !== baseDomain && host.endsWith('.' + baseDomain)) {
|
|
81
|
+
// Extract subdomain (e.g., "alice.example.com" -> "alice")
|
|
82
|
+
const subdomain = host.slice(0, -(baseDomain.length + 1));
|
|
83
|
+
// Only single-level subdomains (no dots)
|
|
84
|
+
if (!subdomain.includes('.')) {
|
|
85
|
+
request.podName = subdomain;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
65
89
|
});
|
|
66
90
|
|
|
67
91
|
// Register WebSocket notifications plugin if enabled
|
package/src/utils/url.js
CHANGED
|
@@ -19,6 +19,58 @@ export function urlToPath(urlPath) {
|
|
|
19
19
|
return path.join(DATA_ROOT, normalized);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Convert URL path to filesystem path in subdomain mode
|
|
24
|
+
* In subdomain mode, the pod is determined by the hostname, not the path
|
|
25
|
+
* @param {string} urlPath - The URL path (e.g., /public/file.txt)
|
|
26
|
+
* @param {string} podName - The pod name from subdomain (e.g., "alice")
|
|
27
|
+
* @returns {string} - Filesystem path (e.g., DATA_ROOT/alice/public/file.txt)
|
|
28
|
+
*/
|
|
29
|
+
export function urlToPathWithPod(urlPath, podName) {
|
|
30
|
+
// Normalize: remove leading slash, decode URI
|
|
31
|
+
let normalized = urlPath.startsWith('/') ? urlPath.slice(1) : urlPath;
|
|
32
|
+
normalized = decodeURIComponent(normalized);
|
|
33
|
+
|
|
34
|
+
// Security: prevent path traversal
|
|
35
|
+
normalized = normalized.replace(/\.\./g, '');
|
|
36
|
+
|
|
37
|
+
// Prepend pod name to path
|
|
38
|
+
return path.join(DATA_ROOT, podName, normalized);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get the effective path for a request (subdomain-aware)
|
|
43
|
+
* @param {object} request - Fastify request object
|
|
44
|
+
* @returns {string} - Filesystem path
|
|
45
|
+
*/
|
|
46
|
+
export function getPathFromRequest(request) {
|
|
47
|
+
const urlPath = request.url.split('?')[0];
|
|
48
|
+
|
|
49
|
+
// In subdomain mode with a recognized pod subdomain
|
|
50
|
+
if (request.subdomainsEnabled && request.podName) {
|
|
51
|
+
return urlToPathWithPod(urlPath, request.podName);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Path-based mode (default)
|
|
55
|
+
return urlToPath(urlPath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get the effective URL path for a request (with pod prefix in subdomain mode)
|
|
60
|
+
* @param {object} request - Fastify request object
|
|
61
|
+
* @returns {string} - URL path with pod prefix if needed
|
|
62
|
+
*/
|
|
63
|
+
export function getEffectiveUrlPath(request) {
|
|
64
|
+
const urlPath = request.url.split('?')[0];
|
|
65
|
+
|
|
66
|
+
// In subdomain mode with a recognized pod subdomain, prepend pod name
|
|
67
|
+
if (request.subdomainsEnabled && request.podName) {
|
|
68
|
+
return '/' + request.podName + urlPath;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return urlPath;
|
|
72
|
+
}
|
|
73
|
+
|
|
22
74
|
/**
|
|
23
75
|
* Check if URL path represents a container (ends with /)
|
|
24
76
|
* @param {string} urlPath
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "3c1cd503-1d7f-4ba0-a3af-ebedf519594d",
|
|
3
|
+
"email": "credtest@example.com",
|
|
4
|
+
"passwordHash": "$2b$10$h3cRwsgAo/4wEOyip6ckyOf6J.reHOzJzyZrM.LDfQdIWa3e/MkAu",
|
|
5
|
+
"webId": "http://localhost:3101/credtest/#me",
|
|
6
|
+
"podName": "credtest",
|
|
7
|
+
"createdAt": "2025-12-27T12:48:00.226Z",
|
|
8
|
+
"lastLogin": "2025-12-27T12:48:00.567Z"
|
|
9
|
+
}
|
|
@@ -3,20 +3,20 @@
|
|
|
3
3
|
"keys": [
|
|
4
4
|
{
|
|
5
5
|
"kty": "EC",
|
|
6
|
-
"x": "
|
|
7
|
-
"y": "
|
|
6
|
+
"x": "NzQ-skj7cwbmI4Q_-vlQzoPYoQLWq_u34ln95VMcpnU",
|
|
7
|
+
"y": "H6gMaardV77boCWD4OTix1DsxdY-clIBw7I5xvvDDe8",
|
|
8
8
|
"crv": "P-256",
|
|
9
|
-
"d": "
|
|
10
|
-
"kid": "
|
|
9
|
+
"d": "WrlpdJo_JidHFldBjU4Q6Wv_ULhAk1j-DTU6YlHxDAQ",
|
|
10
|
+
"kid": "4e655460-7c94-479c-9ed8-923aa8bfd77f",
|
|
11
11
|
"use": "sig",
|
|
12
12
|
"alg": "ES256",
|
|
13
|
-
"iat":
|
|
13
|
+
"iat": 1766839680
|
|
14
14
|
}
|
|
15
15
|
]
|
|
16
16
|
},
|
|
17
17
|
"cookieKeys": [
|
|
18
|
-
"
|
|
19
|
-
"
|
|
18
|
+
"CQXN03oU_rUqugGhwZgoiI7eZMgKJaPE_kyrT9lmTu4",
|
|
19
|
+
"jsJGLtKzYy-RJPZaAMZDpUcfY4-EtdqNOFS-Uiowk9w"
|
|
20
20
|
],
|
|
21
|
-
"createdAt": "2025-12-27T12:
|
|
21
|
+
"createdAt": "2025-12-27T12:48:00.136Z"
|
|
22
22
|
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "292738d6-3363-4f40-9a6b-884bfd17830a",
|
|
3
|
-
"email": "credtest@example.com",
|
|
4
|
-
"passwordHash": "$2b$10$tvcMaMvecS7noqe/T/A5Q.VojfNu1FEPAzWhl/.3v7WXrVIH38iYC",
|
|
5
|
-
"webId": "http://localhost:3101/credtest/#me",
|
|
6
|
-
"podName": "credtest",
|
|
7
|
-
"createdAt": "2025-12-27T12:23:13.338Z",
|
|
8
|
-
"lastLogin": "2025-12-27T12:23:13.871Z"
|
|
9
|
-
}
|