javascript-solid-server 0.0.75 → 0.0.77
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 +27 -1
- package/README.md +38 -2
- package/bin/jss.js +3 -0
- package/package.json +2 -1
- package/src/auth/middleware.js +6 -3
- package/src/config.js +5 -0
- package/src/handlers/resource.js +104 -6
- package/src/idp/accounts.js +133 -0
- package/src/idp/index.js +65 -0
- package/src/idp/interactions.js +118 -9
- package/src/idp/passkey.js +311 -0
- package/src/idp/views.js +312 -1
- package/src/ldp/headers.js +3 -2
- package/src/mashlib/index.js +107 -0
- package/src/server.js +37 -1
- package/src/storage/filesystem.js +22 -0
- package/test/range.test.js +145 -0
package/src/idp/views.js
CHANGED
|
@@ -105,6 +105,38 @@ const styles = `
|
|
|
105
105
|
.btn-secondary:hover {
|
|
106
106
|
background: #e0e0e0;
|
|
107
107
|
}
|
|
108
|
+
.btn-passkey {
|
|
109
|
+
background: #1a73e8;
|
|
110
|
+
color: white;
|
|
111
|
+
width: 100%;
|
|
112
|
+
display: flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
justify-content: center;
|
|
115
|
+
gap: 8px;
|
|
116
|
+
}
|
|
117
|
+
.btn-passkey:hover {
|
|
118
|
+
background: #1557b0;
|
|
119
|
+
}
|
|
120
|
+
.btn-passkey svg {
|
|
121
|
+
width: 20px;
|
|
122
|
+
height: 20px;
|
|
123
|
+
}
|
|
124
|
+
.divider {
|
|
125
|
+
display: flex;
|
|
126
|
+
align-items: center;
|
|
127
|
+
margin: 20px 0;
|
|
128
|
+
color: #666;
|
|
129
|
+
font-size: 14px;
|
|
130
|
+
}
|
|
131
|
+
.divider::before,
|
|
132
|
+
.divider::after {
|
|
133
|
+
content: '';
|
|
134
|
+
flex: 1;
|
|
135
|
+
border-bottom: 1px solid #ddd;
|
|
136
|
+
}
|
|
137
|
+
.divider span {
|
|
138
|
+
padding: 0 12px;
|
|
139
|
+
}
|
|
108
140
|
.scopes {
|
|
109
141
|
margin: 20px 0;
|
|
110
142
|
}
|
|
@@ -149,6 +181,12 @@ const solidLogo = `
|
|
|
149
181
|
</svg>
|
|
150
182
|
`;
|
|
151
183
|
|
|
184
|
+
const passkeyIcon = `
|
|
185
|
+
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
|
186
|
+
<path d="M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>
|
|
187
|
+
</svg>
|
|
188
|
+
`;
|
|
189
|
+
|
|
152
190
|
const scopeDescriptions = {
|
|
153
191
|
openid: 'Access your identity',
|
|
154
192
|
webid: 'Access your WebID',
|
|
@@ -157,11 +195,125 @@ const scopeDescriptions = {
|
|
|
157
195
|
offline_access: 'Stay logged in',
|
|
158
196
|
};
|
|
159
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Escape string for safe use in JavaScript
|
|
200
|
+
*/
|
|
201
|
+
function escapeJs(text) {
|
|
202
|
+
if (!text) return '';
|
|
203
|
+
return String(text)
|
|
204
|
+
.replace(/\\/g, '\\\\')
|
|
205
|
+
.replace(/'/g, "\\'")
|
|
206
|
+
.replace(/"/g, '\\"')
|
|
207
|
+
.replace(/</g, '\\x3c')
|
|
208
|
+
.replace(/>/g, '\\x3e')
|
|
209
|
+
.replace(/\n/g, '\\n')
|
|
210
|
+
.replace(/\r/g, '\\r');
|
|
211
|
+
}
|
|
212
|
+
|
|
160
213
|
/**
|
|
161
214
|
* Login page HTML
|
|
162
215
|
*/
|
|
163
|
-
export function loginPage(uid, clientId, error = null) {
|
|
216
|
+
export function loginPage(uid, clientId, error = null, passkeyEnabled = true) {
|
|
164
217
|
const appName = clientId || 'An application';
|
|
218
|
+
const safeUid = escapeJs(uid);
|
|
219
|
+
|
|
220
|
+
const passkeySection = passkeyEnabled ? `
|
|
221
|
+
<button type="button" class="btn btn-passkey" onclick="loginWithPasskey()">
|
|
222
|
+
${passkeyIcon}
|
|
223
|
+
Sign in with Passkey
|
|
224
|
+
</button>
|
|
225
|
+
|
|
226
|
+
<div class="divider"><span>or</span></div>
|
|
227
|
+
` : '';
|
|
228
|
+
|
|
229
|
+
const passkeyScript = passkeyEnabled ? `
|
|
230
|
+
<script>
|
|
231
|
+
var INTERACTION_UID = '${safeUid}';
|
|
232
|
+
|
|
233
|
+
async function loginWithPasskey() {
|
|
234
|
+
try {
|
|
235
|
+
// Get authentication options
|
|
236
|
+
const optionsRes = await fetch('/idp/passkey/login/options', {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: { 'Content-Type': 'application/json' },
|
|
239
|
+
body: JSON.stringify({ visitorId: crypto.randomUUID() })
|
|
240
|
+
});
|
|
241
|
+
const options = await optionsRes.json();
|
|
242
|
+
if (options.error) {
|
|
243
|
+
alert('Error: ' + options.error);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Convert base64url to ArrayBuffer
|
|
248
|
+
options.challenge = base64urlToBuffer(options.challenge);
|
|
249
|
+
if (options.allowCredentials) {
|
|
250
|
+
options.allowCredentials = options.allowCredentials.map(c => ({
|
|
251
|
+
...c,
|
|
252
|
+
id: base64urlToBuffer(c.id)
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Prompt user for passkey
|
|
257
|
+
const credential = await navigator.credentials.get({ publicKey: options });
|
|
258
|
+
|
|
259
|
+
// Send response to server
|
|
260
|
+
const verifyRes = await fetch('/idp/passkey/login/verify', {
|
|
261
|
+
method: 'POST',
|
|
262
|
+
headers: { 'Content-Type': 'application/json' },
|
|
263
|
+
body: JSON.stringify({
|
|
264
|
+
challengeKey: options.challengeKey,
|
|
265
|
+
credential: {
|
|
266
|
+
id: credential.id,
|
|
267
|
+
rawId: bufferToBase64url(credential.rawId),
|
|
268
|
+
type: credential.type,
|
|
269
|
+
response: {
|
|
270
|
+
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
|
271
|
+
authenticatorData: bufferToBase64url(credential.response.authenticatorData),
|
|
272
|
+
signature: bufferToBase64url(credential.response.signature),
|
|
273
|
+
userHandle: credential.response.userHandle
|
|
274
|
+
? bufferToBase64url(credential.response.userHandle)
|
|
275
|
+
: null
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const result = await verifyRes.json();
|
|
282
|
+
if (result.success) {
|
|
283
|
+
// Complete the OIDC interaction - build URL safely
|
|
284
|
+
const redirectUrl = '/idp/interaction/' + encodeURIComponent(INTERACTION_UID) + '/passkey-complete?accountId=' + encodeURIComponent(result.accountId);
|
|
285
|
+
window.location.href = redirectUrl;
|
|
286
|
+
} else {
|
|
287
|
+
alert('Passkey authentication failed: ' + (result.error || 'Unknown error'));
|
|
288
|
+
}
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if (err.name === 'NotAllowedError') {
|
|
291
|
+
// User cancelled - do nothing
|
|
292
|
+
} else {
|
|
293
|
+
console.error('Passkey error:', err);
|
|
294
|
+
alert('Passkey authentication failed: ' + err.message);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function base64urlToBuffer(base64url) {
|
|
300
|
+
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
301
|
+
const padLen = (4 - base64.length % 4) % 4;
|
|
302
|
+
const padded = base64 + '='.repeat(padLen);
|
|
303
|
+
const binary = atob(padded);
|
|
304
|
+
const bytes = new Uint8Array(binary.length);
|
|
305
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
306
|
+
return bytes.buffer;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function bufferToBase64url(buffer) {
|
|
310
|
+
const bytes = new Uint8Array(buffer);
|
|
311
|
+
let binary = '';
|
|
312
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
313
|
+
return btoa(binary).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=/g, '');
|
|
314
|
+
}
|
|
315
|
+
</script>
|
|
316
|
+
` : '';
|
|
165
317
|
|
|
166
318
|
return `
|
|
167
319
|
<!DOCTYPE html>
|
|
@@ -185,6 +337,8 @@ export function loginPage(uid, clientId, error = null) {
|
|
|
185
337
|
|
|
186
338
|
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
187
339
|
|
|
340
|
+
${passkeySection}
|
|
341
|
+
|
|
188
342
|
<form method="POST" action="/idp/interaction/${uid}/login">
|
|
189
343
|
<label for="username">Username</label>
|
|
190
344
|
<input type="text" id="username" name="username" required autofocus placeholder="Your username">
|
|
@@ -203,6 +357,7 @@ export function loginPage(uid, clientId, error = null) {
|
|
|
203
357
|
Don't have an account? <a href="/idp/register?uid=${uid}" style="color: #0066cc;">Register</a>
|
|
204
358
|
</p>
|
|
205
359
|
</div>
|
|
360
|
+
${passkeyScript}
|
|
206
361
|
</body>
|
|
207
362
|
</html>
|
|
208
363
|
`;
|
|
@@ -349,6 +504,162 @@ export function registerPage(uid = null, error = null, success = null, inviteOnl
|
|
|
349
504
|
`;
|
|
350
505
|
}
|
|
351
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Passkey prompt page - shown after password login to encourage passkey setup
|
|
509
|
+
*/
|
|
510
|
+
export function passkeyPromptPage(uid, accountId) {
|
|
511
|
+
const safeUid = escapeJs(uid);
|
|
512
|
+
const safeAccountId = escapeJs(accountId);
|
|
513
|
+
// Pre-escape the SVG for innerHTML assignment (no user data, just static SVG)
|
|
514
|
+
const passkeyIconEscaped = passkeyIcon.replace(/'/g, "\\'").replace(/\n/g, '');
|
|
515
|
+
|
|
516
|
+
return `
|
|
517
|
+
<!DOCTYPE html>
|
|
518
|
+
<html lang="en">
|
|
519
|
+
<head>
|
|
520
|
+
<meta charset="UTF-8">
|
|
521
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
522
|
+
<title>Add a Passkey - Solid IdP</title>
|
|
523
|
+
<style>${styles}</style>
|
|
524
|
+
</head>
|
|
525
|
+
<body>
|
|
526
|
+
<div class="container">
|
|
527
|
+
<div class="logo">${solidLogo}</div>
|
|
528
|
+
<h1>Add a Passkey?</h1>
|
|
529
|
+
<p class="subtitle">Sign in faster next time</p>
|
|
530
|
+
|
|
531
|
+
<div class="client-info">
|
|
532
|
+
<div class="client-name">Passkeys are more secure</div>
|
|
533
|
+
<div class="client-uri">Use Touch ID, Face ID, or a security key instead of your password</div>
|
|
534
|
+
</div>
|
|
535
|
+
|
|
536
|
+
<button type="button" class="btn btn-passkey" onclick="registerPasskey()" id="addBtn">
|
|
537
|
+
${passkeyIcon}
|
|
538
|
+
Add Passkey
|
|
539
|
+
</button>
|
|
540
|
+
|
|
541
|
+
<form method="GET" action="/idp/interaction/${escapeHtml(uid)}/passkey-skip">
|
|
542
|
+
<button type="submit" class="btn btn-secondary">Skip for now</button>
|
|
543
|
+
</form>
|
|
544
|
+
</div>
|
|
545
|
+
|
|
546
|
+
<script>
|
|
547
|
+
var INTERACTION_UID = '${safeUid}';
|
|
548
|
+
var ACCOUNT_ID = '${safeAccountId}';
|
|
549
|
+
var PASSKEY_ICON = '${passkeyIconEscaped}';
|
|
550
|
+
|
|
551
|
+
async function registerPasskey() {
|
|
552
|
+
const btn = document.getElementById('addBtn');
|
|
553
|
+
btn.disabled = true;
|
|
554
|
+
btn.textContent = 'Setting up...';
|
|
555
|
+
|
|
556
|
+
try {
|
|
557
|
+
// Get registration options
|
|
558
|
+
const optionsRes = await fetch('/idp/passkey/register/options', {
|
|
559
|
+
method: 'POST',
|
|
560
|
+
headers: { 'Content-Type': 'application/json' },
|
|
561
|
+
body: JSON.stringify({ accountId: ACCOUNT_ID })
|
|
562
|
+
});
|
|
563
|
+
const options = await optionsRes.json();
|
|
564
|
+
if (options.error) {
|
|
565
|
+
alert('Error: ' + options.error);
|
|
566
|
+
btn.disabled = false;
|
|
567
|
+
btn.innerHTML = PASSKEY_ICON + ' Add Passkey';
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Save challengeKey for verification
|
|
572
|
+
const challengeKey = options.challengeKey;
|
|
573
|
+
|
|
574
|
+
// Convert base64url to ArrayBuffer
|
|
575
|
+
options.challenge = base64urlToBuffer(options.challenge);
|
|
576
|
+
options.user.id = base64urlToBuffer(options.user.id);
|
|
577
|
+
if (options.excludeCredentials) {
|
|
578
|
+
options.excludeCredentials = options.excludeCredentials.map(c => ({
|
|
579
|
+
...c,
|
|
580
|
+
id: base64urlToBuffer(c.id)
|
|
581
|
+
}));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Prompt user to create passkey
|
|
585
|
+
const credential = await navigator.credentials.create({ publicKey: options });
|
|
586
|
+
|
|
587
|
+
// Send response to server
|
|
588
|
+
const verifyRes = await fetch('/idp/passkey/register/verify', {
|
|
589
|
+
method: 'POST',
|
|
590
|
+
headers: { 'Content-Type': 'application/json' },
|
|
591
|
+
body: JSON.stringify({
|
|
592
|
+
accountId: ACCOUNT_ID,
|
|
593
|
+
challengeKey: challengeKey,
|
|
594
|
+
credential: {
|
|
595
|
+
id: credential.id,
|
|
596
|
+
rawId: bufferToBase64url(credential.rawId),
|
|
597
|
+
type: credential.type,
|
|
598
|
+
response: {
|
|
599
|
+
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
|
600
|
+
attestationObject: bufferToBase64url(credential.response.attestationObject),
|
|
601
|
+
transports: credential.response.getTransports ? credential.response.getTransports() : []
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
name: detectDeviceName()
|
|
605
|
+
})
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const result = await verifyRes.json();
|
|
609
|
+
if (result.success) {
|
|
610
|
+
// Passkey added, continue to app - build URL safely
|
|
611
|
+
const redirectUrl = '/idp/interaction/' + encodeURIComponent(INTERACTION_UID) + '/passkey-complete?accountId=' + encodeURIComponent(ACCOUNT_ID);
|
|
612
|
+
window.location.href = redirectUrl;
|
|
613
|
+
} else {
|
|
614
|
+
alert('Failed to add passkey: ' + (result.error || 'Unknown error'));
|
|
615
|
+
btn.disabled = false;
|
|
616
|
+
btn.innerHTML = PASSKEY_ICON + ' Add Passkey';
|
|
617
|
+
}
|
|
618
|
+
} catch (err) {
|
|
619
|
+
if (err.name === 'NotAllowedError') {
|
|
620
|
+
// User cancelled
|
|
621
|
+
} else {
|
|
622
|
+
console.error('Passkey error:', err);
|
|
623
|
+
alert('Failed to add passkey: ' + err.message);
|
|
624
|
+
}
|
|
625
|
+
btn.disabled = false;
|
|
626
|
+
btn.innerHTML = PASSKEY_ICON + ' Add Passkey';
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function detectDeviceName() {
|
|
631
|
+
const ua = navigator.userAgent;
|
|
632
|
+
if (/iPhone/.test(ua)) return 'iPhone';
|
|
633
|
+
if (/iPad/.test(ua)) return 'iPad';
|
|
634
|
+
if (/Mac/.test(ua)) return 'Mac';
|
|
635
|
+
if (/Android/.test(ua)) return 'Android';
|
|
636
|
+
if (/Windows/.test(ua)) return 'Windows';
|
|
637
|
+
if (/Linux/.test(ua)) return 'Linux';
|
|
638
|
+
return 'Security Key';
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function base64urlToBuffer(base64url) {
|
|
642
|
+
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
643
|
+
const padLen = (4 - base64.length % 4) % 4;
|
|
644
|
+
const padded = base64 + '='.repeat(padLen);
|
|
645
|
+
const binary = atob(padded);
|
|
646
|
+
const bytes = new Uint8Array(binary.length);
|
|
647
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
648
|
+
return bytes.buffer;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function bufferToBase64url(buffer) {
|
|
652
|
+
const bytes = new Uint8Array(buffer);
|
|
653
|
+
let binary = '';
|
|
654
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
655
|
+
return btoa(binary).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=/g, '');
|
|
656
|
+
}
|
|
657
|
+
</script>
|
|
658
|
+
</body>
|
|
659
|
+
</html>
|
|
660
|
+
`;
|
|
661
|
+
}
|
|
662
|
+
|
|
352
663
|
/**
|
|
353
664
|
* Escape HTML to prevent XSS
|
|
354
665
|
*/
|
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
|
*/
|
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
|