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 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
- return {
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.6',
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
- * Render HTML profile with embedded JSON-LD
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
- <div class="avatar">📦</div>
662
- <h1>${config.displayName}</h1>
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
- ${config.summary ? `<p class="bio">${config.summary}</p>` : ''}
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedbox",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Zero to Fediverse in 60 seconds",
5
5
  "type": "module",
6
6
  "main": "lib/server.js",