fedbox 0.0.1 → 0.0.4
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/QUICKSTART.md +90 -0
- package/README.md +32 -13
- package/bin/cli.js +252 -23
- package/lib/actions.js +312 -0
- package/lib/server.js +422 -64
- package/package.json +1 -1
package/lib/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fedbox Server
|
|
3
3
|
* ActivityPub server using microfed
|
|
4
|
+
* Solid-compatible URI structure
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { createServer } from 'http'
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
getActivities,
|
|
20
21
|
savePost,
|
|
21
22
|
getPosts,
|
|
23
|
+
getPost,
|
|
22
24
|
cacheActor,
|
|
23
25
|
getCachedActor
|
|
24
26
|
} from './store.js'
|
|
@@ -26,6 +28,11 @@ import {
|
|
|
26
28
|
let config = null
|
|
27
29
|
let actor = null
|
|
28
30
|
|
|
31
|
+
// Rate limiting: track requests per IP
|
|
32
|
+
const rateLimits = new Map()
|
|
33
|
+
const RATE_LIMIT_WINDOW = 60000 // 1 minute
|
|
34
|
+
const RATE_LIMIT_MAX = 100 // max requests per window
|
|
35
|
+
|
|
29
36
|
/**
|
|
30
37
|
* Load configuration
|
|
31
38
|
*/
|
|
@@ -52,32 +59,95 @@ function getProtocol() {
|
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
/**
|
|
55
|
-
*
|
|
62
|
+
* Get base URL
|
|
56
63
|
*/
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const baseUrl = `${protocol}://${domain}`
|
|
64
|
+
function getBaseUrl() {
|
|
65
|
+
return `${getProtocol()}://${getDomain()}`
|
|
66
|
+
}
|
|
61
67
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Build actor object with Solid-compatible URIs
|
|
70
|
+
* /alice is the profile document
|
|
71
|
+
* /alice#me is the WebID (the Person)
|
|
72
|
+
*/
|
|
73
|
+
function buildActor() {
|
|
74
|
+
const baseUrl = getBaseUrl()
|
|
75
|
+
const profileUrl = `${baseUrl}/${config.username}`
|
|
76
|
+
const actorId = `${profileUrl}#me`
|
|
77
|
+
|
|
78
|
+
// Build actor manually for more control over structure
|
|
79
|
+
return {
|
|
80
|
+
'@context': [
|
|
81
|
+
'https://www.w3.org/ns/activitystreams',
|
|
82
|
+
'https://w3id.org/security/v1'
|
|
83
|
+
],
|
|
84
|
+
type: 'Person',
|
|
85
|
+
id: actorId,
|
|
86
|
+
url: profileUrl,
|
|
87
|
+
preferredUsername: config.username,
|
|
65
88
|
name: config.displayName,
|
|
66
89
|
summary: config.summary ? `<p>${config.summary}</p>` : '',
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
90
|
+
inbox: `${profileUrl}/inbox`,
|
|
91
|
+
outbox: `${profileUrl}/outbox`,
|
|
92
|
+
followers: `${profileUrl}/followers`,
|
|
93
|
+
following: `${profileUrl}/following`,
|
|
94
|
+
endpoints: {
|
|
95
|
+
sharedInbox: `${baseUrl}/inbox`
|
|
96
|
+
},
|
|
97
|
+
publicKey: {
|
|
98
|
+
id: `${profileUrl}#main-key`,
|
|
99
|
+
owner: actorId,
|
|
100
|
+
publicKeyPem: config.publicKey
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check rate limit
|
|
107
|
+
*/
|
|
108
|
+
function checkRateLimit(ip) {
|
|
109
|
+
const now = Date.now()
|
|
110
|
+
const record = rateLimits.get(ip)
|
|
111
|
+
|
|
112
|
+
if (!record || now - record.start > RATE_LIMIT_WINDOW) {
|
|
113
|
+
rateLimits.set(ip, { start: now, count: 1 })
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
record.count++
|
|
118
|
+
if (record.count > RATE_LIMIT_MAX) {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return true
|
|
70
123
|
}
|
|
71
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Clean old rate limit entries (run periodically)
|
|
127
|
+
*/
|
|
128
|
+
function cleanRateLimits() {
|
|
129
|
+
const now = Date.now()
|
|
130
|
+
for (const [ip, record] of rateLimits) {
|
|
131
|
+
if (now - record.start > RATE_LIMIT_WINDOW) {
|
|
132
|
+
rateLimits.delete(ip)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Clean rate limits every minute
|
|
138
|
+
setInterval(cleanRateLimits, RATE_LIMIT_WINDOW)
|
|
139
|
+
|
|
72
140
|
/**
|
|
73
141
|
* Fetch remote actor (with caching)
|
|
74
142
|
*/
|
|
75
143
|
async function fetchActor(id) {
|
|
144
|
+
// Strip fragment for fetching
|
|
145
|
+
const fetchUrl = id.replace(/#.*$/, '')
|
|
76
146
|
const cached = getCachedActor(id)
|
|
77
147
|
if (cached) return cached
|
|
78
148
|
|
|
79
149
|
try {
|
|
80
|
-
const response = await fetch(
|
|
150
|
+
const response = await fetch(fetchUrl, {
|
|
81
151
|
headers: { 'Accept': 'application/activity+json' }
|
|
82
152
|
})
|
|
83
153
|
if (!response.ok) return null
|
|
@@ -90,6 +160,65 @@ async function fetchActor(id) {
|
|
|
90
160
|
}
|
|
91
161
|
}
|
|
92
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Verify HTTP signature on incoming request
|
|
165
|
+
*/
|
|
166
|
+
async function verifySignature(req, body) {
|
|
167
|
+
const signature = req.headers['signature']
|
|
168
|
+
if (!signature) {
|
|
169
|
+
return { valid: false, reason: 'No signature header' }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Parse signature header
|
|
173
|
+
const sigParts = {}
|
|
174
|
+
signature.split(',').forEach(part => {
|
|
175
|
+
const [key, ...rest] = part.split('=')
|
|
176
|
+
sigParts[key.trim()] = rest.join('=').replace(/^"|"$/g, '')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const keyId = sigParts.keyId
|
|
180
|
+
if (!keyId) {
|
|
181
|
+
return { valid: false, reason: 'No keyId in signature' }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Extract actor URL from keyId (strip fragment like #main-key)
|
|
185
|
+
const actorUrl = keyId.replace(/#.*$/, '')
|
|
186
|
+
|
|
187
|
+
// Fetch the actor to get their public key
|
|
188
|
+
const remoteActor = await fetchActor(actorUrl)
|
|
189
|
+
if (!remoteActor) {
|
|
190
|
+
return { valid: false, reason: `Could not fetch actor: ${actorUrl}` }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const publicKeyPem = remoteActor.publicKey?.publicKeyPem
|
|
194
|
+
if (!publicKeyPem) {
|
|
195
|
+
return { valid: false, reason: 'Actor has no public key' }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Build the signing string
|
|
199
|
+
const headers = sigParts.headers?.split(' ') || ['(request-target)', 'host', 'date']
|
|
200
|
+
const signingParts = headers.map(header => {
|
|
201
|
+
if (header === '(request-target)') {
|
|
202
|
+
return `(request-target): ${req.method.toLowerCase()} ${req.url}`
|
|
203
|
+
}
|
|
204
|
+
if (header === 'digest' && body) {
|
|
205
|
+
const crypto = require('crypto')
|
|
206
|
+
const digest = crypto.createHash('sha256').update(body).digest('base64')
|
|
207
|
+
return `digest: SHA-256=${digest}`
|
|
208
|
+
}
|
|
209
|
+
return `${header}: ${req.headers[header.toLowerCase()] || ''}`
|
|
210
|
+
})
|
|
211
|
+
const signingString = signingParts.join('\n')
|
|
212
|
+
|
|
213
|
+
// Verify the signature
|
|
214
|
+
try {
|
|
215
|
+
const isValid = auth.verify(signingString, sigParts.signature, publicKeyPem)
|
|
216
|
+
return { valid: isValid, actor: remoteActor }
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return { valid: false, reason: `Verification error: ${err.message}` }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
93
222
|
/**
|
|
94
223
|
* Handle incoming activities
|
|
95
224
|
*/
|
|
@@ -108,7 +237,7 @@ async function handleActivity(activity) {
|
|
|
108
237
|
handleAccept(activity)
|
|
109
238
|
break
|
|
110
239
|
case 'Create':
|
|
111
|
-
console.log(` New post: ${activity.object?.content?.slice(0, 50)}...`)
|
|
240
|
+
console.log(` 📝 New post: ${activity.object?.content?.slice(0, 50)}...`)
|
|
112
241
|
break
|
|
113
242
|
case 'Like':
|
|
114
243
|
console.log(` ❤️ Liked: ${activity.object}`)
|
|
@@ -141,7 +270,7 @@ async function handleFollow(activity) {
|
|
|
141
270
|
activity: accept,
|
|
142
271
|
inbox: followerActor.inbox,
|
|
143
272
|
privateKey: config.privateKey,
|
|
144
|
-
keyId: `${
|
|
273
|
+
keyId: `${getBaseUrl()}/${config.username}#main-key`
|
|
145
274
|
})
|
|
146
275
|
console.log(` 📤 Sent Accept to ${followerActor.inbox}`)
|
|
147
276
|
} catch (err) {
|
|
@@ -173,7 +302,19 @@ function handleAccept(activity) {
|
|
|
173
302
|
* Request handler
|
|
174
303
|
*/
|
|
175
304
|
async function handleRequest(req, res) {
|
|
176
|
-
|
|
305
|
+
// Get client IP
|
|
306
|
+
const ip = req.headers['x-forwarded-for']?.split(',')[0] ||
|
|
307
|
+
req.socket.remoteAddress ||
|
|
308
|
+
'unknown'
|
|
309
|
+
|
|
310
|
+
// Check rate limit
|
|
311
|
+
if (!checkRateLimit(ip)) {
|
|
312
|
+
console.log(`🚫 Rate limited: ${ip}`)
|
|
313
|
+
res.writeHead(429, { 'Retry-After': '60' })
|
|
314
|
+
return res.end('Too many requests')
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const url = new URL(req.url, getBaseUrl())
|
|
177
318
|
const path = url.pathname
|
|
178
319
|
const accept = req.headers.accept || ''
|
|
179
320
|
const isAP = accept.includes('activity+json') || accept.includes('ld+json')
|
|
@@ -200,62 +341,112 @@ async function handleRequest(req, res) {
|
|
|
200
341
|
return res.end('Not found')
|
|
201
342
|
}
|
|
202
343
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
344
|
+
// Return /alice#me as the actor (Solid-compatible WebID)
|
|
345
|
+
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
346
|
+
const response = {
|
|
347
|
+
subject: `acct:${config.username}@${getDomain()}`,
|
|
348
|
+
links: [
|
|
349
|
+
{
|
|
350
|
+
rel: 'self',
|
|
351
|
+
type: 'application/activity+json',
|
|
352
|
+
href: `${profileUrl}#me`
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
rel: 'http://webfinger.net/rel/profile-page',
|
|
356
|
+
type: 'text/html',
|
|
357
|
+
href: profileUrl
|
|
358
|
+
}
|
|
359
|
+
]
|
|
360
|
+
}
|
|
208
361
|
|
|
209
362
|
res.setHeader('Content-Type', 'application/jrd+json')
|
|
210
363
|
return res.end(JSON.stringify(response, null, 2))
|
|
211
364
|
}
|
|
212
365
|
|
|
213
|
-
//
|
|
214
|
-
if (path ===
|
|
215
|
-
|
|
216
|
-
|
|
366
|
+
// Nodeinfo discovery
|
|
367
|
+
if (path === '/.well-known/nodeinfo') {
|
|
368
|
+
const response = {
|
|
369
|
+
links: [
|
|
370
|
+
{
|
|
371
|
+
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
|
|
372
|
+
href: `${getBaseUrl()}/nodeinfo/2.1`
|
|
373
|
+
}
|
|
374
|
+
]
|
|
375
|
+
}
|
|
376
|
+
res.setHeader('Content-Type', 'application/json')
|
|
377
|
+
return res.end(JSON.stringify(response, null, 2))
|
|
217
378
|
}
|
|
218
379
|
|
|
219
|
-
//
|
|
220
|
-
if (path ===
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
380
|
+
// Nodeinfo 2.1
|
|
381
|
+
if (path === '/nodeinfo/2.1') {
|
|
382
|
+
const response = {
|
|
383
|
+
version: '2.1',
|
|
384
|
+
software: {
|
|
385
|
+
name: 'fedbox',
|
|
386
|
+
version: '0.0.4',
|
|
387
|
+
repository: 'https://github.com/micro-fed/fedbox'
|
|
388
|
+
},
|
|
389
|
+
protocols: ['activitypub'],
|
|
390
|
+
services: { inbound: [], outbound: [] },
|
|
391
|
+
usage: {
|
|
392
|
+
users: { total: 1, activeMonth: 1, activeHalfyear: 1 },
|
|
393
|
+
localPosts: getPosts(1000).length
|
|
394
|
+
},
|
|
395
|
+
openRegistrations: false,
|
|
396
|
+
metadata: {
|
|
397
|
+
nodeName: config.displayName || config.username,
|
|
398
|
+
nodeDescription: config.summary || 'A Fedbox instance'
|
|
399
|
+
}
|
|
224
400
|
}
|
|
401
|
+
res.setHeader('Content-Type', 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"')
|
|
402
|
+
return res.end(JSON.stringify(response, null, 2))
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Shared inbox
|
|
406
|
+
if (path === '/inbox') {
|
|
407
|
+
return handleInbox(req, res)
|
|
408
|
+
}
|
|
225
409
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
console.error('Inbox error:', err)
|
|
237
|
-
res.writeHead(400)
|
|
238
|
-
return res.end('Bad request')
|
|
410
|
+
// Profile routes: /alice, /alice/inbox, /alice/outbox, etc.
|
|
411
|
+
if (path === `/${config.username}`) {
|
|
412
|
+
if (isAP) {
|
|
413
|
+
// Return Actor JSON-LD
|
|
414
|
+
res.setHeader('Content-Type', 'application/activity+json')
|
|
415
|
+
return res.end(JSON.stringify(actor, null, 2))
|
|
416
|
+
} else {
|
|
417
|
+
// Return HTML with embedded JSON-LD
|
|
418
|
+
res.setHeader('Content-Type', 'text/html')
|
|
419
|
+
return res.end(renderProfile())
|
|
239
420
|
}
|
|
240
421
|
}
|
|
241
422
|
|
|
423
|
+
// Inbox
|
|
424
|
+
if (path === `/${config.username}/inbox`) {
|
|
425
|
+
return handleInbox(req, res)
|
|
426
|
+
}
|
|
427
|
+
|
|
242
428
|
// Outbox
|
|
243
|
-
if (path ===
|
|
429
|
+
if (path === `/${config.username}/outbox`) {
|
|
244
430
|
const posts = getPosts(20)
|
|
431
|
+
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
245
432
|
const collection = {
|
|
246
433
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
247
434
|
type: 'OrderedCollection',
|
|
248
|
-
id: `${
|
|
435
|
+
id: `${profileUrl}/outbox`,
|
|
249
436
|
totalItems: posts.length,
|
|
250
437
|
orderedItems: posts.map(p => ({
|
|
251
438
|
type: 'Create',
|
|
252
|
-
actor:
|
|
439
|
+
actor: `${profileUrl}#me`,
|
|
440
|
+
published: p.published,
|
|
253
441
|
object: {
|
|
254
442
|
type: 'Note',
|
|
255
443
|
id: p.id,
|
|
256
444
|
content: p.content,
|
|
257
445
|
published: p.published,
|
|
258
|
-
attributedTo:
|
|
446
|
+
attributedTo: `${profileUrl}#me`,
|
|
447
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
448
|
+
cc: [`${profileUrl}/followers`],
|
|
449
|
+
...(p.in_reply_to ? { inReplyTo: p.in_reply_to } : {})
|
|
259
450
|
}
|
|
260
451
|
}))
|
|
261
452
|
}
|
|
@@ -264,12 +455,13 @@ async function handleRequest(req, res) {
|
|
|
264
455
|
}
|
|
265
456
|
|
|
266
457
|
// Followers
|
|
267
|
-
if (path ===
|
|
458
|
+
if (path === `/${config.username}/followers`) {
|
|
268
459
|
const followers = getFollowers()
|
|
460
|
+
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
269
461
|
const collection = {
|
|
270
462
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
271
463
|
type: 'OrderedCollection',
|
|
272
|
-
id: `${
|
|
464
|
+
id: `${profileUrl}/followers`,
|
|
273
465
|
totalItems: followers.length,
|
|
274
466
|
orderedItems: followers.map(f => f.actor)
|
|
275
467
|
}
|
|
@@ -278,11 +470,12 @@ async function handleRequest(req, res) {
|
|
|
278
470
|
}
|
|
279
471
|
|
|
280
472
|
// Following
|
|
281
|
-
if (path ===
|
|
473
|
+
if (path === `/${config.username}/following`) {
|
|
474
|
+
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
282
475
|
const collection = {
|
|
283
476
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
284
477
|
type: 'OrderedCollection',
|
|
285
|
-
id: `${
|
|
478
|
+
id: `${profileUrl}/following`,
|
|
286
479
|
totalItems: getFollowingCount(),
|
|
287
480
|
orderedItems: []
|
|
288
481
|
}
|
|
@@ -290,10 +483,38 @@ async function handleRequest(req, res) {
|
|
|
290
483
|
return res.end(JSON.stringify(collection, null, 2))
|
|
291
484
|
}
|
|
292
485
|
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
486
|
+
// Individual post: /alice/posts/123
|
|
487
|
+
const postMatch = path.match(new RegExp(`^/${config.username}/posts/(\\d+)$`))
|
|
488
|
+
if (postMatch) {
|
|
489
|
+
const postId = `${getBaseUrl()}${path}`
|
|
490
|
+
const post = getPost(postId)
|
|
491
|
+
if (!post) {
|
|
492
|
+
res.writeHead(404)
|
|
493
|
+
return res.end('Not found')
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
497
|
+
const note = {
|
|
498
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
499
|
+
type: 'Note',
|
|
500
|
+
id: post.id,
|
|
501
|
+
attributedTo: `${profileUrl}#me`,
|
|
502
|
+
content: post.content,
|
|
503
|
+
published: post.published,
|
|
504
|
+
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
|
505
|
+
cc: [`${profileUrl}/followers`]
|
|
506
|
+
}
|
|
507
|
+
if (post.in_reply_to) {
|
|
508
|
+
note.inReplyTo = post.in_reply_to
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (isAP) {
|
|
512
|
+
res.setHeader('Content-Type', 'application/activity+json')
|
|
513
|
+
return res.end(JSON.stringify(note, null, 2))
|
|
514
|
+
} else {
|
|
515
|
+
res.setHeader('Content-Type', 'text/html')
|
|
516
|
+
return res.end(renderPost(post, note))
|
|
517
|
+
}
|
|
297
518
|
}
|
|
298
519
|
|
|
299
520
|
// Home
|
|
@@ -307,11 +528,46 @@ async function handleRequest(req, res) {
|
|
|
307
528
|
}
|
|
308
529
|
|
|
309
530
|
/**
|
|
310
|
-
*
|
|
531
|
+
* Handle inbox POST
|
|
532
|
+
*/
|
|
533
|
+
async function handleInbox(req, res) {
|
|
534
|
+
if (req.method !== 'POST') {
|
|
535
|
+
res.writeHead(405)
|
|
536
|
+
return res.end('Method not allowed')
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const chunks = []
|
|
540
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
541
|
+
const body = Buffer.concat(chunks).toString()
|
|
542
|
+
|
|
543
|
+
// Verify signature
|
|
544
|
+
const sigResult = await verifySignature(req, body)
|
|
545
|
+
if (!sigResult.valid) {
|
|
546
|
+
console.log(` ⚠️ Signature: ${sigResult.reason}`)
|
|
547
|
+
} else {
|
|
548
|
+
console.log(` 🔐 Signature verified`)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
const activity = JSON.parse(body)
|
|
553
|
+
await handleActivity(activity)
|
|
554
|
+
res.writeHead(202)
|
|
555
|
+
return res.end()
|
|
556
|
+
} catch (err) {
|
|
557
|
+
console.error('Inbox error:', err)
|
|
558
|
+
res.writeHead(400)
|
|
559
|
+
return res.end('Bad request')
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Render HTML profile with embedded JSON-LD
|
|
311
565
|
*/
|
|
312
566
|
function renderProfile() {
|
|
313
567
|
const followers = getFollowerCount()
|
|
314
568
|
const following = getFollowingCount()
|
|
569
|
+
const posts = getPosts(10)
|
|
570
|
+
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
315
571
|
|
|
316
572
|
return `<!DOCTYPE html>
|
|
317
573
|
<html>
|
|
@@ -319,6 +575,10 @@ function renderProfile() {
|
|
|
319
575
|
<meta charset="utf-8">
|
|
320
576
|
<title>${config.displayName} (@${config.username}@${getDomain()})</title>
|
|
321
577
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
578
|
+
<link rel="alternate" type="application/activity+json" href="${profileUrl}">
|
|
579
|
+
<script type="application/ld+json">
|
|
580
|
+
${JSON.stringify(actor, null, 2)}
|
|
581
|
+
</script>
|
|
322
582
|
<style>
|
|
323
583
|
* { box-sizing: border-box; }
|
|
324
584
|
body {
|
|
@@ -335,6 +595,7 @@ function renderProfile() {
|
|
|
335
595
|
border-radius: 16px;
|
|
336
596
|
padding: 2rem;
|
|
337
597
|
text-align: center;
|
|
598
|
+
margin-bottom: 1.5rem;
|
|
338
599
|
}
|
|
339
600
|
.avatar {
|
|
340
601
|
width: 120px;
|
|
@@ -363,11 +624,21 @@ function renderProfile() {
|
|
|
363
624
|
margin-top: 1.5rem;
|
|
364
625
|
font-size: 0.9rem;
|
|
365
626
|
}
|
|
627
|
+
.posts { margin-top: 1rem; }
|
|
628
|
+
.post {
|
|
629
|
+
background: #16213e;
|
|
630
|
+
border-radius: 12px;
|
|
631
|
+
padding: 1rem;
|
|
632
|
+
margin-bottom: 1rem;
|
|
633
|
+
}
|
|
634
|
+
.post-content { margin-bottom: 0.5rem; }
|
|
635
|
+
.post-meta { color: #666; font-size: 0.8rem; }
|
|
636
|
+
.post a { color: #667eea; text-decoration: none; }
|
|
366
637
|
</style>
|
|
367
638
|
</head>
|
|
368
639
|
<body>
|
|
369
640
|
<div class="card">
|
|
370
|
-
<div class="avatar"
|
|
641
|
+
<div class="avatar">📦</div>
|
|
371
642
|
<h1>${config.displayName}</h1>
|
|
372
643
|
<p class="handle">@${config.username}@${getDomain()}</p>
|
|
373
644
|
${config.summary ? `<p class="bio">${config.summary}</p>` : ''}
|
|
@@ -380,9 +651,94 @@ function renderProfile() {
|
|
|
380
651
|
<div class="stat-num">${following}</div>
|
|
381
652
|
<div class="stat-label">Following</div>
|
|
382
653
|
</div>
|
|
654
|
+
<div class="stat">
|
|
655
|
+
<div class="stat-num">${posts.length}</div>
|
|
656
|
+
<div class="stat-label">Posts</div>
|
|
657
|
+
</div>
|
|
383
658
|
</div>
|
|
384
659
|
<div class="badge">📦 Powered by Fedbox</div>
|
|
385
660
|
</div>
|
|
661
|
+
|
|
662
|
+
${posts.length > 0 ? `
|
|
663
|
+
<div class="posts">
|
|
664
|
+
${posts.map(p => `
|
|
665
|
+
<div class="post">
|
|
666
|
+
<div class="post-content">${p.content}</div>
|
|
667
|
+
<div class="post-meta">
|
|
668
|
+
<a href="${p.id}">${new Date(p.published).toLocaleString()}</a>
|
|
669
|
+
</div>
|
|
670
|
+
</div>
|
|
671
|
+
`).join('')}
|
|
672
|
+
</div>
|
|
673
|
+
` : ''}
|
|
674
|
+
</body>
|
|
675
|
+
</html>`
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Render individual post with embedded JSON-LD
|
|
680
|
+
*/
|
|
681
|
+
function renderPost(post, note) {
|
|
682
|
+
return `<!DOCTYPE html>
|
|
683
|
+
<html>
|
|
684
|
+
<head>
|
|
685
|
+
<meta charset="utf-8">
|
|
686
|
+
<title>Post by ${config.displayName}</title>
|
|
687
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
688
|
+
<link rel="alternate" type="application/activity+json" href="${post.id}">
|
|
689
|
+
<script type="application/ld+json">
|
|
690
|
+
${JSON.stringify(note, null, 2)}
|
|
691
|
+
</script>
|
|
692
|
+
<style>
|
|
693
|
+
* { box-sizing: border-box; }
|
|
694
|
+
body {
|
|
695
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
696
|
+
max-width: 600px;
|
|
697
|
+
margin: 0 auto;
|
|
698
|
+
padding: 2rem;
|
|
699
|
+
background: #1a1a2e;
|
|
700
|
+
color: #eee;
|
|
701
|
+
min-height: 100vh;
|
|
702
|
+
}
|
|
703
|
+
.post {
|
|
704
|
+
background: #16213e;
|
|
705
|
+
border-radius: 16px;
|
|
706
|
+
padding: 2rem;
|
|
707
|
+
}
|
|
708
|
+
.author {
|
|
709
|
+
display: flex;
|
|
710
|
+
align-items: center;
|
|
711
|
+
gap: 1rem;
|
|
712
|
+
margin-bottom: 1rem;
|
|
713
|
+
}
|
|
714
|
+
.avatar {
|
|
715
|
+
width: 48px;
|
|
716
|
+
height: 48px;
|
|
717
|
+
border-radius: 50%;
|
|
718
|
+
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
719
|
+
display: flex;
|
|
720
|
+
align-items: center;
|
|
721
|
+
justify-content: center;
|
|
722
|
+
font-size: 1.5rem;
|
|
723
|
+
}
|
|
724
|
+
.author-info a { color: #667eea; text-decoration: none; }
|
|
725
|
+
.author-info p { margin: 0; color: #888; font-size: 0.9rem; }
|
|
726
|
+
.content { font-size: 1.2rem; line-height: 1.6; margin: 1rem 0; }
|
|
727
|
+
.meta { color: #666; font-size: 0.9rem; }
|
|
728
|
+
</style>
|
|
729
|
+
</head>
|
|
730
|
+
<body>
|
|
731
|
+
<div class="post">
|
|
732
|
+
<div class="author">
|
|
733
|
+
<div class="avatar">📦</div>
|
|
734
|
+
<div class="author-info">
|
|
735
|
+
<a href="/${config.username}">${config.displayName}</a>
|
|
736
|
+
<p>@${config.username}@${getDomain()}</p>
|
|
737
|
+
</div>
|
|
738
|
+
</div>
|
|
739
|
+
<div class="content">${post.content}</div>
|
|
740
|
+
<div class="meta">${new Date(post.published).toLocaleString()}</div>
|
|
741
|
+
</div>
|
|
386
742
|
</body>
|
|
387
743
|
</html>`
|
|
388
744
|
}
|
|
@@ -391,6 +747,7 @@ function renderProfile() {
|
|
|
391
747
|
* Render home page
|
|
392
748
|
*/
|
|
393
749
|
function renderHome() {
|
|
750
|
+
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
394
751
|
return `<!DOCTYPE html>
|
|
395
752
|
<html>
|
|
396
753
|
<head>
|
|
@@ -418,15 +775,16 @@ function renderHome() {
|
|
|
418
775
|
<p>Your Fediverse server is running!</p>
|
|
419
776
|
|
|
420
777
|
<h2>Your Profile</h2>
|
|
421
|
-
<p><a href="
|
|
778
|
+
<p><a href="/${config.username}">@${config.username}@${getDomain()}</a></p>
|
|
422
779
|
|
|
423
780
|
<h2>Endpoints</h2>
|
|
424
781
|
<ul class="endpoints">
|
|
425
782
|
<li><code>/.well-known/webfinger</code> - Discovery</li>
|
|
426
|
-
<li><code
|
|
427
|
-
<li><code
|
|
428
|
-
<li><code
|
|
429
|
-
<li><code
|
|
783
|
+
<li><code>/${config.username}</code> - Profile (HTML + JSON-LD)</li>
|
|
784
|
+
<li><code>/${config.username}#me</code> - WebID (Actor)</li>
|
|
785
|
+
<li><code>/${config.username}/inbox</code> - Inbox</li>
|
|
786
|
+
<li><code>/${config.username}/outbox</code> - Outbox</li>
|
|
787
|
+
<li><code>/${config.username}/followers</code> - Followers</li>
|
|
430
788
|
</ul>
|
|
431
789
|
|
|
432
790
|
<h2>Federation</h2>
|
|
@@ -451,10 +809,10 @@ export async function startServer() {
|
|
|
451
809
|
console.log(`
|
|
452
810
|
📦 Fedbox is running!
|
|
453
811
|
|
|
454
|
-
Profile: http://localhost:${config.port}
|
|
455
|
-
|
|
812
|
+
Profile: http://localhost:${config.port}/${config.username}
|
|
813
|
+
WebID: http://localhost:${config.port}/${config.username}#me
|
|
456
814
|
|
|
457
|
-
${config.domain ? ` Federated: https://${config.domain}
|
|
815
|
+
${config.domain ? ` Federated: https://${config.domain}/${config.username}` : ' ⚠️ Set "domain" in fedbox.json for federation'}
|
|
458
816
|
|
|
459
817
|
Press Ctrl+C to stop
|
|
460
818
|
`)
|