javascript-solid-server 0.0.73 → 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.
- package/.claude/settings.local.json +29 -1
- package/README.md +63 -3
- package/bin/jss.js +7 -0
- package/package.json +1 -1
- package/src/auth/middleware.js +6 -3
- package/src/auth/solid-oidc.js +2 -2
- package/src/auth/token.js +45 -30
- package/src/auth/webid-tls.js +270 -0
- package/src/config.js +9 -0
- package/src/handlers/resource.js +104 -6
- package/src/ldp/headers.js +3 -2
- package/src/mashlib/index.js +107 -0
- package/src/notifications/index.js +5 -2
- package/src/notifications/websocket.js +63 -3
- package/src/server.js +47 -1
- package/src/storage/filesystem.js +22 -0
- package/test/helpers.js +2 -0
- package/test/notifications.test.js +90 -0
- package/test/range.test.js +145 -0
- package/test/webid-tls.test.js +119 -0
package/src/handlers/resource.js
CHANGED
|
@@ -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
|
-
|
|
153
|
-
const html =
|
|
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
|
-
//
|
|
228
|
-
const
|
|
229
|
-
|
|
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' });
|
package/src/ldp/headers.js
CHANGED
|
@@ -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
|
};
|
package/src/mashlib/index.js
CHANGED
|
@@ -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
|
*/
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import websocket from '@fastify/websocket';
|
|
20
20
|
import { handleWebSocket, getConnectionCount, getSubscriptionCount } from './websocket.js';
|
|
21
|
+
import { getWebIdFromRequestAsync } from '../auth/token.js';
|
|
21
22
|
export { emitChange } from './events.js';
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -32,8 +33,10 @@ export async function notificationsPlugin(fastify, options) {
|
|
|
32
33
|
// WebSocket route for notifications (dedicated path to avoid route conflicts)
|
|
33
34
|
// Clients discover this via Updates-Via header
|
|
34
35
|
// In @fastify/websocket v8, handler receives (connection, request) where connection.socket is the raw WebSocket
|
|
35
|
-
fastify.get('/.notifications', { websocket: true }, (connection, request) => {
|
|
36
|
-
|
|
36
|
+
fastify.get('/.notifications', { websocket: true }, async (connection, request) => {
|
|
37
|
+
// Get WebID from auth token (if present) for ACL checking on subscriptions
|
|
38
|
+
const { webId } = await getWebIdFromRequestAsync(request);
|
|
39
|
+
handleWebSocket(connection.socket, request, webId);
|
|
37
40
|
});
|
|
38
41
|
|
|
39
42
|
// Optional: Status endpoint for monitoring
|
|
@@ -6,11 +6,19 @@
|
|
|
6
6
|
* Protocol:
|
|
7
7
|
* - Server sends: "protocol solid-0.1" on connect
|
|
8
8
|
* - Client sends: "sub <uri>" to subscribe
|
|
9
|
-
* - Server sends: "ack <uri>" to acknowledge
|
|
9
|
+
* - Server sends: "ack <uri>" to acknowledge (if authorized)
|
|
10
|
+
* - Server sends: "err <uri> forbidden" if not authorized
|
|
10
11
|
* - Server sends: "pub <uri>" when resource changes
|
|
12
|
+
*
|
|
13
|
+
* Security:
|
|
14
|
+
* - ACL is checked on every subscription request
|
|
15
|
+
* - Only subscribers with read access receive notifications
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
import { resourceEvents } from './events.js';
|
|
19
|
+
import { checkAccess } from '../wac/checker.js';
|
|
20
|
+
import { AccessMode } from '../wac/parser.js';
|
|
21
|
+
import * as storage from '../storage/filesystem.js';
|
|
14
22
|
|
|
15
23
|
// Security limits
|
|
16
24
|
const MAX_SUBSCRIPTIONS_PER_CONNECTION = 100;
|
|
@@ -26,8 +34,13 @@ const subscribers = new Map();
|
|
|
26
34
|
* Handle new WebSocket connection
|
|
27
35
|
* @param {WebSocket} socket - The WebSocket connection
|
|
28
36
|
* @param {Request} request - The HTTP request
|
|
37
|
+
* @param {string|null} webId - Authenticated WebID (null for anonymous)
|
|
29
38
|
*/
|
|
30
|
-
export function handleWebSocket(socket, request) {
|
|
39
|
+
export function handleWebSocket(socket, request, webId = null) {
|
|
40
|
+
// Store webId and server info on socket for ACL checks
|
|
41
|
+
socket.webId = webId;
|
|
42
|
+
socket.serverOrigin = `${request.protocol}://${request.hostname}`;
|
|
43
|
+
|
|
31
44
|
// Send protocol greeting
|
|
32
45
|
socket.send('protocol solid-0.1');
|
|
33
46
|
|
|
@@ -35,7 +48,7 @@ export function handleWebSocket(socket, request) {
|
|
|
35
48
|
subscriptions.set(socket, new Set());
|
|
36
49
|
|
|
37
50
|
// Handle incoming messages
|
|
38
|
-
socket.on('message', (message) => {
|
|
51
|
+
socket.on('message', async (message) => {
|
|
39
52
|
const msg = message.toString().trim();
|
|
40
53
|
|
|
41
54
|
// Handle subscription request
|
|
@@ -55,6 +68,13 @@ export function handleWebSocket(socket, request) {
|
|
|
55
68
|
return;
|
|
56
69
|
}
|
|
57
70
|
|
|
71
|
+
// Security: check ACL read permission before allowing subscription
|
|
72
|
+
const canSubscribe = await checkSubscriptionAccess(url, socket);
|
|
73
|
+
if (!canSubscribe) {
|
|
74
|
+
socket.send(`err ${url} forbidden`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
subscribe(socket, url);
|
|
59
79
|
socket.send(`ack ${url}`);
|
|
60
80
|
}
|
|
@@ -80,6 +100,46 @@ export function handleWebSocket(socket, request) {
|
|
|
80
100
|
});
|
|
81
101
|
}
|
|
82
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Check if socket has read access to subscribe to a URL
|
|
105
|
+
* @param {string} url - The URL to subscribe to
|
|
106
|
+
* @param {WebSocket} socket - The WebSocket connection (with webId attached)
|
|
107
|
+
* @returns {Promise<boolean>} - true if subscription is allowed
|
|
108
|
+
*/
|
|
109
|
+
async function checkSubscriptionAccess(url, socket) {
|
|
110
|
+
try {
|
|
111
|
+
// Parse the subscription URL
|
|
112
|
+
const parsedUrl = new URL(url);
|
|
113
|
+
|
|
114
|
+
// Security: Only allow subscriptions to URLs on this server
|
|
115
|
+
// This prevents using the server as a proxy to probe other servers
|
|
116
|
+
if (parsedUrl.origin !== socket.serverOrigin) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const resourcePath = decodeURIComponent(parsedUrl.pathname);
|
|
121
|
+
|
|
122
|
+
// Check if resource exists and if it's a container
|
|
123
|
+
const stats = await storage.stat(resourcePath);
|
|
124
|
+
const isContainer = stats?.isDirectory || resourcePath.endsWith('/');
|
|
125
|
+
|
|
126
|
+
// Check WAC read permission
|
|
127
|
+
const { allowed } = await checkAccess({
|
|
128
|
+
resourceUrl: url,
|
|
129
|
+
resourcePath,
|
|
130
|
+
isContainer,
|
|
131
|
+
agentWebId: socket.webId,
|
|
132
|
+
requiredMode: AccessMode.READ
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return allowed;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
// On any error (invalid URL, storage error, etc.), deny subscription
|
|
138
|
+
// This prevents information leakage through error messages
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
83
143
|
/**
|
|
84
144
|
* Subscribe a socket to a resource URL
|
|
85
145
|
*/
|
package/src/server.js
CHANGED
|
@@ -37,6 +37,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
37
37
|
* @param {string} options.apDisplayName - ActivityPub display name
|
|
38
38
|
* @param {string} options.apSummary - ActivityPub bio/summary
|
|
39
39
|
* @param {string} options.apNostrPubkey - Nostr pubkey for identity linking
|
|
40
|
+
* @param {boolean} options.webidTls - Enable WebID-TLS client certificate auth (default false)
|
|
40
41
|
*/
|
|
41
42
|
export function createServer(options = {}) {
|
|
42
43
|
// Content negotiation is OFF by default - we're a JSON-LD native server
|
|
@@ -54,6 +55,8 @@ export function createServer(options = {}) {
|
|
|
54
55
|
const mashlibEnabled = options.mashlib ?? false;
|
|
55
56
|
const mashlibCdn = options.mashlibCdn ?? false;
|
|
56
57
|
const mashlibVersion = options.mashlibVersion ?? '2.0.0';
|
|
58
|
+
// SolidOS UI (modern Nextcloud-style interface) - requires mashlib
|
|
59
|
+
const solidosUiEnabled = options.solidosUi ?? false;
|
|
57
60
|
// Git HTTP backend is OFF by default - enables clone/push via git protocol
|
|
58
61
|
const gitEnabled = options.git ?? false;
|
|
59
62
|
// Nostr relay is OFF by default
|
|
@@ -70,6 +73,8 @@ export function createServer(options = {}) {
|
|
|
70
73
|
const inviteOnly = options.inviteOnly ?? false;
|
|
71
74
|
// Default storage quota per pod (50MB default, 0 = unlimited)
|
|
72
75
|
const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
|
|
76
|
+
// WebID-TLS client certificate authentication is OFF by default
|
|
77
|
+
const webidTlsEnabled = options.webidTls ?? false;
|
|
73
78
|
|
|
74
79
|
// Set data root via environment variable if provided
|
|
75
80
|
if (options.root) {
|
|
@@ -90,6 +95,13 @@ export function createServer(options = {}) {
|
|
|
90
95
|
key: options.ssl.key,
|
|
91
96
|
cert: options.ssl.cert,
|
|
92
97
|
};
|
|
98
|
+
|
|
99
|
+
// Enable client certificate request for WebID-TLS
|
|
100
|
+
if (webidTlsEnabled) {
|
|
101
|
+
fastifyOptions.https.requestCert = true;
|
|
102
|
+
// Don't reject unauthorized - we verify via WebID profile, not CA chain
|
|
103
|
+
fastifyOptions.https.rejectUnauthorized = false;
|
|
104
|
+
}
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
const fastify = Fastify(fastifyOptions);
|
|
@@ -117,6 +129,7 @@ export function createServer(options = {}) {
|
|
|
117
129
|
fastify.decorateRequest('mashlibEnabled', null);
|
|
118
130
|
fastify.decorateRequest('mashlibCdn', null);
|
|
119
131
|
fastify.decorateRequest('mashlibVersion', null);
|
|
132
|
+
fastify.decorateRequest('solidosUiEnabled', null);
|
|
120
133
|
fastify.decorateRequest('defaultQuota', null);
|
|
121
134
|
fastify.addHook('onRequest', async (request) => {
|
|
122
135
|
request.connegEnabled = connegEnabled;
|
|
@@ -127,6 +140,7 @@ export function createServer(options = {}) {
|
|
|
127
140
|
request.mashlibEnabled = mashlibEnabled;
|
|
128
141
|
request.mashlibCdn = mashlibCdn;
|
|
129
142
|
request.mashlibVersion = mashlibVersion;
|
|
143
|
+
request.solidosUiEnabled = solidosUiEnabled;
|
|
130
144
|
request.defaultQuota = defaultQuota;
|
|
131
145
|
|
|
132
146
|
// Extract pod name from subdomain if enabled
|
|
@@ -286,7 +300,7 @@ export function createServer(options = {}) {
|
|
|
286
300
|
// Authorization hook - check WAC permissions
|
|
287
301
|
// Skip for pod creation endpoint (needs special handling)
|
|
288
302
|
fastify.addHook('preHandler', async (request, reply) => {
|
|
289
|
-
// 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
|
|
290
304
|
const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
|
|
291
305
|
const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following'];
|
|
292
306
|
// Check if request wants ActivityPub content for profile
|
|
@@ -298,6 +312,7 @@ export function createServer(options = {}) {
|
|
|
298
312
|
request.method === 'OPTIONS' ||
|
|
299
313
|
request.url.startsWith('/idp/') ||
|
|
300
314
|
request.url.startsWith('/.well-known/') ||
|
|
315
|
+
request.url.startsWith('/solidos-ui/') ||
|
|
301
316
|
(nostrEnabled && request.url.startsWith(nostrPath)) ||
|
|
302
317
|
(gitEnabled && isGitRequest(request.url)) ||
|
|
303
318
|
(activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
|
|
@@ -371,6 +386,37 @@ export function createServer(options = {}) {
|
|
|
371
386
|
}
|
|
372
387
|
}
|
|
373
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
|
+
|
|
374
420
|
// Rate limit configuration for write operations
|
|
375
421
|
// Protects against resource exhaustion and abuse
|
|
376
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
|
package/test/helpers.js
CHANGED
|
@@ -39,9 +39,11 @@ export async function startTestServer(options = {}) {
|
|
|
39
39
|
*/
|
|
40
40
|
export async function stopTestServer() {
|
|
41
41
|
if (server) {
|
|
42
|
+
// Force close all connections to avoid hanging
|
|
42
43
|
await server.close();
|
|
43
44
|
server = null;
|
|
44
45
|
}
|
|
46
|
+
baseUrl = null;
|
|
45
47
|
// Clean up test data
|
|
46
48
|
await fs.emptyDir(TEST_DATA_DIR);
|
|
47
49
|
// Clear tokens
|
|
@@ -328,6 +328,96 @@ describe('WebSocket Notifications (notifications enabled)', () => {
|
|
|
328
328
|
});
|
|
329
329
|
});
|
|
330
330
|
|
|
331
|
+
describe('WebSocket ACL Enforcement', () => {
|
|
332
|
+
let wsUrl;
|
|
333
|
+
|
|
334
|
+
before(async () => {
|
|
335
|
+
await startTestServer({ notifications: true });
|
|
336
|
+
await createTestPod('aclnotify');
|
|
337
|
+
const res = await request('/aclnotify/', { method: 'OPTIONS' });
|
|
338
|
+
wsUrl = res.headers.get('Updates-Via');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
after(async () => {
|
|
342
|
+
await stopTestServer();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should allow anonymous subscription to public resources', async () => {
|
|
346
|
+
const ws = new WebSocket(wsUrl);
|
|
347
|
+
const baseUrl = getBaseUrl();
|
|
348
|
+
const resourceUrl = `${baseUrl}/aclnotify/public/anon-allowed.json`;
|
|
349
|
+
|
|
350
|
+
const messages = [];
|
|
351
|
+
|
|
352
|
+
await new Promise((resolve, reject) => {
|
|
353
|
+
ws.on('open', () => {
|
|
354
|
+
ws.send(`sub ${resourceUrl}`);
|
|
355
|
+
});
|
|
356
|
+
ws.on('message', (data) => {
|
|
357
|
+
messages.push(data.toString());
|
|
358
|
+
if (messages.length >= 2) resolve();
|
|
359
|
+
});
|
|
360
|
+
ws.on('error', reject);
|
|
361
|
+
setTimeout(() => resolve(), 2000);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
assert.ok(messages.includes('protocol solid-0.1'), 'Should receive protocol greeting');
|
|
365
|
+
assert.ok(messages.some(m => m === `ack ${resourceUrl}`), 'Should receive ack for public resource');
|
|
366
|
+
ws.close();
|
|
367
|
+
await new Promise(r => setTimeout(r, 50)); // Allow WebSocket to fully close
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should deny anonymous subscription to private resources', async () => {
|
|
371
|
+
const ws = new WebSocket(wsUrl);
|
|
372
|
+
const baseUrl = getBaseUrl();
|
|
373
|
+
const resourceUrl = `${baseUrl}/aclnotify/private/secret.json`;
|
|
374
|
+
|
|
375
|
+
const messages = [];
|
|
376
|
+
|
|
377
|
+
await new Promise((resolve, reject) => {
|
|
378
|
+
ws.on('open', () => {
|
|
379
|
+
ws.send(`sub ${resourceUrl}`);
|
|
380
|
+
});
|
|
381
|
+
ws.on('message', (data) => {
|
|
382
|
+
messages.push(data.toString());
|
|
383
|
+
if (messages.some(m => m.startsWith('err '))) resolve();
|
|
384
|
+
});
|
|
385
|
+
ws.on('error', reject);
|
|
386
|
+
setTimeout(() => resolve(), 2000);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
assert.ok(messages.includes('protocol solid-0.1'), 'Should receive protocol greeting');
|
|
390
|
+
assert.ok(messages.some(m => m === `err ${resourceUrl} forbidden`),
|
|
391
|
+
`Should receive err forbidden for private resource. Got: ${messages.join(', ')}`);
|
|
392
|
+
ws.close();
|
|
393
|
+
await new Promise(r => setTimeout(r, 50)); // Allow WebSocket to fully close
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should deny subscription to resources on other servers', async () => {
|
|
397
|
+
const ws = new WebSocket(wsUrl);
|
|
398
|
+
const externalUrl = 'https://evil.example.com/steal/data.json';
|
|
399
|
+
|
|
400
|
+
const messages = [];
|
|
401
|
+
|
|
402
|
+
await new Promise((resolve, reject) => {
|
|
403
|
+
ws.on('open', () => {
|
|
404
|
+
ws.send(`sub ${externalUrl}`);
|
|
405
|
+
});
|
|
406
|
+
ws.on('message', (data) => {
|
|
407
|
+
messages.push(data.toString());
|
|
408
|
+
if (messages.some(m => m.startsWith('err '))) resolve();
|
|
409
|
+
});
|
|
410
|
+
ws.on('error', reject);
|
|
411
|
+
setTimeout(() => resolve(), 2000);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
assert.ok(messages.some(m => m === `err ${externalUrl} forbidden`),
|
|
415
|
+
'Should deny subscription to external URLs');
|
|
416
|
+
ws.close();
|
|
417
|
+
await new Promise(r => setTimeout(r, 50)); // Allow WebSocket to fully close
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
331
421
|
describe('WebSocket Notifications (notifications disabled - default)', () => {
|
|
332
422
|
before(async () => {
|
|
333
423
|
// Start server with notifications DISABLED (default)
|