javascript-solid-server 0.0.75 → 0.0.76

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.
@@ -234,7 +234,29 @@
234
234
  "Bash(timeout 90 bash -c:*)",
235
235
  "Bash(git commit -m \"$\\(cat <<''EOF''\nsecurity: add ACL check on WebSocket subscription requests\n\nCheck WAC read permission before allowing subscription to prevent\ninformation leakage via notifications. Unauthorized subscriptions\nnow receive ''err <url> forbidden'' response.\n\nSecurity improvements:\n- Check ACL read access before allowing subscription\n- Validate URLs are on this server \\(prevents SSRF-like probing\\)\n- Add subscription limit and URL length validation\n\nFixes #62\nEOF\n\\)\")",
236
236
  "Bash(gh repo fork:*)",
237
- "Bash(timeout 180 npm test:*)"
237
+ "Bash(timeout 180 npm test:*)",
238
+ "Bash(git show:*)",
239
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline -30 -- \"test/integration/acl-tls-test.mjs\" \"test-esm/integration/acl-tls-test.js\")",
240
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --follow -30 -- \"test/integration/acl-tls-test.mjs\")",
241
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server show 778095ad --stat)",
242
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server show 778095ad)",
243
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server show b183c7a0)",
244
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --before=\"2019-10-29\" --after=\"2019-10-01\" -20)",
245
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server show 1a92a912 --stat)",
246
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --all --grep=jaxoncreed)",
247
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --author=\"jaxoncreed\" -30)",
248
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --author=\"[Dd]mitri\" -20)",
249
+ "Bash(git -C /home/melvin/remote/github.com/nodeSolidServer/node-solid-server log --oneline --all --grep=\"oidc\" -20)",
250
+ "Bash(npm install)",
251
+ "Bash(timeout 60 npx mocha:*)",
252
+ "Bash(timeout 120 npx mocha:*)",
253
+ "Bash(timeout 30 npx mocha:*)",
254
+ "Bash(openssl x509:*)",
255
+ "Bash(gh pr checks:*)",
256
+ "Bash(gh run view:*)",
257
+ "Bash(gh pr edit:*)",
258
+ "WebFetch(domain:patch-diff.githubusercontent.com)",
259
+ "Bash(git rebase:*)"
238
260
  ]
239
261
  }
240
262
  }
package/README.md CHANGED
@@ -122,6 +122,7 @@ jss --help # Show help
122
122
  | `--mashlib` | Enable Mashlib (local mode) | false |
123
123
  | `--mashlib-cdn` | Enable Mashlib (CDN mode) | false |
124
124
  | `--mashlib-version <ver>` | Mashlib CDN version | 2.0.0 |
125
+ | `--solidos-ui` | Enable modern SolidOS UI (requires --mashlib) | false |
125
126
  | `--git` | Enable Git HTTP backend | false |
126
127
  | `--nostr` | Enable Nostr relay | false |
127
128
  | `--nostr-path <path>` | Nostr relay WebSocket path | /relay |
@@ -333,6 +334,18 @@ npm install && npm run build
333
334
 
334
335
  **Note:** Mashlib works best with `--conneg` enabled for Turtle support.
335
336
 
337
+ **Modern UI (SolidOS UI):**
338
+ ```bash
339
+ jss start --mashlib --solidos-ui --conneg
340
+ ```
341
+ Serves a modern Nextcloud-style UI shell while reusing mashlib's data layer. The `--solidos-ui` flag swaps the classic databrowser interface for a cleaner, mobile-friendly design with:
342
+ - Modern file browser with breadcrumb navigation
343
+ - Profile, Contacts, Sharing, and Settings views
344
+ - Path-based URLs (browser URL reflects current resource)
345
+ - Responsive design for mobile devices
346
+
347
+ Requires solidos-ui dist files in `src/mashlib-local/dist/solidos-ui/`. See [solidos-ui](https://github.com/solidos/solidos/tree/main/workspaces/solidos-ui) for details.
348
+
336
349
  ### Profile Pages
337
350
 
338
351
  Pod profiles (`/alice/`) use HTML with embedded JSON-LD data islands and are rendered using:
package/bin/jss.js CHANGED
@@ -57,6 +57,7 @@ program
57
57
  .option('--mashlib-cdn', 'Enable Mashlib data browser (CDN mode, no local files needed)')
58
58
  .option('--no-mashlib', 'Disable Mashlib data browser')
59
59
  .option('--mashlib-version <version>', 'Mashlib version for CDN mode (default: 2.0.0)')
60
+ .option('--solidos-ui', 'Enable modern Nextcloud-style UI (requires --mashlib)')
60
61
  .option('--git', 'Enable Git HTTP backend (clone/push support)')
61
62
  .option('--no-git', 'Disable Git HTTP backend')
62
63
  .option('--nostr', 'Enable Nostr relay')
@@ -114,6 +115,7 @@ program
114
115
  mashlib: config.mashlib || config.mashlibCdn,
115
116
  mashlibCdn: config.mashlibCdn,
116
117
  mashlibVersion: config.mashlibVersion,
118
+ solidosUi: config.solidosUi,
117
119
  git: config.git,
118
120
  nostr: config.nostr,
119
121
  nostrPath: config.nostrPath,
@@ -143,6 +145,7 @@ program
143
145
  } else if (config.mashlib) {
144
146
  console.log(` Mashlib: local (data browser enabled)`);
145
147
  }
148
+ if (config.solidosUi) console.log(' SolidOS UI: enabled (modern interface)');
146
149
  if (config.git) console.log(' Git: enabled (clone/push support)');
147
150
  if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
148
151
  if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.75",
3
+ "version": "0.0.76",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -9,7 +9,7 @@ import { checkAccess, getRequiredMode } from '../wac/checker.js';
9
9
  import { AccessMode } from '../wac/parser.js';
10
10
  import * as storage from '../storage/filesystem.js';
11
11
  import { getEffectiveUrlPath } from '../utils/url.js';
12
- import { generateDatabrowserHtml } from '../mashlib/index.js';
12
+ import { generateDatabrowserHtml, generateSolidosUiHtml } from '../mashlib/index.js';
13
13
 
14
14
  /**
15
15
  * Check if request is authorized
@@ -117,8 +117,11 @@ export function handleUnauthorized(request, reply, isAuthenticated, wacAllow, au
117
117
  // If mashlib is enabled, serve mashlib instead of static error page
118
118
  // Mashlib has built-in login functionality via panes.runDataBrowser()
119
119
  if (request.mashlibEnabled) {
120
- const cdnVersion = request.mashlibCdn ? request.mashlibVersion : null;
121
- return reply.code(statusCode).type('text/html').send(generateDatabrowserHtml(request.url, cdnVersion));
120
+ // Use SolidOS UI if enabled, otherwise fallback to classic mashlib
121
+ const html = request.solidosUiEnabled
122
+ ? generateSolidosUiHtml()
123
+ : generateDatabrowserHtml(request.url, request.mashlibCdn ? request.mashlibVersion : null);
124
+ return reply.code(statusCode).type('text/html').send(html);
122
125
  }
123
126
  return reply.code(statusCode).type('text/html').send(getErrorPage(statusCode, isAuthenticated, request));
124
127
  }
package/src/config.js CHANGED
@@ -42,6 +42,9 @@ export const defaults = {
42
42
  mashlibCdn: false,
43
43
  mashlibVersion: '2.0.0',
44
44
 
45
+ // SolidOS UI (modern Nextcloud-style interface)
46
+ solidosUi: false,
47
+
45
48
  // Git HTTP backend
46
49
  git: false,
47
50
 
@@ -95,6 +98,7 @@ const envMap = {
95
98
  JSS_MASHLIB: 'mashlib',
96
99
  JSS_MASHLIB_CDN: 'mashlibCdn',
97
100
  JSS_MASHLIB_VERSION: 'mashlibVersion',
101
+ JSS_SOLIDOS_UI: 'solidosUi',
98
102
  JSS_GIT: 'git',
99
103
  JSS_NOSTR: 'nostr',
100
104
  JSS_NOSTR_PATH: 'nostrPath',
@@ -258,5 +262,6 @@ export function printConfig(config) {
258
262
  console.log(` IdP: ${config.idp ? (config.idpIssuer || 'enabled') : 'disabled'}`);
259
263
  console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
260
264
  console.log(` Mashlib: ${config.mashlibCdn ? `CDN v${config.mashlibVersion}` : config.mashlib ? 'local' : 'disabled'}`);
265
+ console.log(` SolidOS UI: ${config.solidosUi ? 'enabled' : 'disabled'}`);
261
266
  console.log('─'.repeat(40));
262
267
  }
@@ -15,7 +15,7 @@ import {
15
15
  } from '../rdf/conneg.js';
16
16
  import { emitChange } from '../notifications/events.js';
17
17
  import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
18
- import { generateDatabrowserHtml, shouldServeMashlib } from '../mashlib/index.js';
18
+ import { generateDatabrowserHtml, generateSolidosUiHtml, shouldServeMashlib } from '../mashlib/index.js';
19
19
 
20
20
  /**
21
21
  * Get the storage path and resource URL for a request
@@ -30,6 +30,64 @@ function getRequestPaths(request) {
30
30
  return { urlPath, storagePath, resourceUrl };
31
31
  }
32
32
 
33
+ /**
34
+ * Parse HTTP Range header
35
+ * @param {string} rangeHeader - The Range header value (e.g., "bytes=0-1023")
36
+ * @param {number} fileSize - Total file size in bytes
37
+ * @returns {{ start: number, end: number } | null}
38
+ */
39
+ function parseRangeHeader(rangeHeader, fileSize) {
40
+ if (!rangeHeader || !rangeHeader.startsWith('bytes=')) {
41
+ return null;
42
+ }
43
+
44
+ const range = rangeHeader.slice(6); // Remove 'bytes='
45
+
46
+ // Multi-range requests (e.g., "0-100,200-300") are not supported
47
+ // Per RFC 7233, ignore Range header and serve full content instead of 416
48
+ if (range.includes(',')) {
49
+ return null;
50
+ }
51
+
52
+ const parts = range.split('-');
53
+
54
+ if (parts.length !== 2) {
55
+ return null;
56
+ }
57
+
58
+ let start, end;
59
+
60
+ if (parts[0] === '') {
61
+ // Suffix range: bytes=-500 (last 500 bytes)
62
+ const suffix = parseInt(parts[1], 10);
63
+ if (isNaN(suffix) || suffix <= 0) return null;
64
+ start = Math.max(0, fileSize - suffix);
65
+ end = fileSize - 1;
66
+ } else if (parts[1] === '') {
67
+ // Open-ended range: bytes=1024- (from 1024 to end)
68
+ start = parseInt(parts[0], 10);
69
+ if (isNaN(start) || start < 0) return null;
70
+ end = fileSize - 1;
71
+ } else {
72
+ // Normal range: bytes=0-1023
73
+ start = parseInt(parts[0], 10);
74
+ end = parseInt(parts[1], 10);
75
+ if (isNaN(start) || isNaN(end) || start < 0 || end < start) return null;
76
+ }
77
+
78
+ // Clamp end to file size
79
+ if (end >= fileSize) {
80
+ end = fileSize - 1;
81
+ }
82
+
83
+ // Check if range is satisfiable
84
+ if (start > end || start >= fileSize) {
85
+ return null;
86
+ }
87
+
88
+ return { start, end };
89
+ }
90
+
33
91
  /**
34
92
  * Handle GET request
35
93
  */
@@ -149,8 +207,10 @@ export async function handleGet(request, reply) {
149
207
 
150
208
  // Check if we should serve Mashlib data browser for containers
151
209
  if (shouldServeMashlib(request, request.mashlibEnabled, 'application/ld+json')) {
152
- const cdnVersion = request.mashlibCdn ? request.mashlibVersion : null;
153
- const html = generateDatabrowserHtml(resourceUrl, cdnVersion);
210
+ // Use SolidOS UI if enabled, otherwise fallback to classic mashlib
211
+ const html = request.solidosUiEnabled
212
+ ? generateSolidosUiHtml()
213
+ : generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
154
214
  const headers = getAllHeaders({
155
215
  isContainer: true,
156
216
  etag: stats.etag,
@@ -224,9 +284,10 @@ export async function handleGet(request, reply) {
224
284
  // Check if we should serve Mashlib data browser
225
285
  // Only for RDF resources when Accept: text/html is requested
226
286
  if (shouldServeMashlib(request, request.mashlibEnabled, storedContentType)) {
227
- // Pass CDN version if using CDN mode, null for local mode
228
- const cdnVersion = request.mashlibCdn ? request.mashlibVersion : null;
229
- const html = generateDatabrowserHtml(resourceUrl, cdnVersion);
287
+ // Use SolidOS UI if enabled, otherwise fallback to classic mashlib
288
+ const html = request.solidosUiEnabled
289
+ ? generateSolidosUiHtml()
290
+ : generateDatabrowserHtml(resourceUrl, request.mashlibCdn ? request.mashlibVersion : null);
230
291
  const headers = getAllHeaders({
231
292
  isContainer: false,
232
293
  etag: stats.etag,
@@ -245,6 +306,43 @@ export async function handleGet(request, reply) {
245
306
  return reply.type('text/html').send(html);
246
307
  }
247
308
 
309
+ // Handle Range requests for media files (video, audio, etc.)
310
+ const rangeHeader = request.headers.range;
311
+ if (rangeHeader && !isRdfContentType(storedContentType)) {
312
+ const range = parseRangeHeader(rangeHeader, stats.size);
313
+
314
+ if (range) {
315
+ const { start, end } = range;
316
+ const chunkSize = end - start + 1;
317
+
318
+ const headers = getAllHeaders({
319
+ isContainer: false,
320
+ etag: stats.etag,
321
+ contentType: storedContentType,
322
+ origin,
323
+ resourceUrl,
324
+ connegEnabled
325
+ });
326
+ headers['Content-Range'] = `bytes ${start}-${end}/${stats.size}`;
327
+ headers['Content-Length'] = chunkSize;
328
+
329
+ Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
330
+
331
+ const streamResult = storage.createReadStream(storagePath, { start, end });
332
+ if (!streamResult) {
333
+ return reply.code(500).send({ error: 'Stream error' });
334
+ }
335
+
336
+ // Handle stream errors that occur during response
337
+ streamResult.stream.on('error', (err) => {
338
+ console.error('Stream error during range response:', err.message);
339
+ });
340
+
341
+ return reply.code(206).send(streamResult.stream);
342
+ }
343
+ // If range is null (unsupported format or multi-range), fall through to serve full content
344
+ }
345
+
248
346
  const content = await storage.read(storagePath);
249
347
  if (content === null) {
250
348
  return reply.code(500).send({ error: 'Read error' });
@@ -56,6 +56,7 @@ export function getResponseHeaders({ isContainer = false, etag = null, contentTy
56
56
  const headers = {
57
57
  'Link': getLinkHeader(isContainer, aclUrl),
58
58
  'Accept-Patch': 'text/n3, application/sparql-update',
59
+ 'Accept-Ranges': isContainer ? 'none' : 'bytes',
59
60
  'Allow': 'GET, HEAD, PUT, DELETE, PATCH, OPTIONS' + (isContainer ? ', POST' : ''),
60
61
  'Vary': connegEnabled ? 'Accept, Authorization, Origin' : 'Authorization, Origin'
61
62
  };
@@ -94,8 +95,8 @@ export function getCorsHeaders(origin) {
94
95
  return {
95
96
  'Access-Control-Allow-Origin': origin || '*',
96
97
  'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS',
97
- 'Access-Control-Allow-Headers': 'Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Slug, Origin',
98
- 'Access-Control-Expose-Headers': 'Accept-Patch, Accept-Post, Allow, Content-Type, ETag, Link, Location, Updates-Via, WAC-Allow',
98
+ 'Access-Control-Allow-Headers': 'Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Range, Slug, Origin',
99
+ 'Access-Control-Expose-Headers': 'Accept-Patch, Accept-Post, Accept-Ranges, Allow, Content-Length, Content-Range, Content-Type, ETag, Link, Location, Updates-Via, WAC-Allow',
99
100
  'Access-Control-Allow-Credentials': 'true',
100
101
  'Access-Control-Max-Age': '86400'
101
102
  };
@@ -94,6 +94,113 @@ export function shouldServeMashlib(request, mashlibEnabled, contentType) {
94
94
  return rdfTypes.includes(baseType);
95
95
  }
96
96
 
97
+ /**
98
+ * Generate SolidOS UI HTML (modern Nextcloud-style interface)
99
+ * Uses mashlib for data layer but solidos-ui for the UI shell
100
+ *
101
+ * @returns {string} HTML content
102
+ */
103
+ export function generateSolidosUiHtml() {
104
+ return `<!DOCTYPE html>
105
+ <html lang="en">
106
+ <head>
107
+ <meta charset="utf-8"/>
108
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
109
+ <title>SolidOS - Modern UI</title>
110
+ <!-- SolidOS UI Styles -->
111
+ <link rel="stylesheet" href="/solidos-ui/styles/variables.css">
112
+ <link rel="stylesheet" href="/solidos-ui/styles/shell.css">
113
+ <link rel="stylesheet" href="/solidos-ui/styles/components.css">
114
+ <link rel="stylesheet" href="/solidos-ui/styles/responsive.css">
115
+ <!-- View-specific styles -->
116
+ <link rel="stylesheet" href="/solidos-ui/views/profile/profile.css">
117
+ <link rel="stylesheet" href="/solidos-ui/views/contacts/contacts.css">
118
+ <link rel="stylesheet" href="/solidos-ui/views/sharing/sharing.css">
119
+ <link rel="stylesheet" href="/solidos-ui/views/settings/settings.css">
120
+ <!-- Bundled styles (contains all component styles) -->
121
+ <link rel="stylesheet" href="/solidos-ui/style.css">
122
+ <style>
123
+ * { margin: 0; padding: 0; box-sizing: border-box; }
124
+ html, body { height: 100%; }
125
+ #app { height: 100%; }
126
+ </style>
127
+ </head>
128
+ <body>
129
+ <div id="app"></div>
130
+
131
+ <script>
132
+ // Load mashlib first, then solidos-ui
133
+ (function() {
134
+ var mashScript = document.createElement('script');
135
+ mashScript.src = '/mashlib.min.js';
136
+ mashScript.onload = function() {
137
+ // Now load solidos-ui
138
+ import('/solidos-ui/solidos-ui.js').then(function(module) {
139
+ var initSolidOSSkin = module.initSolidOSSkin;
140
+ var SolidLogic = window.SolidLogic;
141
+ var panes = window.panes;
142
+ var store = SolidLogic.store;
143
+
144
+ initSolidOSSkin('#app', {
145
+ store: store,
146
+ fetcher: store.fetcher,
147
+ paneRegistry: panes,
148
+ authn: SolidLogic.authn,
149
+ logic: SolidLogic.solidLogicSingleton,
150
+ }, {
151
+ onNavigate: function(uri) {
152
+ if (uri) {
153
+ // Use path-based navigation - update URL to match resource
154
+ try {
155
+ var url = new URL(uri);
156
+ // Always use the path from the URI, regardless of origin
157
+ // (URIs may use internal hostname like jss:4000 vs localhost:4000)
158
+ var newPath = url.pathname;
159
+ if (newPath !== window.location.pathname) {
160
+ window.history.pushState({ uri: uri }, '', newPath);
161
+ }
162
+ } catch (e) {
163
+ console.warn('Invalid URI for navigation:', uri);
164
+ }
165
+ }
166
+ },
167
+ onLogout: function() {
168
+ window.location.reload();
169
+ },
170
+ }).then(function(skin) {
171
+ // Handle browser back/forward
172
+ window.addEventListener('popstate', function(event) {
173
+ // Use the current URL as the resource (not hash-based)
174
+ var resourceUrl = window.location.origin + window.location.pathname;
175
+ skin.goto(resourceUrl);
176
+ });
177
+
178
+ // Navigate to the current URL's resource
179
+ // The URL path IS the resource in JSS (not hash-based routing)
180
+ var currentPath = window.location.pathname;
181
+ if (currentPath && currentPath !== '/') {
182
+ var resourceUrl = window.location.origin + currentPath;
183
+ skin.goto(resourceUrl);
184
+ }
185
+
186
+ // Expose for debugging
187
+ window.solidosSkin = skin;
188
+ });
189
+ }).catch(function(err) {
190
+ console.error('Failed to load solidos-ui:', err);
191
+ document.body.innerHTML = '<p>Failed to load SolidOS UI</p>';
192
+ });
193
+ };
194
+ mashScript.onerror = function() {
195
+ document.body.innerHTML = '<p>Failed to load Mashlib</p>';
196
+ };
197
+ document.head.appendChild(mashScript);
198
+ })();
199
+ </script>
200
+ </body>
201
+ </html>`;
202
+ }
203
+
97
204
  /**
98
205
  * Escape HTML special characters
99
206
  */
package/src/server.js CHANGED
@@ -55,6 +55,8 @@ export function createServer(options = {}) {
55
55
  const mashlibEnabled = options.mashlib ?? false;
56
56
  const mashlibCdn = options.mashlibCdn ?? false;
57
57
  const mashlibVersion = options.mashlibVersion ?? '2.0.0';
58
+ // SolidOS UI (modern Nextcloud-style interface) - requires mashlib
59
+ const solidosUiEnabled = options.solidosUi ?? false;
58
60
  // Git HTTP backend is OFF by default - enables clone/push via git protocol
59
61
  const gitEnabled = options.git ?? false;
60
62
  // Nostr relay is OFF by default
@@ -127,6 +129,7 @@ export function createServer(options = {}) {
127
129
  fastify.decorateRequest('mashlibEnabled', null);
128
130
  fastify.decorateRequest('mashlibCdn', null);
129
131
  fastify.decorateRequest('mashlibVersion', null);
132
+ fastify.decorateRequest('solidosUiEnabled', null);
130
133
  fastify.decorateRequest('defaultQuota', null);
131
134
  fastify.addHook('onRequest', async (request) => {
132
135
  request.connegEnabled = connegEnabled;
@@ -137,6 +140,7 @@ export function createServer(options = {}) {
137
140
  request.mashlibEnabled = mashlibEnabled;
138
141
  request.mashlibCdn = mashlibCdn;
139
142
  request.mashlibVersion = mashlibVersion;
143
+ request.solidosUiEnabled = solidosUiEnabled;
140
144
  request.defaultQuota = defaultQuota;
141
145
 
142
146
  // Extract pod name from subdomain if enabled
@@ -296,7 +300,7 @@ export function createServer(options = {}) {
296
300
  // Authorization hook - check WAC permissions
297
301
  // Skip for pod creation endpoint (needs special handling)
298
302
  fastify.addHook('preHandler', async (request, reply) => {
299
- // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, well-known, notifications, nostr, git, and AP
303
+ // Skip auth for pod creation, OPTIONS, IdP routes, mashlib, solidos-ui, well-known, notifications, nostr, git, and AP
300
304
  const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
301
305
  const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following'];
302
306
  // Check if request wants ActivityPub content for profile
@@ -308,6 +312,7 @@ export function createServer(options = {}) {
308
312
  request.method === 'OPTIONS' ||
309
313
  request.url.startsWith('/idp/') ||
310
314
  request.url.startsWith('/.well-known/') ||
315
+ request.url.startsWith('/solidos-ui/') ||
311
316
  (nostrEnabled && request.url.startsWith(nostrPath)) ||
312
317
  (gitEnabled && isGitRequest(request.url)) ||
313
318
  (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
@@ -381,6 +386,37 @@ export function createServer(options = {}) {
381
386
  }
382
387
  }
383
388
 
389
+ // SolidOS UI static files (modern Nextcloud-style interface)
390
+ // Serves from /solidos-ui/* - requires mashlib to be enabled as well
391
+ if (solidosUiEnabled && mashlibEnabled) {
392
+ const solidosUiDir = join(__dirname, 'mashlib-local', 'dist', 'solidos-ui');
393
+
394
+ // Serve all files under /solidos-ui/* path
395
+ fastify.get('/solidos-ui/*', async (request, reply) => {
396
+ try {
397
+ // Get the path after /solidos-ui/
398
+ const filePath = request.url.replace('/solidos-ui/', '').split('?')[0];
399
+ const fullPath = join(solidosUiDir, filePath);
400
+
401
+ // Determine content type based on extension
402
+ const ext = filePath.split('.').pop()?.toLowerCase();
403
+ const contentTypes = {
404
+ 'js': 'application/javascript',
405
+ 'css': 'text/css',
406
+ 'map': 'application/json',
407
+ 'html': 'text/html'
408
+ };
409
+ const contentType = contentTypes[ext] || 'application/octet-stream';
410
+
411
+ const content = await readFile(fullPath);
412
+ return reply.type(contentType).send(content);
413
+ } catch (err) {
414
+ request.log.error(err, 'Failed to serve solidos-ui file');
415
+ return reply.code(404).send({ error: 'Not Found' });
416
+ }
417
+ });
418
+ }
419
+
384
420
  // Rate limit configuration for write operations
385
421
  // Protects against resource exhaustion and abuse
386
422
  const writeRateLimit = {
@@ -51,6 +51,28 @@ export async function read(urlPath) {
51
51
  }
52
52
  }
53
53
 
54
+ /**
55
+ * Create a readable stream for a resource (supports range requests)
56
+ * @param {string} urlPath
57
+ * @param {object} options - { start, end } byte range options
58
+ * @returns {{ stream: ReadStream, filePath: string } | null}
59
+ */
60
+ export function createReadStream(urlPath, options = {}) {
61
+ const filePath = urlToPath(urlPath);
62
+
63
+ // Check file exists before creating stream (createReadStream doesn't throw sync)
64
+ if (!fs.pathExistsSync(filePath)) {
65
+ return null;
66
+ }
67
+
68
+ try {
69
+ const stream = fs.createReadStream(filePath, options);
70
+ return { stream, filePath };
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
54
76
  /**
55
77
  * Write resource content
56
78
  * @param {string} urlPath
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Range Request Tests
3
+ *
4
+ * Tests HTTP Range header support for partial content delivery.
5
+ */
6
+
7
+ import { describe, it, before, after } from 'node:test';
8
+ import assert from 'node:assert';
9
+ import {
10
+ startTestServer,
11
+ stopTestServer,
12
+ request,
13
+ createTestPod,
14
+ assertStatus
15
+ } from './helpers.js';
16
+
17
+ describe('Range Requests', () => {
18
+ const testContent = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; // 36 bytes
19
+
20
+ before(async () => {
21
+ await startTestServer();
22
+ await createTestPod('rangetest');
23
+
24
+ // Create a test file with known content
25
+ await request('/rangetest/public/test.txt', {
26
+ method: 'PUT',
27
+ headers: { 'Content-Type': 'text/plain' },
28
+ body: testContent,
29
+ auth: 'rangetest'
30
+ });
31
+ });
32
+
33
+ after(async () => {
34
+ await stopTestServer();
35
+ });
36
+
37
+ describe('Accept-Ranges header', () => {
38
+ it('should include Accept-Ranges: bytes for files', async () => {
39
+ const res = await request('/rangetest/public/test.txt');
40
+ assertStatus(res, 200);
41
+ assert.strictEqual(res.headers.get('Accept-Ranges'), 'bytes');
42
+ });
43
+
44
+ it('should include Accept-Ranges: none for containers', async () => {
45
+ const res = await request('/rangetest/public/');
46
+ assertStatus(res, 200);
47
+ assert.strictEqual(res.headers.get('Accept-Ranges'), 'none');
48
+ });
49
+ });
50
+
51
+ describe('Range header parsing', () => {
52
+ it('should return 206 for valid range bytes=0-9', async () => {
53
+ const res = await request('/rangetest/public/test.txt', {
54
+ headers: { 'Range': 'bytes=0-9' }
55
+ });
56
+ assertStatus(res, 206);
57
+
58
+ const body = await res.text();
59
+ assert.strictEqual(body, 'ABCDEFGHIJ');
60
+ assert.strictEqual(res.headers.get('Content-Range'), 'bytes 0-9/36');
61
+ assert.strictEqual(res.headers.get('Content-Length'), '10');
62
+ });
63
+
64
+ it('should return 206 for open-ended range bytes=30-', async () => {
65
+ const res = await request('/rangetest/public/test.txt', {
66
+ headers: { 'Range': 'bytes=30-' }
67
+ });
68
+ assertStatus(res, 206);
69
+
70
+ const body = await res.text();
71
+ assert.strictEqual(body, '456789');
72
+ assert.strictEqual(res.headers.get('Content-Range'), 'bytes 30-35/36');
73
+ });
74
+
75
+ it('should return 206 for suffix range bytes=-6', async () => {
76
+ const res = await request('/rangetest/public/test.txt', {
77
+ headers: { 'Range': 'bytes=-6' }
78
+ });
79
+ assertStatus(res, 206);
80
+
81
+ const body = await res.text();
82
+ assert.strictEqual(body, '456789');
83
+ assert.strictEqual(res.headers.get('Content-Range'), 'bytes 30-35/36');
84
+ });
85
+
86
+ it('should clamp end to file size for range exceeding file', async () => {
87
+ const res = await request('/rangetest/public/test.txt', {
88
+ headers: { 'Range': 'bytes=30-1000' }
89
+ });
90
+ assertStatus(res, 206);
91
+
92
+ const body = await res.text();
93
+ assert.strictEqual(body, '456789');
94
+ assert.strictEqual(res.headers.get('Content-Range'), 'bytes 30-35/36');
95
+ });
96
+ });
97
+
98
+ describe('Multi-range requests', () => {
99
+ it('should ignore multi-range and return 200 with full content', async () => {
100
+ const res = await request('/rangetest/public/test.txt', {
101
+ headers: { 'Range': 'bytes=0-5,10-15' }
102
+ });
103
+ // Multi-range is not supported, should fall back to 200
104
+ assertStatus(res, 200);
105
+
106
+ const body = await res.text();
107
+ assert.strictEqual(body, testContent);
108
+ });
109
+ });
110
+
111
+ describe('Invalid ranges', () => {
112
+ it('should return 200 for invalid range format', async () => {
113
+ const res = await request('/rangetest/public/test.txt', {
114
+ headers: { 'Range': 'invalid' }
115
+ });
116
+ // Invalid format, ignore Range header
117
+ assertStatus(res, 200);
118
+ });
119
+
120
+ it('should return 200 for non-bytes range unit', async () => {
121
+ const res = await request('/rangetest/public/test.txt', {
122
+ headers: { 'Range': 'chars=0-10' }
123
+ });
124
+ assertStatus(res, 200);
125
+ });
126
+ });
127
+
128
+ describe('RDF resources', () => {
129
+ it('should ignore Range header for RDF resources', async () => {
130
+ // Create an RDF resource
131
+ await request('/rangetest/public/data.jsonld', {
132
+ method: 'PUT',
133
+ headers: { 'Content-Type': 'application/ld+json' },
134
+ body: JSON.stringify({ '@id': '#test', 'http://example.org/name': 'Test' }),
135
+ auth: 'rangetest'
136
+ });
137
+
138
+ const res = await request('/rangetest/public/data.jsonld', {
139
+ headers: { 'Range': 'bytes=0-10' }
140
+ });
141
+ // RDF resources don't support range requests
142
+ assertStatus(res, 200);
143
+ });
144
+ });
145
+ });