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/README.md CHANGED
@@ -54,7 +54,7 @@ npm run benchmark
54
54
 
55
55
  ## Features
56
56
 
57
- ### Implemented (v0.0.15)
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** - Create pods at `/<username>/`
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
- MIT
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.15",
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": "MIT",
45
+ "license": "AGPL-3.0-only",
46
46
  "devDependencies": {
47
47
  "autocannon": "^8.0.0"
48
48
  }
@@ -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(urlPath);
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 = urlPath;
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(urlPath);
52
+ const parentPath = getParentPath(storagePath);
49
53
  checkPath = parentPath;
50
- checkUrl = `${request.protocol}://${request.hostname}${parentPath}`;
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.url.split('?')[0];
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(urlPath);
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(urlPath);
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(urlPath, slug, isCreatingContainer);
50
- const newPath = urlPath + filename + (isCreatingContainer ? '/' : '');
51
- const resourceUrl = `${request.protocol}://${request.hostname}${newPath}`;
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(newPath);
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(newPath, content);
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 baseUri = `${request.protocol}://${request.hostname}`;
158
- const podUri = `${baseUri}${podPath}`;
159
- const webId = `${podUri}#me`;
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
 
@@ -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.url.split('?')[0]; // Remove query string
23
- const stats = await storage.stat(urlPath);
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 = urlPath.endsWith('/') ? `${urlPath}index.html` : `${urlPath}/index.html`;
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(urlPath);
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 content = await storage.read(urlPath);
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 urlPath = request.url.split('?')[0];
188
- const stats = await storage.stat(urlPath);
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 resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
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.url.split('?')[0];
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(urlPath);
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(urlPath);
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(urlPath);
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(urlPath, content);
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 urlPath = request.url.split('?')[0];
364
+ const { storagePath, resourceUrl } = getRequestPaths(request);
336
365
 
337
366
  // Check if resource exists and get current ETag
338
- const stats = await storage.stat(urlPath);
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(urlPath);
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.url.split('?')[0];
375
- const stats = await storage.stat(urlPath);
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.url.split('?')[0];
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(urlPath);
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(urlPath);
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(urlPath, Buffer.from(updatedContent));
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' });