fedbox 0.0.6 → 0.0.8
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/bin/cli.js +21 -1
- package/lib/profile-server.js +446 -0
- package/lib/server.js +322 -8
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -28,6 +28,7 @@ const BANNER = `
|
|
|
28
28
|
const COMMANDS = {
|
|
29
29
|
init: runInit,
|
|
30
30
|
start: runStart,
|
|
31
|
+
profile: runProfile,
|
|
31
32
|
status: runStatus,
|
|
32
33
|
post: runPost,
|
|
33
34
|
follow: runFollow,
|
|
@@ -122,6 +123,20 @@ async function runStart() {
|
|
|
122
123
|
await startServer()
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
async function runProfile() {
|
|
127
|
+
if (!existsSync('fedbox.json')) {
|
|
128
|
+
console.log('❌ Not initialized. Run: fedbox init\n')
|
|
129
|
+
process.exit(1)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const config = JSON.parse(readFileSync('fedbox.json', 'utf8'))
|
|
133
|
+
const port = process.argv[3] || config.port || 3000
|
|
134
|
+
console.log(`🪪 Starting profile server on port ${port}...\n`)
|
|
135
|
+
|
|
136
|
+
const { startProfileServer } = await import('../lib/profile-server.js')
|
|
137
|
+
await startProfileServer(parseInt(port))
|
|
138
|
+
}
|
|
139
|
+
|
|
125
140
|
async function runStatus() {
|
|
126
141
|
if (!existsSync('fedbox.json')) {
|
|
127
142
|
console.log('❌ Not initialized. Run: fedbox init\n')
|
|
@@ -372,7 +387,8 @@ Usage: fedbox <command> [args]
|
|
|
372
387
|
|
|
373
388
|
Setup:
|
|
374
389
|
init Set up a new Fediverse identity
|
|
375
|
-
start Start the server
|
|
390
|
+
start Start the full ActivityPub server
|
|
391
|
+
profile [port] Start profile-only server (minimal, edit via web)
|
|
376
392
|
status Show current configuration
|
|
377
393
|
|
|
378
394
|
Social:
|
|
@@ -392,6 +408,10 @@ Quick start:
|
|
|
392
408
|
$ fedbox post "Hello, Fediverse!"
|
|
393
409
|
$ fedbox follow @user@mastodon.social
|
|
394
410
|
|
|
411
|
+
Profile only (for Solid/WebID testing):
|
|
412
|
+
$ fedbox profile
|
|
413
|
+
Then visit http://localhost:3000/ and click Edit
|
|
414
|
+
|
|
395
415
|
For federation (so Mastodon can find you):
|
|
396
416
|
$ ngrok http 3000
|
|
397
417
|
Then update fedbox.json with your ngrok domain
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fedbox Profile Server
|
|
3
|
+
* Minimal server that just serves the profile
|
|
4
|
+
* Great for testing, Solid integration, or static-like deployment
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createServer } from 'http'
|
|
8
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs'
|
|
9
|
+
import { join, extname } from 'path'
|
|
10
|
+
|
|
11
|
+
let config = null
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load configuration
|
|
15
|
+
*/
|
|
16
|
+
function loadConfig() {
|
|
17
|
+
if (!existsSync('fedbox.json')) {
|
|
18
|
+
throw new Error('Not initialized. Run: fedbox init')
|
|
19
|
+
}
|
|
20
|
+
config = JSON.parse(readFileSync('fedbox.json', 'utf8'))
|
|
21
|
+
return config
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get base URL
|
|
26
|
+
*/
|
|
27
|
+
function getBaseUrl(port) {
|
|
28
|
+
if (config.domain) {
|
|
29
|
+
return `https://${config.domain}`
|
|
30
|
+
}
|
|
31
|
+
return `http://localhost:${port}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build actor object
|
|
36
|
+
*/
|
|
37
|
+
function buildActor(baseUrl) {
|
|
38
|
+
const profileUrl = `${baseUrl}/${config.username}`
|
|
39
|
+
const actorId = `${profileUrl}#me`
|
|
40
|
+
|
|
41
|
+
const actor = {
|
|
42
|
+
'@context': [
|
|
43
|
+
'https://www.w3.org/ns/activitystreams',
|
|
44
|
+
'https://w3id.org/security/v1'
|
|
45
|
+
],
|
|
46
|
+
type: 'Person',
|
|
47
|
+
id: actorId,
|
|
48
|
+
url: profileUrl,
|
|
49
|
+
preferredUsername: config.username,
|
|
50
|
+
name: config.displayName,
|
|
51
|
+
summary: config.summary ? `<p>${config.summary}</p>` : '',
|
|
52
|
+
inbox: `${profileUrl}/inbox`,
|
|
53
|
+
outbox: `${profileUrl}/outbox`,
|
|
54
|
+
followers: `${profileUrl}/followers`,
|
|
55
|
+
following: `${profileUrl}/following`,
|
|
56
|
+
endpoints: {
|
|
57
|
+
sharedInbox: `${baseUrl}/inbox`
|
|
58
|
+
},
|
|
59
|
+
publicKey: {
|
|
60
|
+
id: `${profileUrl}#main-key`,
|
|
61
|
+
owner: actorId,
|
|
62
|
+
publicKeyPem: config.publicKey
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (config.avatar) {
|
|
67
|
+
actor.icon = {
|
|
68
|
+
type: 'Image',
|
|
69
|
+
mediaType: config.avatar.endsWith('.png') ? 'image/png' :
|
|
70
|
+
config.avatar.endsWith('.gif') ? 'image/gif' : 'image/jpeg',
|
|
71
|
+
url: `${baseUrl}/public/${config.avatar}`
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Add alsoKnownAs for identity linking (Nostr, etc.)
|
|
76
|
+
const alsoKnownAs = []
|
|
77
|
+
if (config.nostrPubkey) {
|
|
78
|
+
alsoKnownAs.push(`did:nostr:${config.nostrPubkey}`)
|
|
79
|
+
}
|
|
80
|
+
if (alsoKnownAs.length > 0) {
|
|
81
|
+
actor.alsoKnownAs = alsoKnownAs
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return actor
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Render profile HTML
|
|
89
|
+
*/
|
|
90
|
+
function renderProfile(actor, baseUrl) {
|
|
91
|
+
const avatarUrl = config.avatar ? `/public/${config.avatar}` : ''
|
|
92
|
+
|
|
93
|
+
return `<!DOCTYPE html>
|
|
94
|
+
<html>
|
|
95
|
+
<head>
|
|
96
|
+
<meta charset="utf-8">
|
|
97
|
+
<title>${config.displayName} (@${config.username})</title>
|
|
98
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
99
|
+
<link rel="alternate" type="application/activity+json" href="${actor.url}">
|
|
100
|
+
<script type="application/ld+json" id="profile">
|
|
101
|
+
${JSON.stringify(actor, null, 2)}
|
|
102
|
+
</script>
|
|
103
|
+
<style>
|
|
104
|
+
* { box-sizing: border-box; }
|
|
105
|
+
body {
|
|
106
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
107
|
+
max-width: 600px;
|
|
108
|
+
margin: 0 auto;
|
|
109
|
+
padding: 2rem;
|
|
110
|
+
background: #1a1a2e;
|
|
111
|
+
color: #eee;
|
|
112
|
+
min-height: 100vh;
|
|
113
|
+
}
|
|
114
|
+
.card {
|
|
115
|
+
background: #16213e;
|
|
116
|
+
border-radius: 16px;
|
|
117
|
+
padding: 2rem;
|
|
118
|
+
text-align: center;
|
|
119
|
+
position: relative;
|
|
120
|
+
}
|
|
121
|
+
.edit-btn {
|
|
122
|
+
position: absolute;
|
|
123
|
+
top: 1rem;
|
|
124
|
+
right: 1rem;
|
|
125
|
+
background: #667eea;
|
|
126
|
+
color: white;
|
|
127
|
+
border: none;
|
|
128
|
+
padding: 0.5rem 1rem;
|
|
129
|
+
border-radius: 8px;
|
|
130
|
+
cursor: pointer;
|
|
131
|
+
font-size: 0.9rem;
|
|
132
|
+
}
|
|
133
|
+
.edit-btn:hover { background: #5a6fd6; }
|
|
134
|
+
.avatar {
|
|
135
|
+
width: 120px;
|
|
136
|
+
height: 120px;
|
|
137
|
+
border-radius: 50%;
|
|
138
|
+
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
139
|
+
margin: 0 auto 1rem;
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
justify-content: center;
|
|
143
|
+
font-size: 3rem;
|
|
144
|
+
overflow: hidden;
|
|
145
|
+
cursor: pointer;
|
|
146
|
+
position: relative;
|
|
147
|
+
}
|
|
148
|
+
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
|
149
|
+
.avatar-overlay {
|
|
150
|
+
display: none;
|
|
151
|
+
position: absolute;
|
|
152
|
+
inset: 0;
|
|
153
|
+
background: rgba(0,0,0,0.5);
|
|
154
|
+
align-items: center;
|
|
155
|
+
justify-content: center;
|
|
156
|
+
font-size: 1rem;
|
|
157
|
+
}
|
|
158
|
+
.editing .avatar-overlay { display: flex; }
|
|
159
|
+
.avatar-input { display: none; }
|
|
160
|
+
h1 { margin: 0 0 0.5rem; }
|
|
161
|
+
.handle { color: #888; margin-bottom: 1rem; }
|
|
162
|
+
.bio { color: #aaa; margin-bottom: 1.5rem; }
|
|
163
|
+
.badge {
|
|
164
|
+
display: inline-block;
|
|
165
|
+
background: #667eea;
|
|
166
|
+
color: white;
|
|
167
|
+
padding: 0.5rem 1rem;
|
|
168
|
+
border-radius: 20px;
|
|
169
|
+
margin-top: 1.5rem;
|
|
170
|
+
font-size: 0.9rem;
|
|
171
|
+
}
|
|
172
|
+
.edit-input {
|
|
173
|
+
background: #0f172a;
|
|
174
|
+
border: 1px solid #667eea;
|
|
175
|
+
color: #eee;
|
|
176
|
+
padding: 0.5rem;
|
|
177
|
+
border-radius: 8px;
|
|
178
|
+
font-size: inherit;
|
|
179
|
+
text-align: center;
|
|
180
|
+
width: 100%;
|
|
181
|
+
max-width: 300px;
|
|
182
|
+
}
|
|
183
|
+
.edit-textarea {
|
|
184
|
+
background: #0f172a;
|
|
185
|
+
border: 1px solid #667eea;
|
|
186
|
+
color: #aaa;
|
|
187
|
+
padding: 0.5rem;
|
|
188
|
+
border-radius: 8px;
|
|
189
|
+
font-size: 1rem;
|
|
190
|
+
text-align: center;
|
|
191
|
+
width: 100%;
|
|
192
|
+
max-width: 400px;
|
|
193
|
+
resize: vertical;
|
|
194
|
+
min-height: 60px;
|
|
195
|
+
}
|
|
196
|
+
.edit-actions {
|
|
197
|
+
display: none;
|
|
198
|
+
gap: 0.5rem;
|
|
199
|
+
justify-content: center;
|
|
200
|
+
margin-top: 1rem;
|
|
201
|
+
}
|
|
202
|
+
.editing .edit-actions { display: flex; }
|
|
203
|
+
.save-btn { background: #22c55e; color: white; border: none; padding: 0.5rem 1.5rem; border-radius: 8px; cursor: pointer; }
|
|
204
|
+
.cancel-btn { background: #64748b; color: white; border: none; padding: 0.5rem 1.5rem; border-radius: 8px; cursor: pointer; }
|
|
205
|
+
.view-mode { display: block; }
|
|
206
|
+
.edit-mode { display: none; }
|
|
207
|
+
.editing .view-mode { display: none; }
|
|
208
|
+
.editing .edit-mode { display: block; }
|
|
209
|
+
.editing .edit-btn { display: none; }
|
|
210
|
+
</style>
|
|
211
|
+
</head>
|
|
212
|
+
<body>
|
|
213
|
+
<div class="card" id="profile-card">
|
|
214
|
+
<button class="edit-btn" onclick="toggleEdit()">Edit</button>
|
|
215
|
+
|
|
216
|
+
<div class="avatar" onclick="document.getElementById('avatar-input').click()">
|
|
217
|
+
${avatarUrl ? `<img src="${avatarUrl}" alt="avatar">` : '📦'}
|
|
218
|
+
<div class="avatar-overlay">Change</div>
|
|
219
|
+
</div>
|
|
220
|
+
<input type="file" id="avatar-input" class="avatar-input" accept="image/*" onchange="previewAvatar(this)">
|
|
221
|
+
|
|
222
|
+
<div class="view-mode"><h1>${config.displayName}</h1></div>
|
|
223
|
+
<div class="edit-mode">
|
|
224
|
+
<input type="text" class="edit-input" id="edit-name" value="${config.displayName}" placeholder="Display Name">
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<p class="handle">@${config.username}</p>
|
|
228
|
+
|
|
229
|
+
<div class="view-mode">
|
|
230
|
+
${config.summary ? `<p class="bio">${config.summary}</p>` : '<p class="bio" style="opacity:0.5">No bio yet</p>'}
|
|
231
|
+
</div>
|
|
232
|
+
<div class="edit-mode">
|
|
233
|
+
<textarea class="edit-textarea" id="edit-summary" placeholder="Write a short bio...">${config.summary || ''}</textarea>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
${config.nostrPubkey ? `<p class="view-mode" style="margin-bottom:1rem"><a href="nostr:${config.nostrPubkey}" style="color:#667eea;font-size:0.85rem">did:nostr:${config.nostrPubkey.slice(0,8)}...</a></p>` : ''}
|
|
237
|
+
<div class="edit-mode" style="margin-bottom:1rem">
|
|
238
|
+
<input type="text" class="edit-input" id="edit-nostr" value="${config.nostrPubkey || ''}" placeholder="Nostr pubkey (64-char hex)" style="font-size:0.85rem;max-width:400px">
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<div class="edit-actions">
|
|
242
|
+
<button class="save-btn" onclick="saveProfile()">Save</button>
|
|
243
|
+
<button class="cancel-btn" onclick="toggleEdit()">Cancel</button>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<div class="badge">🪪 Profile</div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<script>
|
|
250
|
+
let avatarFile = null;
|
|
251
|
+
function toggleEdit() {
|
|
252
|
+
document.getElementById('profile-card').classList.toggle('editing');
|
|
253
|
+
avatarFile = null;
|
|
254
|
+
}
|
|
255
|
+
function previewAvatar(input) {
|
|
256
|
+
if (input.files && input.files[0]) {
|
|
257
|
+
avatarFile = input.files[0];
|
|
258
|
+
const reader = new FileReader();
|
|
259
|
+
reader.onload = (e) => {
|
|
260
|
+
const avatar = document.querySelector('.avatar');
|
|
261
|
+
avatar.innerHTML = '<img src="' + e.target.result + '" alt="avatar"><div class="avatar-overlay">Change</div>';
|
|
262
|
+
};
|
|
263
|
+
reader.readAsDataURL(input.files[0]);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async function saveProfile() {
|
|
267
|
+
const formData = new FormData();
|
|
268
|
+
formData.append('displayName', document.getElementById('edit-name').value);
|
|
269
|
+
formData.append('summary', document.getElementById('edit-summary').value);
|
|
270
|
+
formData.append('nostrPubkey', document.getElementById('edit-nostr').value);
|
|
271
|
+
if (avatarFile) formData.append('avatar', avatarFile);
|
|
272
|
+
try {
|
|
273
|
+
const res = await fetch('/edit', { method: 'POST', body: formData });
|
|
274
|
+
if (res.ok) location.reload();
|
|
275
|
+
else alert('Failed to save');
|
|
276
|
+
} catch (err) { alert('Error: ' + err.message); }
|
|
277
|
+
}
|
|
278
|
+
</script>
|
|
279
|
+
</body>
|
|
280
|
+
</html>`
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Parse multipart form data
|
|
285
|
+
*/
|
|
286
|
+
function parseMultipart(body, boundary) {
|
|
287
|
+
const parts = {}
|
|
288
|
+
const boundaryBuffer = Buffer.from('--' + boundary)
|
|
289
|
+
const doubleCrlf = Buffer.from('\r\n\r\n')
|
|
290
|
+
|
|
291
|
+
let start = 0
|
|
292
|
+
while (true) {
|
|
293
|
+
const boundaryPos = body.indexOf(boundaryBuffer, start)
|
|
294
|
+
if (boundaryPos === -1) break
|
|
295
|
+
const afterBoundary = boundaryPos + boundaryBuffer.length
|
|
296
|
+
if (body.slice(afterBoundary, afterBoundary + 2).toString() === '--') break
|
|
297
|
+
const headersEnd = body.indexOf(doubleCrlf, afterBoundary)
|
|
298
|
+
if (headersEnd === -1) break
|
|
299
|
+
const headers = body.slice(afterBoundary + 2, headersEnd).toString()
|
|
300
|
+
const nextBoundary = body.indexOf(boundaryBuffer, headersEnd)
|
|
301
|
+
const content = body.slice(headersEnd + 4, nextBoundary - 2)
|
|
302
|
+
const nameMatch = headers.match(/name="([^"]+)"/)
|
|
303
|
+
const filenameMatch = headers.match(/filename="([^"]+)"/)
|
|
304
|
+
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/)
|
|
305
|
+
if (nameMatch) {
|
|
306
|
+
const name = nameMatch[1]
|
|
307
|
+
if (filenameMatch) {
|
|
308
|
+
parts[name] = {
|
|
309
|
+
filename: filenameMatch[1],
|
|
310
|
+
contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
|
|
311
|
+
data: content
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
parts[name] = content.toString()
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
start = nextBoundary
|
|
318
|
+
}
|
|
319
|
+
return parts
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Handle profile edit
|
|
324
|
+
*/
|
|
325
|
+
async function handleEdit(req, res) {
|
|
326
|
+
const chunks = []
|
|
327
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
328
|
+
const body = Buffer.concat(chunks)
|
|
329
|
+
|
|
330
|
+
const contentType = req.headers['content-type'] || ''
|
|
331
|
+
const boundary = contentType.split('boundary=')[1]
|
|
332
|
+
if (!boundary) {
|
|
333
|
+
res.writeHead(400)
|
|
334
|
+
return res.end('Bad request')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const parts = parseMultipart(body, boundary)
|
|
338
|
+
let updated = false
|
|
339
|
+
|
|
340
|
+
if (parts.displayName !== undefined) {
|
|
341
|
+
config.displayName = parts.displayName
|
|
342
|
+
updated = true
|
|
343
|
+
}
|
|
344
|
+
if (parts.summary !== undefined) {
|
|
345
|
+
config.summary = parts.summary
|
|
346
|
+
updated = true
|
|
347
|
+
}
|
|
348
|
+
if (parts.nostrPubkey !== undefined) {
|
|
349
|
+
config.nostrPubkey = parts.nostrPubkey || undefined
|
|
350
|
+
updated = true
|
|
351
|
+
}
|
|
352
|
+
if (parts.avatar && parts.avatar.data && parts.avatar.data.length > 0) {
|
|
353
|
+
if (!existsSync('public')) mkdirSync('public', { recursive: true })
|
|
354
|
+
const ext = parts.avatar.contentType?.includes('png') ? 'png' :
|
|
355
|
+
parts.avatar.contentType?.includes('gif') ? 'gif' : 'jpg'
|
|
356
|
+
const filename = `avatar.${ext}`
|
|
357
|
+
writeFileSync(join('public', filename), parts.avatar.data)
|
|
358
|
+
config.avatar = filename
|
|
359
|
+
updated = true
|
|
360
|
+
console.log(`📷 Avatar saved: public/${filename}`)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (updated) {
|
|
364
|
+
writeFileSync('fedbox.json', JSON.stringify(config, null, 2))
|
|
365
|
+
console.log('✅ Profile updated')
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
res.writeHead(200)
|
|
369
|
+
res.end('OK')
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Start the profile server
|
|
374
|
+
*/
|
|
375
|
+
export async function startProfileServer(port = 3000) {
|
|
376
|
+
loadConfig()
|
|
377
|
+
const baseUrl = getBaseUrl(port)
|
|
378
|
+
const actor = buildActor(baseUrl)
|
|
379
|
+
|
|
380
|
+
const server = createServer(async (req, res) => {
|
|
381
|
+
const url = new URL(req.url, baseUrl)
|
|
382
|
+
const path = url.pathname
|
|
383
|
+
const accept = req.headers.accept || ''
|
|
384
|
+
const isAP = accept.includes('activity+json') || accept.includes('ld+json')
|
|
385
|
+
|
|
386
|
+
// CORS
|
|
387
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
388
|
+
res.setHeader('Access-Control-Allow-Headers', '*')
|
|
389
|
+
if (req.method === 'OPTIONS') {
|
|
390
|
+
res.writeHead(204)
|
|
391
|
+
return res.end()
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
console.log(`${req.method} ${path}`)
|
|
395
|
+
|
|
396
|
+
// Profile edit
|
|
397
|
+
if (path === '/edit' && req.method === 'POST') {
|
|
398
|
+
return handleEdit(req, res)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Static files
|
|
402
|
+
if (path.startsWith('/public/')) {
|
|
403
|
+
const filePath = join(process.cwd(), path)
|
|
404
|
+
if (existsSync(filePath)) {
|
|
405
|
+
const ext = extname(filePath)
|
|
406
|
+
const mimeTypes = {
|
|
407
|
+
'.js': 'application/javascript',
|
|
408
|
+
'.css': 'text/css',
|
|
409
|
+
'.png': 'image/png',
|
|
410
|
+
'.jpg': 'image/jpeg',
|
|
411
|
+
'.gif': 'image/gif',
|
|
412
|
+
'.svg': 'image/svg+xml'
|
|
413
|
+
}
|
|
414
|
+
res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
|
|
415
|
+
return res.end(readFileSync(filePath))
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Profile (root or /username)
|
|
420
|
+
if (path === '/' || path === `/${config.username}`) {
|
|
421
|
+
if (isAP) {
|
|
422
|
+
res.setHeader('Content-Type', 'application/activity+json')
|
|
423
|
+
return res.end(JSON.stringify(actor, null, 2))
|
|
424
|
+
}
|
|
425
|
+
res.setHeader('Content-Type', 'text/html')
|
|
426
|
+
return res.end(renderProfile(actor, baseUrl))
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
res.writeHead(404)
|
|
430
|
+
res.end('Not found')
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
server.listen(port, () => {
|
|
434
|
+
console.log(`
|
|
435
|
+
🪪 Profile server running!
|
|
436
|
+
|
|
437
|
+
Profile: http://localhost:${port}/
|
|
438
|
+
WebID: http://localhost:${port}/${config.username}#me
|
|
439
|
+
JSON-LD: curl -H "Accept: application/activity+json" http://localhost:${port}/
|
|
440
|
+
|
|
441
|
+
Press Ctrl+C to stop
|
|
442
|
+
`)
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export default { startProfileServer }
|
package/lib/server.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { createServer } from 'http'
|
|
8
|
-
import { readFileSync, existsSync } from 'fs'
|
|
8
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs'
|
|
9
9
|
import { join, extname } from 'path'
|
|
10
10
|
import { profile, auth, webfinger, outbox } from 'microfed'
|
|
11
11
|
import {
|
|
@@ -77,7 +77,7 @@ function buildActor() {
|
|
|
77
77
|
const actorId = `${profileUrl}#me`
|
|
78
78
|
|
|
79
79
|
// Build actor manually for more control over structure
|
|
80
|
-
|
|
80
|
+
const actor = {
|
|
81
81
|
'@context': [
|
|
82
82
|
'https://www.w3.org/ns/activitystreams',
|
|
83
83
|
'https://w3id.org/security/v1'
|
|
@@ -101,6 +101,28 @@ function buildActor() {
|
|
|
101
101
|
publicKeyPem: config.publicKey
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
// Add icon if avatar is set
|
|
106
|
+
if (config.avatar) {
|
|
107
|
+
actor.icon = {
|
|
108
|
+
type: 'Image',
|
|
109
|
+
mediaType: config.avatar.endsWith('.png') ? 'image/png' :
|
|
110
|
+
config.avatar.endsWith('.gif') ? 'image/gif' : 'image/jpeg',
|
|
111
|
+
url: `${baseUrl}/public/${config.avatar}`
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Add alsoKnownAs for identity linking (Nostr, etc.)
|
|
116
|
+
const alsoKnownAs = []
|
|
117
|
+
if (config.nostrPubkey) {
|
|
118
|
+
// Format as did:nostr per https://nostrcg.github.io/did-nostr/
|
|
119
|
+
alsoKnownAs.push(`did:nostr:${config.nostrPubkey}`)
|
|
120
|
+
}
|
|
121
|
+
if (alsoKnownAs.length > 0) {
|
|
122
|
+
actor.alsoKnownAs = alsoKnownAs
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return actor
|
|
104
126
|
}
|
|
105
127
|
|
|
106
128
|
/**
|
|
@@ -384,7 +406,7 @@ async function handleRequest(req, res) {
|
|
|
384
406
|
version: '2.1',
|
|
385
407
|
software: {
|
|
386
408
|
name: 'fedbox',
|
|
387
|
-
version: '0.0.
|
|
409
|
+
version: '0.0.8',
|
|
388
410
|
repository: 'https://github.com/micro-fed/fedbox'
|
|
389
411
|
},
|
|
390
412
|
protocols: ['activitypub'],
|
|
@@ -421,6 +443,11 @@ async function handleRequest(req, res) {
|
|
|
421
443
|
}
|
|
422
444
|
}
|
|
423
445
|
|
|
446
|
+
// Profile edit
|
|
447
|
+
if (path === `/${config.username}/edit` && req.method === 'POST') {
|
|
448
|
+
return handleProfileEdit(req, res)
|
|
449
|
+
}
|
|
450
|
+
|
|
424
451
|
// Inbox
|
|
425
452
|
if (path === `/${config.username}/inbox`) {
|
|
426
453
|
return handleInbox(req, res)
|
|
@@ -581,13 +608,137 @@ async function handleInbox(req, res) {
|
|
|
581
608
|
}
|
|
582
609
|
|
|
583
610
|
/**
|
|
584
|
-
*
|
|
611
|
+
* Handle profile edit POST (multipart form data)
|
|
612
|
+
*/
|
|
613
|
+
async function handleProfileEdit(req, res) {
|
|
614
|
+
const chunks = []
|
|
615
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
616
|
+
const body = Buffer.concat(chunks)
|
|
617
|
+
|
|
618
|
+
// Parse multipart form data
|
|
619
|
+
const contentType = req.headers['content-type'] || ''
|
|
620
|
+
const boundary = contentType.split('boundary=')[1]
|
|
621
|
+
|
|
622
|
+
if (!boundary) {
|
|
623
|
+
res.writeHead(400)
|
|
624
|
+
return res.end('Bad request')
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const parts = parseMultipart(body, boundary)
|
|
628
|
+
let updated = false
|
|
629
|
+
|
|
630
|
+
// Update display name
|
|
631
|
+
if (parts.displayName !== undefined) {
|
|
632
|
+
config.displayName = parts.displayName
|
|
633
|
+
updated = true
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Update summary
|
|
637
|
+
if (parts.summary !== undefined) {
|
|
638
|
+
config.summary = parts.summary
|
|
639
|
+
updated = true
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Update nostr pubkey
|
|
643
|
+
if (parts.nostrPubkey !== undefined) {
|
|
644
|
+
config.nostrPubkey = parts.nostrPubkey || undefined
|
|
645
|
+
updated = true
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Handle avatar upload
|
|
649
|
+
if (parts.avatar && parts.avatar.data && parts.avatar.data.length > 0) {
|
|
650
|
+
// Ensure public directory exists
|
|
651
|
+
if (!existsSync('public')) {
|
|
652
|
+
mkdirSync('public', { recursive: true })
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Determine file extension from content type
|
|
656
|
+
const ext = parts.avatar.contentType?.includes('png') ? 'png' :
|
|
657
|
+
parts.avatar.contentType?.includes('gif') ? 'gif' : 'jpg'
|
|
658
|
+
const filename = `avatar.${ext}`
|
|
659
|
+
|
|
660
|
+
writeFileSync(join('public', filename), parts.avatar.data)
|
|
661
|
+
config.avatar = filename
|
|
662
|
+
updated = true
|
|
663
|
+
console.log(`📷 Avatar saved: public/${filename}`)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (updated) {
|
|
667
|
+
// Save config
|
|
668
|
+
writeFileSync('fedbox.json', JSON.stringify(config, null, 2))
|
|
669
|
+
// Rebuild actor with new config
|
|
670
|
+
actor = buildActor()
|
|
671
|
+
console.log('✅ Profile updated')
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
res.writeHead(200)
|
|
675
|
+
res.end('OK')
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Parse multipart form data
|
|
680
|
+
*/
|
|
681
|
+
function parseMultipart(body, boundary) {
|
|
682
|
+
const parts = {}
|
|
683
|
+
const boundaryBuffer = Buffer.from('--' + boundary)
|
|
684
|
+
const crlf = Buffer.from('\r\n')
|
|
685
|
+
const doubleCrlf = Buffer.from('\r\n\r\n')
|
|
686
|
+
|
|
687
|
+
let start = 0
|
|
688
|
+
while (true) {
|
|
689
|
+
// Find next boundary
|
|
690
|
+
const boundaryPos = body.indexOf(boundaryBuffer, start)
|
|
691
|
+
if (boundaryPos === -1) break
|
|
692
|
+
|
|
693
|
+
// Check for end boundary
|
|
694
|
+
const afterBoundary = boundaryPos + boundaryBuffer.length
|
|
695
|
+
if (body.slice(afterBoundary, afterBoundary + 2).toString() === '--') break
|
|
696
|
+
|
|
697
|
+
// Find headers end
|
|
698
|
+
const headersEnd = body.indexOf(doubleCrlf, afterBoundary)
|
|
699
|
+
if (headersEnd === -1) break
|
|
700
|
+
|
|
701
|
+
const headers = body.slice(afterBoundary + 2, headersEnd).toString()
|
|
702
|
+
|
|
703
|
+
// Find next boundary for content end
|
|
704
|
+
const nextBoundary = body.indexOf(boundaryBuffer, headersEnd)
|
|
705
|
+
const content = body.slice(headersEnd + 4, nextBoundary - 2) // -2 for CRLF before boundary
|
|
706
|
+
|
|
707
|
+
// Parse headers
|
|
708
|
+
const nameMatch = headers.match(/name="([^"]+)"/)
|
|
709
|
+
const filenameMatch = headers.match(/filename="([^"]+)"/)
|
|
710
|
+
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/)
|
|
711
|
+
|
|
712
|
+
if (nameMatch) {
|
|
713
|
+
const name = nameMatch[1]
|
|
714
|
+
if (filenameMatch) {
|
|
715
|
+
// File upload
|
|
716
|
+
parts[name] = {
|
|
717
|
+
filename: filenameMatch[1],
|
|
718
|
+
contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
|
|
719
|
+
data: content
|
|
720
|
+
}
|
|
721
|
+
} else {
|
|
722
|
+
// Regular field
|
|
723
|
+
parts[name] = content.toString()
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
start = nextBoundary
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return parts
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Render HTML profile with embedded JSON-LD and inline editing
|
|
585
735
|
*/
|
|
586
736
|
function renderProfile() {
|
|
587
737
|
const followers = getFollowerCount()
|
|
588
738
|
const following = getFollowingCount()
|
|
589
739
|
const posts = getPosts(10)
|
|
590
740
|
const profileUrl = `${getBaseUrl()}/${config.username}`
|
|
741
|
+
const avatarUrl = config.avatar ? `/public/${config.avatar}` : ''
|
|
591
742
|
|
|
592
743
|
return `<!DOCTYPE html>
|
|
593
744
|
<html>
|
|
@@ -616,7 +767,21 @@ ${JSON.stringify(actor, null, 2)}
|
|
|
616
767
|
padding: 2rem;
|
|
617
768
|
text-align: center;
|
|
618
769
|
margin-bottom: 1.5rem;
|
|
770
|
+
position: relative;
|
|
619
771
|
}
|
|
772
|
+
.edit-btn {
|
|
773
|
+
position: absolute;
|
|
774
|
+
top: 1rem;
|
|
775
|
+
right: 1rem;
|
|
776
|
+
background: #667eea;
|
|
777
|
+
color: white;
|
|
778
|
+
border: none;
|
|
779
|
+
padding: 0.5rem 1rem;
|
|
780
|
+
border-radius: 8px;
|
|
781
|
+
cursor: pointer;
|
|
782
|
+
font-size: 0.9rem;
|
|
783
|
+
}
|
|
784
|
+
.edit-btn:hover { background: #5a6fd6; }
|
|
620
785
|
.avatar {
|
|
621
786
|
width: 120px;
|
|
622
787
|
height: 120px;
|
|
@@ -627,7 +792,26 @@ ${JSON.stringify(actor, null, 2)}
|
|
|
627
792
|
align-items: center;
|
|
628
793
|
justify-content: center;
|
|
629
794
|
font-size: 3rem;
|
|
795
|
+
overflow: hidden;
|
|
796
|
+
cursor: pointer;
|
|
797
|
+
position: relative;
|
|
798
|
+
}
|
|
799
|
+
.avatar img {
|
|
800
|
+
width: 100%;
|
|
801
|
+
height: 100%;
|
|
802
|
+
object-fit: cover;
|
|
803
|
+
}
|
|
804
|
+
.avatar-overlay {
|
|
805
|
+
display: none;
|
|
806
|
+
position: absolute;
|
|
807
|
+
inset: 0;
|
|
808
|
+
background: rgba(0,0,0,0.5);
|
|
809
|
+
align-items: center;
|
|
810
|
+
justify-content: center;
|
|
811
|
+
font-size: 1rem;
|
|
630
812
|
}
|
|
813
|
+
.editing .avatar-overlay { display: flex; }
|
|
814
|
+
.avatar-input { display: none; }
|
|
631
815
|
h1 { margin: 0 0 0.5rem; }
|
|
632
816
|
.handle { color: #888; margin-bottom: 1rem; }
|
|
633
817
|
.bio { color: #aaa; margin-bottom: 1.5rem; }
|
|
@@ -654,14 +838,93 @@ ${JSON.stringify(actor, null, 2)}
|
|
|
654
838
|
.post-content { margin-bottom: 0.5rem; }
|
|
655
839
|
.post-meta { color: #666; font-size: 0.8rem; }
|
|
656
840
|
.post a { color: #667eea; text-decoration: none; }
|
|
841
|
+
/* Edit mode */
|
|
842
|
+
.edit-input {
|
|
843
|
+
background: #0f172a;
|
|
844
|
+
border: 1px solid #667eea;
|
|
845
|
+
color: #eee;
|
|
846
|
+
padding: 0.5rem;
|
|
847
|
+
border-radius: 8px;
|
|
848
|
+
font-size: inherit;
|
|
849
|
+
text-align: center;
|
|
850
|
+
width: 100%;
|
|
851
|
+
max-width: 300px;
|
|
852
|
+
}
|
|
853
|
+
.edit-input:focus { outline: none; border-color: #818cf8; }
|
|
854
|
+
.edit-textarea {
|
|
855
|
+
background: #0f172a;
|
|
856
|
+
border: 1px solid #667eea;
|
|
857
|
+
color: #aaa;
|
|
858
|
+
padding: 0.5rem;
|
|
859
|
+
border-radius: 8px;
|
|
860
|
+
font-size: 1rem;
|
|
861
|
+
text-align: center;
|
|
862
|
+
width: 100%;
|
|
863
|
+
max-width: 400px;
|
|
864
|
+
resize: vertical;
|
|
865
|
+
min-height: 60px;
|
|
866
|
+
}
|
|
867
|
+
.edit-actions {
|
|
868
|
+
display: none;
|
|
869
|
+
gap: 0.5rem;
|
|
870
|
+
justify-content: center;
|
|
871
|
+
margin-top: 1rem;
|
|
872
|
+
}
|
|
873
|
+
.editing .edit-actions { display: flex; }
|
|
874
|
+
.save-btn {
|
|
875
|
+
background: #22c55e;
|
|
876
|
+
color: white;
|
|
877
|
+
border: none;
|
|
878
|
+
padding: 0.5rem 1.5rem;
|
|
879
|
+
border-radius: 8px;
|
|
880
|
+
cursor: pointer;
|
|
881
|
+
}
|
|
882
|
+
.cancel-btn {
|
|
883
|
+
background: #64748b;
|
|
884
|
+
color: white;
|
|
885
|
+
border: none;
|
|
886
|
+
padding: 0.5rem 1.5rem;
|
|
887
|
+
border-radius: 8px;
|
|
888
|
+
cursor: pointer;
|
|
889
|
+
}
|
|
890
|
+
.view-mode { display: block; }
|
|
891
|
+
.edit-mode { display: none; }
|
|
892
|
+
.editing .view-mode { display: none; }
|
|
893
|
+
.editing .edit-mode { display: block; }
|
|
894
|
+
.editing .edit-btn { display: none; }
|
|
657
895
|
</style>
|
|
658
896
|
</head>
|
|
659
897
|
<body>
|
|
660
|
-
<div class="card">
|
|
661
|
-
<
|
|
662
|
-
|
|
898
|
+
<div class="card" id="profile-card">
|
|
899
|
+
<button class="edit-btn" onclick="toggleEdit()">Edit</button>
|
|
900
|
+
|
|
901
|
+
<div class="avatar" onclick="document.getElementById('avatar-input').click()">
|
|
902
|
+
${avatarUrl ? `<img src="${avatarUrl}" alt="avatar">` : '📦'}
|
|
903
|
+
<div class="avatar-overlay">Change</div>
|
|
904
|
+
</div>
|
|
905
|
+
<input type="file" id="avatar-input" class="avatar-input" accept="image/*" onchange="previewAvatar(this)">
|
|
906
|
+
|
|
907
|
+
<div class="view-mode">
|
|
908
|
+
<h1>${config.displayName}</h1>
|
|
909
|
+
</div>
|
|
910
|
+
<div class="edit-mode">
|
|
911
|
+
<input type="text" class="edit-input" id="edit-name" value="${config.displayName}" placeholder="Display Name">
|
|
912
|
+
</div>
|
|
913
|
+
|
|
663
914
|
<p class="handle">@${config.username}@${getDomain()}</p>
|
|
664
|
-
|
|
915
|
+
|
|
916
|
+
<div class="view-mode">
|
|
917
|
+
${config.summary ? `<p class="bio">${config.summary}</p>` : '<p class="bio" style="opacity:0.5">No bio yet</p>'}
|
|
918
|
+
</div>
|
|
919
|
+
<div class="edit-mode">
|
|
920
|
+
<textarea class="edit-textarea" id="edit-summary" placeholder="Write a short bio...">${config.summary || ''}</textarea>
|
|
921
|
+
</div>
|
|
922
|
+
|
|
923
|
+
${config.nostrPubkey ? `<p class="nostr-link view-mode" style="margin-bottom:1rem"><a href="nostr:${config.nostrPubkey}" style="color:#667eea;font-size:0.85rem">did:nostr:${config.nostrPubkey.slice(0,8)}...</a></p>` : ''}
|
|
924
|
+
<div class="edit-mode" style="margin-bottom:1rem">
|
|
925
|
+
<input type="text" class="edit-input" id="edit-nostr" value="${config.nostrPubkey || ''}" placeholder="Nostr pubkey (64-char hex)" style="font-size:0.85rem;max-width:400px">
|
|
926
|
+
</div>
|
|
927
|
+
|
|
665
928
|
<div class="stats">
|
|
666
929
|
<div class="stat">
|
|
667
930
|
<div class="stat-num">${followers}</div>
|
|
@@ -676,6 +939,12 @@ ${JSON.stringify(actor, null, 2)}
|
|
|
676
939
|
<div class="stat-label">Posts</div>
|
|
677
940
|
</div>
|
|
678
941
|
</div>
|
|
942
|
+
|
|
943
|
+
<div class="edit-actions">
|
|
944
|
+
<button class="save-btn" onclick="saveProfile()">Save</button>
|
|
945
|
+
<button class="cancel-btn" onclick="toggleEdit()">Cancel</button>
|
|
946
|
+
</div>
|
|
947
|
+
|
|
679
948
|
<div class="badge">📦 Powered by Fedbox</div>
|
|
680
949
|
</div>
|
|
681
950
|
|
|
@@ -691,6 +960,51 @@ ${JSON.stringify(actor, null, 2)}
|
|
|
691
960
|
`).join('')}
|
|
692
961
|
</div>
|
|
693
962
|
` : ''}
|
|
963
|
+
|
|
964
|
+
<script>
|
|
965
|
+
let avatarFile = null;
|
|
966
|
+
|
|
967
|
+
function toggleEdit() {
|
|
968
|
+
document.getElementById('profile-card').classList.toggle('editing');
|
|
969
|
+
avatarFile = null;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function previewAvatar(input) {
|
|
973
|
+
if (input.files && input.files[0]) {
|
|
974
|
+
avatarFile = input.files[0];
|
|
975
|
+
const reader = new FileReader();
|
|
976
|
+
reader.onload = (e) => {
|
|
977
|
+
const avatar = document.querySelector('.avatar');
|
|
978
|
+
avatar.innerHTML = '<img src="' + e.target.result + '" alt="avatar"><div class="avatar-overlay">Change</div>';
|
|
979
|
+
};
|
|
980
|
+
reader.readAsDataURL(input.files[0]);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function saveProfile() {
|
|
985
|
+
const formData = new FormData();
|
|
986
|
+
formData.append('displayName', document.getElementById('edit-name').value);
|
|
987
|
+
formData.append('summary', document.getElementById('edit-summary').value);
|
|
988
|
+
formData.append('nostrPubkey', document.getElementById('edit-nostr').value);
|
|
989
|
+
if (avatarFile) {
|
|
990
|
+
formData.append('avatar', avatarFile);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
try {
|
|
994
|
+
const res = await fetch('/${config.username}/edit', {
|
|
995
|
+
method: 'POST',
|
|
996
|
+
body: formData
|
|
997
|
+
});
|
|
998
|
+
if (res.ok) {
|
|
999
|
+
location.reload();
|
|
1000
|
+
} else {
|
|
1001
|
+
alert('Failed to save');
|
|
1002
|
+
}
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
alert('Error: ' + err.message);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
</script>
|
|
694
1008
|
</body>
|
|
695
1009
|
</html>`
|
|
696
1010
|
}
|