javascript-solid-server 0.0.66 → 0.0.68
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 +3 -1
- package/package.json +1 -1
- package/src/ap/index.js +4 -2
- package/src/ap/routes/outbox.js +99 -2
- package/src/auth/middleware.js +231 -5
- package/src/server.js +1 -1
package/package.json
CHANGED
package/src/ap/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { webfinger } from 'microfed'
|
|
|
7
7
|
import { loadOrCreateKeypair, getKeyId } from './keys.js'
|
|
8
8
|
import { initStore } from './store.js'
|
|
9
9
|
import { createInboxHandler } from './routes/inbox.js'
|
|
10
|
-
import { createOutboxHandler } from './routes/outbox.js'
|
|
10
|
+
import { createOutboxHandler, createOutboxPostHandler } from './routes/outbox.js'
|
|
11
11
|
import { createCollectionsHandler } from './routes/collections.js'
|
|
12
12
|
import { createActorHandler } from './routes/actor.js'
|
|
13
13
|
|
|
@@ -135,7 +135,7 @@ export async function activityPubPlugin(fastify, options = {}) {
|
|
|
135
135
|
version: '2.1',
|
|
136
136
|
software: {
|
|
137
137
|
name: 'jss',
|
|
138
|
-
version: '0.0.
|
|
138
|
+
version: '0.0.67',
|
|
139
139
|
repository: 'https://github.com/JavaScriptSolidServer/JavaScriptSolidServer'
|
|
140
140
|
},
|
|
141
141
|
protocols: ['activitypub', 'solid'],
|
|
@@ -165,7 +165,9 @@ export async function activityPubPlugin(fastify, options = {}) {
|
|
|
165
165
|
|
|
166
166
|
// Outbox endpoint
|
|
167
167
|
const outboxHandler = createOutboxHandler(config, keypair)
|
|
168
|
+
const outboxPostHandler = createOutboxPostHandler(config, keypair)
|
|
168
169
|
fastify.get('/profile/card/outbox', outboxHandler)
|
|
170
|
+
fastify.post('/profile/card/outbox', outboxPostHandler)
|
|
169
171
|
|
|
170
172
|
// Followers/Following collections
|
|
171
173
|
const collectionsHandler = createCollectionsHandler(config)
|
package/src/ap/routes/outbox.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Outbox endpoint handler
|
|
3
3
|
* Returns user's activities as OrderedCollection
|
|
4
|
+
* Accepts POST to create new posts and deliver to followers
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import {
|
|
7
|
+
import { outbox } from 'microfed'
|
|
8
|
+
import { getPosts, savePost, getFollowerInboxes } from '../store.js'
|
|
9
|
+
import { randomUUID } from 'crypto'
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* Create outbox handler
|
|
@@ -49,4 +52,98 @@ export function createOutboxHandler(config, keypair) {
|
|
|
49
52
|
}
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Create outbox POST handler for creating new posts
|
|
57
|
+
* @param {object} config - AP configuration
|
|
58
|
+
* @param {object} keypair - RSA keypair
|
|
59
|
+
* @returns {Function} Fastify handler
|
|
60
|
+
*/
|
|
61
|
+
export function createOutboxPostHandler(config, keypair) {
|
|
62
|
+
return async (request, reply) => {
|
|
63
|
+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
|
|
64
|
+
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
65
|
+
const baseUrl = `${protocol}://${host}`
|
|
66
|
+
const profileUrl = `${baseUrl}/profile/card`
|
|
67
|
+
const actorId = `${profileUrl}#me`
|
|
68
|
+
|
|
69
|
+
// Parse body
|
|
70
|
+
let activity
|
|
71
|
+
try {
|
|
72
|
+
activity = typeof request.body === 'string'
|
|
73
|
+
? JSON.parse(request.body)
|
|
74
|
+
: request.body
|
|
75
|
+
} catch {
|
|
76
|
+
return reply.code(400).send({ error: 'Invalid JSON' })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Handle direct Note posting (convenience)
|
|
80
|
+
if (activity.type === 'Note' || (!activity.type && activity.content)) {
|
|
81
|
+
const noteId = `${baseUrl}/posts/${randomUUID()}`
|
|
82
|
+
const now = new Date().toISOString()
|
|
83
|
+
|
|
84
|
+
const note = {
|
|
85
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
86
|
+
type: 'Note',
|
|
87
|
+
id: noteId,
|
|
88
|
+
content: activity.content,
|
|
89
|
+
published: now,
|
|
90
|
+
attributedTo: actorId,
|
|
91
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
92
|
+
cc: [`${profileUrl}/followers`],
|
|
93
|
+
...(activity.inReplyTo ? { inReplyTo: activity.inReplyTo } : {})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
activity = {
|
|
97
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
98
|
+
type: 'Create',
|
|
99
|
+
id: `${noteId}/activity`,
|
|
100
|
+
actor: actorId,
|
|
101
|
+
published: now,
|
|
102
|
+
object: note,
|
|
103
|
+
to: note.to,
|
|
104
|
+
cc: note.cc
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Save post
|
|
109
|
+
if (activity.type === 'Create' && activity.object?.type === 'Note') {
|
|
110
|
+
savePost(
|
|
111
|
+
activity.object.id,
|
|
112
|
+
activity.object.content,
|
|
113
|
+
activity.object.inReplyTo || null
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Deliver to followers
|
|
118
|
+
const inboxes = getFollowerInboxes()
|
|
119
|
+
request.log.info(`Delivering to ${inboxes.length} follower(s)`)
|
|
120
|
+
|
|
121
|
+
const keyId = `${profileUrl}#main-key`
|
|
122
|
+
const deliveryResults = await Promise.allSettled(
|
|
123
|
+
inboxes.map(inbox =>
|
|
124
|
+
outbox.send({
|
|
125
|
+
activity,
|
|
126
|
+
inbox,
|
|
127
|
+
privateKey: keypair.privateKey,
|
|
128
|
+
keyId
|
|
129
|
+
})
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const succeeded = deliveryResults.filter(r => r.status === 'fulfilled').length
|
|
134
|
+
const failed = deliveryResults.filter(r => r.status === 'rejected').length
|
|
135
|
+
|
|
136
|
+
if (failed > 0) {
|
|
137
|
+
request.log.warn(`Delivery: ${succeeded} succeeded, ${failed} failed`)
|
|
138
|
+
} else {
|
|
139
|
+
request.log.info(`Delivered to ${succeeded} inbox(es)`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return reply
|
|
143
|
+
.code(201)
|
|
144
|
+
.header('Location', activity.object?.id || activity.id)
|
|
145
|
+
.send(activity)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default { createOutboxHandler, createOutboxPostHandler }
|
package/src/auth/middleware.js
CHANGED
|
@@ -93,26 +93,36 @@ function getParentPath(path) {
|
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
95
|
* Handle unauthorized request
|
|
96
|
+
* @param {object} request - Fastify request
|
|
96
97
|
* @param {object} reply - Fastify reply
|
|
97
98
|
* @param {boolean} isAuthenticated - Whether user is authenticated
|
|
98
99
|
* @param {string} wacAllow - WAC-Allow header value
|
|
99
100
|
* @param {string|null} authError - Authentication error message (for DPoP failures)
|
|
100
101
|
* @param {string|null} issuer - IdP issuer URL for WWW-Authenticate header
|
|
101
102
|
*/
|
|
102
|
-
export function handleUnauthorized(reply, isAuthenticated, wacAllow, authError = null, issuer = null) {
|
|
103
|
+
export function handleUnauthorized(request, reply, isAuthenticated, wacAllow, authError = null, issuer = null) {
|
|
103
104
|
reply.header('WAC-Allow', wacAllow);
|
|
104
105
|
|
|
106
|
+
const statusCode = isAuthenticated ? 403 : 401;
|
|
107
|
+
const realm = issuer || 'Solid';
|
|
108
|
+
|
|
105
109
|
if (!isAuthenticated) {
|
|
106
|
-
// Not authenticated - return 401 with WWW-Authenticate header
|
|
107
|
-
// Solid-OIDC requires DPoP authentication
|
|
108
|
-
const realm = issuer || 'Solid';
|
|
109
110
|
reply.header('WWW-Authenticate', `DPoP realm="${realm}", Bearer realm="${realm}"`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Check if browser wants HTML
|
|
114
|
+
const accept = request.headers.accept || '';
|
|
115
|
+
if (accept.includes('text/html')) {
|
|
116
|
+
return reply.code(statusCode).type('text/html').send(getErrorPage(statusCode, isAuthenticated, request));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Return JSON for API clients
|
|
120
|
+
if (!isAuthenticated) {
|
|
110
121
|
return reply.code(401).send({
|
|
111
122
|
error: 'Unauthorized',
|
|
112
123
|
message: authError || 'Authentication required'
|
|
113
124
|
});
|
|
114
125
|
} else {
|
|
115
|
-
// Authenticated but not authorized - return 403
|
|
116
126
|
return reply.code(403).send({
|
|
117
127
|
error: 'Forbidden',
|
|
118
128
|
message: 'Access denied'
|
|
@@ -120,6 +130,222 @@ export function handleUnauthorized(reply, isAuthenticated, wacAllow, authError =
|
|
|
120
130
|
}
|
|
121
131
|
}
|
|
122
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Generate a beautiful error page for browsers
|
|
135
|
+
*/
|
|
136
|
+
function getErrorPage(statusCode, isAuthenticated, request) {
|
|
137
|
+
const is401 = statusCode === 401;
|
|
138
|
+
const title = is401 ? 'Authentication Required' : 'Access Denied';
|
|
139
|
+
const subtitle = is401
|
|
140
|
+
? "This resource is protected. You'll need to sign in to continue."
|
|
141
|
+
: "You're signed in, but you don't have permission to view this resource.";
|
|
142
|
+
|
|
143
|
+
const baseUrl = `${request.protocol}://${request.hostname}`;
|
|
144
|
+
|
|
145
|
+
return `<!DOCTYPE html>
|
|
146
|
+
<html lang="en">
|
|
147
|
+
<head>
|
|
148
|
+
<meta charset="UTF-8">
|
|
149
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
150
|
+
<title>${title} - Solid Server</title>
|
|
151
|
+
<style>
|
|
152
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
153
|
+
|
|
154
|
+
body {
|
|
155
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
156
|
+
min-height: 100vh;
|
|
157
|
+
display: flex;
|
|
158
|
+
align-items: center;
|
|
159
|
+
justify-content: center;
|
|
160
|
+
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%);
|
|
161
|
+
padding: 2rem;
|
|
162
|
+
color: #374151;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.container {
|
|
166
|
+
max-width: 540px;
|
|
167
|
+
width: 100%;
|
|
168
|
+
text-align: center;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.card {
|
|
172
|
+
background: white;
|
|
173
|
+
border-radius: 16px;
|
|
174
|
+
padding: 3rem 2.5rem;
|
|
175
|
+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.icon {
|
|
179
|
+
width: 80px;
|
|
180
|
+
height: 80px;
|
|
181
|
+
margin: 0 auto 1.5rem;
|
|
182
|
+
background: ${is401 ? '#fef3c7' : '#fee2e2'};
|
|
183
|
+
border-radius: 50%;
|
|
184
|
+
display: flex;
|
|
185
|
+
align-items: center;
|
|
186
|
+
justify-content: center;
|
|
187
|
+
font-size: 2.5rem;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
h1 {
|
|
191
|
+
font-size: 1.75rem;
|
|
192
|
+
font-weight: 600;
|
|
193
|
+
color: #111827;
|
|
194
|
+
margin-bottom: 0.75rem;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.subtitle {
|
|
198
|
+
color: #6b7280;
|
|
199
|
+
font-size: 1.05rem;
|
|
200
|
+
line-height: 1.6;
|
|
201
|
+
margin-bottom: 2rem;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.actions {
|
|
205
|
+
display: flex;
|
|
206
|
+
flex-direction: column;
|
|
207
|
+
gap: 0.75rem;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.btn {
|
|
211
|
+
display: inline-flex;
|
|
212
|
+
align-items: center;
|
|
213
|
+
justify-content: center;
|
|
214
|
+
gap: 0.5rem;
|
|
215
|
+
padding: 0.875rem 1.5rem;
|
|
216
|
+
border-radius: 10px;
|
|
217
|
+
font-size: 1rem;
|
|
218
|
+
font-weight: 500;
|
|
219
|
+
text-decoration: none;
|
|
220
|
+
transition: all 0.2s ease;
|
|
221
|
+
cursor: pointer;
|
|
222
|
+
border: none;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.btn-primary {
|
|
226
|
+
background: linear-gradient(135deg, #7c3aed 0%, #6366f1 100%);
|
|
227
|
+
color: white;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.btn-primary:hover {
|
|
231
|
+
transform: translateY(-1px);
|
|
232
|
+
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.btn-secondary {
|
|
236
|
+
background: #f3f4f6;
|
|
237
|
+
color: #374151;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.btn-secondary:hover {
|
|
241
|
+
background: #e5e7eb;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.divider {
|
|
245
|
+
display: flex;
|
|
246
|
+
align-items: center;
|
|
247
|
+
margin: 2rem 0;
|
|
248
|
+
color: #9ca3af;
|
|
249
|
+
font-size: 0.875rem;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.divider::before,
|
|
253
|
+
.divider::after {
|
|
254
|
+
content: '';
|
|
255
|
+
flex: 1;
|
|
256
|
+
height: 1px;
|
|
257
|
+
background: #e5e7eb;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.divider span {
|
|
261
|
+
padding: 0 1rem;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.info-box {
|
|
265
|
+
background: #f0fdf4;
|
|
266
|
+
border: 1px solid #bbf7d0;
|
|
267
|
+
border-radius: 10px;
|
|
268
|
+
padding: 1.25rem;
|
|
269
|
+
text-align: left;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.info-box h3 {
|
|
273
|
+
font-size: 0.9rem;
|
|
274
|
+
font-weight: 600;
|
|
275
|
+
color: #166534;
|
|
276
|
+
margin-bottom: 0.5rem;
|
|
277
|
+
display: flex;
|
|
278
|
+
align-items: center;
|
|
279
|
+
gap: 0.5rem;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.info-box p {
|
|
283
|
+
font-size: 0.875rem;
|
|
284
|
+
color: #15803d;
|
|
285
|
+
line-height: 1.5;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.footer {
|
|
289
|
+
margin-top: 2rem;
|
|
290
|
+
font-size: 0.8rem;
|
|
291
|
+
color: #9ca3af;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.footer a {
|
|
295
|
+
color: #7c3aed;
|
|
296
|
+
text-decoration: none;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.footer a:hover {
|
|
300
|
+
text-decoration: underline;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.status-code {
|
|
304
|
+
font-size: 0.75rem;
|
|
305
|
+
color: #9ca3af;
|
|
306
|
+
margin-top: 1rem;
|
|
307
|
+
}
|
|
308
|
+
</style>
|
|
309
|
+
</head>
|
|
310
|
+
<body>
|
|
311
|
+
<div class="container">
|
|
312
|
+
<div class="card">
|
|
313
|
+
<div class="icon">${is401 ? '🔐' : '🚫'}</div>
|
|
314
|
+
<h1>${title}</h1>
|
|
315
|
+
<p class="subtitle">${subtitle}</p>
|
|
316
|
+
|
|
317
|
+
<div class="actions">
|
|
318
|
+
${is401 ? `<a href="${baseUrl}/.account/login/password" class="btn btn-primary">
|
|
319
|
+
Sign In
|
|
320
|
+
</a>` : ''}
|
|
321
|
+
<a href="${baseUrl}/" class="btn btn-secondary">
|
|
322
|
+
Go to Homepage
|
|
323
|
+
</a>
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
<div class="divider"><span>What is this?</span></div>
|
|
327
|
+
|
|
328
|
+
<div class="info-box">
|
|
329
|
+
<h3>🏖️ Welcome to Solid</h3>
|
|
330
|
+
<p>
|
|
331
|
+
This is a <strong>Solid Pod</strong> — a personal data store where you control your own data.
|
|
332
|
+
Resources can be private, shared with specific people, or public.
|
|
333
|
+
${is401 ? 'Sign in with your WebID to access protected content.' : 'Ask the owner to grant you access.'}
|
|
334
|
+
</p>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
<p class="status-code">HTTP ${statusCode} • ${request.url}</p>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
<p class="footer">
|
|
341
|
+
Powered by <a href="https://sandy-mount.com">Sandymount</a> •
|
|
342
|
+
<a href="https://solidproject.org">Learn about Solid</a>
|
|
343
|
+
</p>
|
|
344
|
+
</div>
|
|
345
|
+
</body>
|
|
346
|
+
</html>`;
|
|
347
|
+
}
|
|
348
|
+
|
|
123
349
|
/**
|
|
124
350
|
* Authorize access to ACL files
|
|
125
351
|
* ACL files require acl:Control permission on the resource they protect
|
package/src/server.js
CHANGED
|
@@ -316,7 +316,7 @@ export function createServer(options = {}) {
|
|
|
316
316
|
reply.header('WAC-Allow', wacAllow);
|
|
317
317
|
|
|
318
318
|
if (!authorized) {
|
|
319
|
-
return handleUnauthorized(reply, webId !== null, wacAllow, authError);
|
|
319
|
+
return handleUnauthorized(request, reply, webId !== null, wacAllow, authError);
|
|
320
320
|
}
|
|
321
321
|
});
|
|
322
322
|
|