javascript-solid-server 0.0.15 → 0.0.17
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 +5 -1
- package/LICENSE +661 -0
- package/README.md +88 -4
- package/bin/jss.js +12 -0
- package/package.json +2 -2
- package/src/auth/middleware.js +11 -5
- package/src/config.js +14 -0
- package/src/handlers/container.js +41 -13
- package/src/handlers/resource.js +58 -33
- package/src/mashlib/index.js +98 -0
- package/src/server.js +31 -0
- package/src/utils/url.js +52 -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/accounts/ea61c611-2dda-41b8-8787-c6a22c5f33cc.json +9 -0
- 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
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.17)
|
|
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,9 @@ 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
|
|
69
|
+
- **Mashlib Data Browser** - Optional SolidOS UI for browsing RDF resources
|
|
68
70
|
- **WebID Profiles** - JSON-LD structured data in HTML at pod root
|
|
69
71
|
- **Web Access Control (WAC)** - `.acl` file-based authorization
|
|
70
72
|
- **Solid-OIDC Identity Provider** - Built-in IdP with DPoP, dynamic registration
|
|
@@ -135,6 +137,10 @@ jss --help # Show help
|
|
|
135
137
|
| `--notifications` | Enable WebSocket | false |
|
|
136
138
|
| `--idp` | Enable built-in IdP | false |
|
|
137
139
|
| `--idp-issuer <url>` | IdP issuer URL | (auto) |
|
|
140
|
+
| `--subdomains` | Enable subdomain-based pods | false |
|
|
141
|
+
| `--base-domain <domain>` | Base domain for subdomains | - |
|
|
142
|
+
| `--mashlib` | Enable Mashlib data browser | false |
|
|
143
|
+
| `--mashlib-version <ver>` | Mashlib version | 2.0.0 |
|
|
138
144
|
| `-q, --quiet` | Suppress logs | false |
|
|
139
145
|
|
|
140
146
|
### Environment Variables
|
|
@@ -146,6 +152,9 @@ export JSS_PORT=8443
|
|
|
146
152
|
export JSS_SSL_KEY=/path/to/key.pem
|
|
147
153
|
export JSS_SSL_CERT=/path/to/cert.pem
|
|
148
154
|
export JSS_CONNEG=true
|
|
155
|
+
export JSS_SUBDOMAINS=true
|
|
156
|
+
export JSS_BASE_DOMAIN=example.com
|
|
157
|
+
export JSS_MASHLIB=true
|
|
149
158
|
jss start
|
|
150
159
|
```
|
|
151
160
|
|
|
@@ -338,16 +347,89 @@ curl -H "Authorization: DPoP ACCESS_TOKEN" \
|
|
|
338
347
|
http://localhost:3000/alice/private/
|
|
339
348
|
```
|
|
340
349
|
|
|
350
|
+
## Subdomain Mode (XSS Protection)
|
|
351
|
+
|
|
352
|
+
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.
|
|
353
|
+
|
|
354
|
+
**Subdomain mode** provides **origin isolation** - each pod gets its own subdomain (`alice.example.com`, `bob.example.com`), preventing XSS attacks between pods.
|
|
355
|
+
|
|
356
|
+
### Why Subdomain Mode?
|
|
357
|
+
|
|
358
|
+
| Mode | URL | Origin | XSS Risk |
|
|
359
|
+
|------|-----|--------|----------|
|
|
360
|
+
| Path-based | `example.com/alice/` | `example.com` | Shared origin - pods can XSS each other |
|
|
361
|
+
| Subdomain | `alice.example.com/` | `alice.example.com` | Isolated - browser's Same-Origin Policy protects |
|
|
362
|
+
|
|
363
|
+
### Enabling Subdomain Mode
|
|
364
|
+
|
|
365
|
+
```bash
|
|
366
|
+
jss start --subdomains --base-domain example.com
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Or via environment variables:
|
|
370
|
+
|
|
371
|
+
```bash
|
|
372
|
+
export JSS_SUBDOMAINS=true
|
|
373
|
+
export JSS_BASE_DOMAIN=example.com
|
|
374
|
+
jss start
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### DNS Configuration
|
|
378
|
+
|
|
379
|
+
You need a **wildcard DNS record** pointing to your server:
|
|
380
|
+
|
|
381
|
+
```
|
|
382
|
+
*.example.com A <your-server-ip>
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Pod URLs in Subdomain Mode
|
|
386
|
+
|
|
387
|
+
| Path Mode | Subdomain Mode |
|
|
388
|
+
|-----------|----------------|
|
|
389
|
+
| `example.com/alice/` | `alice.example.com/` |
|
|
390
|
+
| `example.com/alice/public/file.txt` | `alice.example.com/public/file.txt` |
|
|
391
|
+
| `example.com/alice/#me` | `alice.example.com/#me` |
|
|
392
|
+
|
|
393
|
+
Pod creation still uses the main domain:
|
|
394
|
+
|
|
395
|
+
```bash
|
|
396
|
+
curl -X POST https://example.com/.pods \
|
|
397
|
+
-H "Content-Type: application/json" \
|
|
398
|
+
-d '{"name": "alice"}'
|
|
399
|
+
```
|
|
400
|
+
|
|
341
401
|
## Configuration
|
|
342
402
|
|
|
343
403
|
```javascript
|
|
344
404
|
createServer({
|
|
345
405
|
logger: true, // Enable Fastify logging (default: true)
|
|
346
406
|
conneg: false, // Enable content negotiation (default: false)
|
|
347
|
-
notifications: false // Enable WebSocket notifications (default: false)
|
|
407
|
+
notifications: false, // Enable WebSocket notifications (default: false)
|
|
408
|
+
subdomains: false, // Enable subdomain-based pods (default: false)
|
|
409
|
+
baseDomain: null, // Base domain for subdomains (e.g., "example.com")
|
|
410
|
+
mashlib: false, // Enable Mashlib data browser (default: false)
|
|
411
|
+
mashlibVersion: '2.0.0', // Mashlib version to use
|
|
348
412
|
});
|
|
349
413
|
```
|
|
350
414
|
|
|
415
|
+
### Mashlib Data Browser
|
|
416
|
+
|
|
417
|
+
Enable the [SolidOS Mashlib](https://github.com/SolidOS/mashlib) data browser for RDF resources:
|
|
418
|
+
|
|
419
|
+
```bash
|
|
420
|
+
jss start --mashlib --conneg
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
When enabled, requesting an RDF resource with `Accept: text/html` returns an interactive data browser UI instead of raw data. Mashlib is loaded from the unpkg CDN.
|
|
424
|
+
|
|
425
|
+
**How it works:**
|
|
426
|
+
1. Browser requests `/alice/public/data.ttl` with `Accept: text/html`
|
|
427
|
+
2. Server returns Mashlib HTML wrapper (loads JS/CSS from CDN)
|
|
428
|
+
3. Mashlib fetches the actual data via content negotiation
|
|
429
|
+
4. Mashlib renders an interactive, editable view
|
|
430
|
+
|
|
431
|
+
**Note:** Mashlib works best with `--conneg` enabled for Turtle support. Pod profiles (`/alice/`) continue to serve our JSON-LD-in-HTML format.
|
|
432
|
+
|
|
351
433
|
### WebSocket Notifications
|
|
352
434
|
|
|
353
435
|
Enable real-time notifications for resource changes:
|
|
@@ -470,4 +552,6 @@ Minimal dependencies for a fast, secure server:
|
|
|
470
552
|
|
|
471
553
|
## License
|
|
472
554
|
|
|
473
|
-
|
|
555
|
+
AGPL-3.0-only
|
|
556
|
+
|
|
557
|
+
This project is licensed under the GNU Affero General Public License v3.0. If you run a modified version as a network service, you must make the source code available to users of that service.
|
package/bin/jss.js
CHANGED
|
@@ -47,6 +47,12 @@ 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")')
|
|
53
|
+
.option('--mashlib', 'Enable Mashlib data browser for RDF resources')
|
|
54
|
+
.option('--no-mashlib', 'Disable Mashlib data browser')
|
|
55
|
+
.option('--mashlib-version <version>', 'Mashlib version to use (default: 2.0.0)')
|
|
50
56
|
.option('-q, --quiet', 'Suppress log output')
|
|
51
57
|
.option('--print-config', 'Print configuration and exit')
|
|
52
58
|
.action(async (options) => {
|
|
@@ -80,6 +86,10 @@ program
|
|
|
80
86
|
cert: await fs.readFile(config.sslCert),
|
|
81
87
|
} : null,
|
|
82
88
|
root: config.root,
|
|
89
|
+
subdomains: config.subdomains,
|
|
90
|
+
baseDomain: config.baseDomain,
|
|
91
|
+
mashlib: config.mashlib,
|
|
92
|
+
mashlibVersion: config.mashlibVersion,
|
|
83
93
|
});
|
|
84
94
|
|
|
85
95
|
await server.listen({ port: config.port, host: config.host });
|
|
@@ -92,6 +102,8 @@ program
|
|
|
92
102
|
if (config.conneg) console.log(' Conneg: enabled');
|
|
93
103
|
if (config.notifications) console.log(' WebSocket: enabled');
|
|
94
104
|
if (config.idp) console.log(` IdP: ${idpIssuer}`);
|
|
105
|
+
if (config.subdomains) console.log(` Subdomains: ${config.baseDomain} (XSS protection enabled)`);
|
|
106
|
+
if (config.mashlib) console.log(` Mashlib: v${config.mashlibVersion} (data browser enabled)`);
|
|
95
107
|
console.log('\n Press Ctrl+C to stop\n');
|
|
96
108
|
}
|
|
97
109
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
4
4
|
"description": "A minimal, fast Solid server",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"linked-data",
|
|
43
43
|
"decentralized"
|
|
44
44
|
],
|
|
45
|
-
"license": "
|
|
45
|
+
"license": "AGPL-3.0-only",
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"autocannon": "^8.0.0"
|
|
48
48
|
}
|
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,14 @@ 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
|
+
|
|
40
|
+
// Mashlib data browser
|
|
41
|
+
mashlib: false,
|
|
42
|
+
mashlibVersion: '2.0.0',
|
|
43
|
+
|
|
36
44
|
// Logging
|
|
37
45
|
logger: true,
|
|
38
46
|
quiet: false,
|
|
@@ -57,6 +65,10 @@ const envMap = {
|
|
|
57
65
|
JSS_CONFIG_PATH: 'configPath',
|
|
58
66
|
JSS_IDP: 'idp',
|
|
59
67
|
JSS_IDP_ISSUER: 'idpIssuer',
|
|
68
|
+
JSS_SUBDOMAINS: 'subdomains',
|
|
69
|
+
JSS_BASE_DOMAIN: 'baseDomain',
|
|
70
|
+
JSS_MASHLIB: 'mashlib',
|
|
71
|
+
JSS_MASHLIB_VERSION: 'mashlibVersion',
|
|
60
72
|
};
|
|
61
73
|
|
|
62
74
|
/**
|
|
@@ -188,5 +200,7 @@ export function printConfig(config) {
|
|
|
188
200
|
console.log(` Conneg: ${config.conneg}`);
|
|
189
201
|
console.log(` Notifications: ${config.notifications}`);
|
|
190
202
|
console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`);
|
|
203
|
+
console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
|
|
204
|
+
console.log(` Mashlib: ${config.mashlib ? `v${config.mashlibVersion}` : 'disabled'}`);
|
|
191
205
|
console.log('─'.repeat(40));
|
|
192
206
|
}
|
|
@@ -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 {
|
|
@@ -14,13 +14,27 @@ import {
|
|
|
14
14
|
} from '../rdf/conneg.js';
|
|
15
15
|
import { emitChange } from '../notifications/events.js';
|
|
16
16
|
import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
|
|
17
|
+
import { generateDatabrowserHtml, shouldServeMashlib } from '../mashlib/index.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get the storage path and resource URL for a request
|
|
21
|
+
* In subdomain mode, storage path includes pod name, URL uses subdomain
|
|
22
|
+
*/
|
|
23
|
+
function getRequestPaths(request) {
|
|
24
|
+
const urlPath = request.url.split('?')[0];
|
|
25
|
+
// Storage path - includes pod name in subdomain mode
|
|
26
|
+
const storagePath = getEffectiveUrlPath(request);
|
|
27
|
+
// Resource URL - uses the actual request hostname (subdomain in subdomain mode)
|
|
28
|
+
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
29
|
+
return { urlPath, storagePath, resourceUrl };
|
|
30
|
+
}
|
|
17
31
|
|
|
18
32
|
/**
|
|
19
33
|
* Handle GET request
|
|
20
34
|
*/
|
|
21
35
|
export async function handleGet(request, reply) {
|
|
22
|
-
const urlPath = request
|
|
23
|
-
const stats = await storage.stat(
|
|
36
|
+
const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
|
|
37
|
+
const stats = await storage.stat(storagePath);
|
|
24
38
|
|
|
25
39
|
if (!stats) {
|
|
26
40
|
return reply.code(404).send({ error: 'Not Found' });
|
|
@@ -36,14 +50,13 @@ export async function handleGet(request, reply) {
|
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
const origin = request.headers.origin;
|
|
39
|
-
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
40
53
|
|
|
41
54
|
// Handle container
|
|
42
55
|
if (stats.isDirectory) {
|
|
43
56
|
const connegEnabled = request.connegEnabled || false;
|
|
44
57
|
|
|
45
58
|
// Check for index.html (serves as both profile and container representation)
|
|
46
|
-
const indexPath =
|
|
59
|
+
const indexPath = storagePath.endsWith('/') ? `${storagePath}index.html` : `${storagePath}/index.html`;
|
|
47
60
|
const indexExists = await storage.exists(indexPath);
|
|
48
61
|
|
|
49
62
|
if (indexExists) {
|
|
@@ -105,7 +118,7 @@ export async function handleGet(request, reply) {
|
|
|
105
118
|
}
|
|
106
119
|
|
|
107
120
|
// No index.html, return JSON-LD container listing
|
|
108
|
-
const entries = await storage.listContainer(
|
|
121
|
+
const entries = await storage.listContainer(storagePath);
|
|
109
122
|
const jsonLd = generateContainerJsonLd(resourceUrl, entries || []);
|
|
110
123
|
|
|
111
124
|
const headers = getAllHeaders({
|
|
@@ -122,14 +135,32 @@ export async function handleGet(request, reply) {
|
|
|
122
135
|
}
|
|
123
136
|
|
|
124
137
|
// Handle resource
|
|
125
|
-
const
|
|
138
|
+
const storedContentType = getContentType(storagePath);
|
|
139
|
+
const connegEnabled = request.connegEnabled || false;
|
|
140
|
+
|
|
141
|
+
// Check if we should serve Mashlib data browser
|
|
142
|
+
// Only for RDF resources when Accept: text/html is requested
|
|
143
|
+
if (shouldServeMashlib(request, request.mashlibEnabled, storedContentType)) {
|
|
144
|
+
const html = generateDatabrowserHtml(resourceUrl, request.mashlibVersion);
|
|
145
|
+
const headers = getAllHeaders({
|
|
146
|
+
isContainer: false,
|
|
147
|
+
etag: stats.etag,
|
|
148
|
+
contentType: 'text/html',
|
|
149
|
+
origin,
|
|
150
|
+
resourceUrl,
|
|
151
|
+
connegEnabled
|
|
152
|
+
});
|
|
153
|
+
headers['Vary'] = 'Accept';
|
|
154
|
+
|
|
155
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
156
|
+
return reply.type('text/html').send(html);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const content = await storage.read(storagePath);
|
|
126
160
|
if (content === null) {
|
|
127
161
|
return reply.code(500).send({ error: 'Read error' });
|
|
128
162
|
}
|
|
129
163
|
|
|
130
|
-
const storedContentType = getContentType(urlPath);
|
|
131
|
-
const connegEnabled = request.connegEnabled || false;
|
|
132
|
-
|
|
133
164
|
// Content negotiation for RDF resources
|
|
134
165
|
if (connegEnabled && isRdfContentType(storedContentType)) {
|
|
135
166
|
try {
|
|
@@ -184,16 +215,15 @@ export async function handleGet(request, reply) {
|
|
|
184
215
|
* Handle HEAD request
|
|
185
216
|
*/
|
|
186
217
|
export async function handleHead(request, reply) {
|
|
187
|
-
const
|
|
188
|
-
const stats = await storage.stat(
|
|
218
|
+
const { storagePath, resourceUrl } = getRequestPaths(request);
|
|
219
|
+
const stats = await storage.stat(storagePath);
|
|
189
220
|
|
|
190
221
|
if (!stats) {
|
|
191
222
|
return reply.code(404).send();
|
|
192
223
|
}
|
|
193
224
|
|
|
194
225
|
const origin = request.headers.origin;
|
|
195
|
-
const
|
|
196
|
-
const contentType = stats.isDirectory ? 'application/ld+json' : getContentType(urlPath);
|
|
226
|
+
const contentType = stats.isDirectory ? 'application/ld+json' : getContentType(storagePath);
|
|
197
227
|
|
|
198
228
|
const headers = getAllHeaders({
|
|
199
229
|
isContainer: stats.isDirectory,
|
|
@@ -215,20 +245,19 @@ export async function handleHead(request, reply) {
|
|
|
215
245
|
* Handle PUT request
|
|
216
246
|
*/
|
|
217
247
|
export async function handlePut(request, reply) {
|
|
218
|
-
const urlPath = request
|
|
248
|
+
const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
|
|
219
249
|
const connegEnabled = request.connegEnabled || false;
|
|
220
|
-
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
221
250
|
|
|
222
251
|
// Handle container creation via PUT
|
|
223
252
|
if (isContainer(urlPath)) {
|
|
224
|
-
const stats = await storage.stat(
|
|
253
|
+
const stats = await storage.stat(storagePath);
|
|
225
254
|
if (stats?.isDirectory) {
|
|
226
255
|
// Container already exists - don't allow PUT to modify
|
|
227
256
|
return reply.code(409).send({ error: 'Cannot PUT to existing container' });
|
|
228
257
|
}
|
|
229
258
|
|
|
230
259
|
// Create the container (and any intermediate containers)
|
|
231
|
-
const success = await storage.createContainer(
|
|
260
|
+
const success = await storage.createContainer(storagePath);
|
|
232
261
|
if (!success) {
|
|
233
262
|
return reply.code(500).send({ error: 'Failed to create container' });
|
|
234
263
|
}
|
|
@@ -258,7 +287,7 @@ export async function handlePut(request, reply) {
|
|
|
258
287
|
}
|
|
259
288
|
|
|
260
289
|
// Check if resource already exists and get current ETag
|
|
261
|
-
const stats = await storage.stat(
|
|
290
|
+
const stats = await storage.stat(storagePath);
|
|
262
291
|
const existed = stats !== null;
|
|
263
292
|
const currentEtag = stats?.etag || null;
|
|
264
293
|
|
|
@@ -308,7 +337,7 @@ export async function handlePut(request, reply) {
|
|
|
308
337
|
}
|
|
309
338
|
}
|
|
310
339
|
|
|
311
|
-
const success = await storage.write(
|
|
340
|
+
const success = await storage.write(storagePath, content);
|
|
312
341
|
if (!success) {
|
|
313
342
|
return reply.code(500).send({ error: 'Write failed' });
|
|
314
343
|
}
|
|
@@ -332,10 +361,10 @@ export async function handlePut(request, reply) {
|
|
|
332
361
|
* Handle DELETE request
|
|
333
362
|
*/
|
|
334
363
|
export async function handleDelete(request, reply) {
|
|
335
|
-
const
|
|
364
|
+
const { storagePath, resourceUrl } = getRequestPaths(request);
|
|
336
365
|
|
|
337
366
|
// Check if resource exists and get current ETag
|
|
338
|
-
const stats = await storage.stat(
|
|
367
|
+
const stats = await storage.stat(storagePath);
|
|
339
368
|
if (!stats) {
|
|
340
369
|
return reply.code(404).send({ error: 'Not Found' });
|
|
341
370
|
}
|
|
@@ -349,13 +378,12 @@ export async function handleDelete(request, reply) {
|
|
|
349
378
|
}
|
|
350
379
|
}
|
|
351
380
|
|
|
352
|
-
const success = await storage.remove(
|
|
381
|
+
const success = await storage.remove(storagePath);
|
|
353
382
|
if (!success) {
|
|
354
383
|
return reply.code(500).send({ error: 'Delete failed' });
|
|
355
384
|
}
|
|
356
385
|
|
|
357
386
|
const origin = request.headers.origin;
|
|
358
|
-
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
359
387
|
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
|
|
360
388
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
361
389
|
|
|
@@ -371,11 +399,10 @@ export async function handleDelete(request, reply) {
|
|
|
371
399
|
* Handle OPTIONS request
|
|
372
400
|
*/
|
|
373
401
|
export async function handleOptions(request, reply) {
|
|
374
|
-
const urlPath = request
|
|
375
|
-
const stats = await storage.stat(
|
|
402
|
+
const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
|
|
403
|
+
const stats = await storage.stat(storagePath);
|
|
376
404
|
|
|
377
405
|
const origin = request.headers.origin;
|
|
378
|
-
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
379
406
|
const connegEnabled = request.connegEnabled || false;
|
|
380
407
|
const headers = getAllHeaders({
|
|
381
408
|
isContainer: stats?.isDirectory || isContainer(urlPath),
|
|
@@ -393,7 +420,7 @@ export async function handleOptions(request, reply) {
|
|
|
393
420
|
* Supports N3 Patch format (text/n3) and SPARQL Update for updating RDF resources
|
|
394
421
|
*/
|
|
395
422
|
export async function handlePatch(request, reply) {
|
|
396
|
-
const urlPath = request
|
|
423
|
+
const { urlPath, storagePath, resourceUrl } = getRequestPaths(request);
|
|
397
424
|
|
|
398
425
|
// Don't allow PATCH to containers
|
|
399
426
|
if (isContainer(urlPath)) {
|
|
@@ -413,7 +440,7 @@ export async function handlePatch(request, reply) {
|
|
|
413
440
|
}
|
|
414
441
|
|
|
415
442
|
// Check if resource exists
|
|
416
|
-
const stats = await storage.stat(
|
|
443
|
+
const stats = await storage.stat(storagePath);
|
|
417
444
|
if (!stats) {
|
|
418
445
|
return reply.code(404).send({ error: 'Not Found' });
|
|
419
446
|
}
|
|
@@ -428,7 +455,7 @@ export async function handlePatch(request, reply) {
|
|
|
428
455
|
}
|
|
429
456
|
|
|
430
457
|
// Read existing content
|
|
431
|
-
const existingContent = await storage.read(
|
|
458
|
+
const existingContent = await storage.read(storagePath);
|
|
432
459
|
if (existingContent === null) {
|
|
433
460
|
return reply.code(500).send({ error: 'Read error' });
|
|
434
461
|
}
|
|
@@ -449,8 +476,6 @@ export async function handlePatch(request, reply) {
|
|
|
449
476
|
? request.body.toString()
|
|
450
477
|
: request.body;
|
|
451
478
|
|
|
452
|
-
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
453
|
-
|
|
454
479
|
let updatedDocument;
|
|
455
480
|
|
|
456
481
|
if (isSparqlUpdate) {
|
|
@@ -497,7 +522,7 @@ export async function handlePatch(request, reply) {
|
|
|
497
522
|
|
|
498
523
|
// Write updated document
|
|
499
524
|
const updatedContent = JSON.stringify(updatedDocument, null, 2);
|
|
500
|
-
const success = await storage.write(
|
|
525
|
+
const success = await storage.write(storagePath, Buffer.from(updatedContent));
|
|
501
526
|
|
|
502
527
|
if (!success) {
|
|
503
528
|
return reply.code(500).send({ error: 'Write failed' });
|